前言
目前市面上图片选择器大致可以分为以下两类:
- 原生实现UI
- Flutter实现UI
两种类型的对比如下
类型 | 优点 | 缺点 |
---|---|---|
原生实现UI | 性能,流畅度很好 | 双端分别需要实现UI及相册逻辑 |
Flutter实现UI | 双端UI统一 | 双端需要分别实现相册逻辑,流畅度以及性能有瓶颈 |
简介
本文是基于第二种也就是Flutter实现UI完成的图片选择器链接在此,使用到的库有
- photo_gallery用于获取相关相册内容,这里做了一些改动,所以对其进行的源码依赖
- image_picker用于拍照
- permission_handler用于相册,相机等权限处理
目前实现的功能有
- 图片、视频共存与互斥显示
- 限制选择item的数量
- 每行展示item的数量
- 视频时长限制
- 拍照
- 拍照之后默认选中
- 相册列表
- 切换相册
- 切换选择器大小窗
部分截图
具体实现
构造方法
由于要实时共享已选的资源给调用者,还要与外部共享切换大小窗等数据,在这里使用ValueNotifier,可以轻松处理共享数据,构造方法里提供了一些必选以及可选参数,可以根据具体场景做相应的处理
const MediaPickerPage(
{Key? key,
this.type = allType, //媒体类型
required this.maxSelectCount, //最大选择数量
this.crossAxisCount = 4, //每行展示的数量
required this.lastSelectMedia, //与调用者共享的已选资源
this.defaultShowHeight = 260, //半弹窗默认高度
this.maxHeight = 600, //弹窗最大高度
this.maxVideoDuration, //选择视频最大时长
this.showBigViewNotifier, //切换大小窗监听
this.canSelectedVideoNotifier, //是否可选视频监听
this.actionCallBack, //一些自定义事件回调
this.mediaPickerItemClickCallBack, //点击item的回调
this.mediaPickerMediumInfoListCreatedCallBack}) //获取到数据的回调
: super(key: key);
复制代码
数据获取
获取默认相册列表里的数据,在这里基于permission_handler做了权限判断,具体的业务场景其实未必会在这里判断,可能在之前就已经做了处理
void _obtainMediaInfo({bool isAdd = false}) async {
if (await Permission.photos.request().isGranted) {
MediumType? type = jsonToMediumType(widget.type);
if (type != null) {
List<MediumInfo> list = await FlutterPluginMediaPicker.listMedium(
_thumbnailSize, type,
maxVideoDuration: widget.maxVideoDuration ?? defaultMaxVideoDuration);
setState(() {
_mediumInfoList.clear();
_mediumInfoList.addAll(list);
///相册列表数据获取完成后回调
if (widget.mediaPickerMediumInfoListCreatedCallBack != null) {
widget.mediaPickerMediumInfoListCreatedCallBack!(_mediumInfoList);
}
if (isAdd == true) {
///拍完照后,重置key,确保列表刷新
_uniqueKey = UniqueKey();
_handleTakePhotoResult();
}
});
}
}
}
复制代码
通过FlutterPluginMediaPicker.listMedium获取到相应相册的内容,之后刷新GridView,这里其实就是调用的photo_gallery的listMedia方法,默认获取全部资源,就把albumId给到默认值"__ALL__"
class PhotoGalleryExtension extends PhotoGallery {
static const MethodChannel _channel = MethodChannel('photo_gallery');
static const String _allAlbumId = "__ALL__";
static const int _defaultTotal = 1 << 16; // 给一个极大值
static Future<List<Medium>> listMedium(
{MediumType mediumType = MediumType.all}) async {
final json = await _channel.invokeMethod('listMedia', {
'albumId': _allAlbumId,
'mediumType': mediumTypeToJson(mediumType),
'newest': true,
'total': _defaultTotal,
});
return json['items'].map<Medium>((x) => Medium.fromJson(x)).toList();
}
复制代码
数据展示
其中GridView.builder是来展示列表数据的,key: _uniqueKey
是为了能够在拍照之后成功刷新列表,让拍照获得的照片放在列表第一位。AlbumListWidget是用来展示相册列表数据的,选择相应的相册之后会在onAlbumClickedCallBack回调中更新列表数据,展示相应的相册资源。
Widget _getGridView() {
return Expanded(
child: Container(
color: Colors.white,
child: Stack(
children: [
GridView.builder(
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
key: _uniqueKey,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.crossAxisCount!,
crossAxisSpacing: 3,
mainAxisSpacing: 3,
childAspectRatio: 1,
),
cacheExtent: 1,
padding: const EdgeInsets.only(left: 3, right: 3),
itemCount: _mediumInfoList.length + 1 + _getPlaceHolderCount(),
scrollDirection: Axis.vertical,
itemBuilder: (context, index) {
return _buildItem(index);
}),
AlbumListWidget(
type: jsonToMediumType(widget.type) ?? MediumType.all,
maxHeight: widget.maxHeight!,
showValueNotifier: showAlbumListNotifier,
onAlbumClickedCallBack: (Album album) {
albumName = album.name ?? albumName;
_loadAlbumData(album);
},
),
],
),
),
);
}
复制代码
如果我们获取大量图片的原图bytes的话对于channel的压力会很大,所以每一个item获取图片数据的时候都会由原生压缩之后获取到相应的数据,同时使用photo_gallery中的ThumbnailProvider展示图片,这里会由系统imageCache处理缓存,之后会说对于系统缓存的处理,因为缓存是有上限的,对于资源数量巨大的情况下,需要做一些特殊处理,这里贴一下ThumbnailProvider的源码
part of photogallery;
/// Fetches the given medium thumbnail from the gallery.
class ThumbnailProvider extends ImageProvider<ThumbnailProvider> {
const ThumbnailProvider({
required this.mediumId,
this.mediumType,
this.height,
this.width,
this.highQuality = false,
});
final String mediumId;
final MediumType? mediumType;
final int? height;
final int? width;
final bool? highQuality;
@override
ImageStreamCompleter load(key, decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: 1.0,
informationCollector: () sync* {
yield ErrorDescription('Id: $mediumId');
},
);
}
Future<ui.Codec> _loadAsync(
ThumbnailProvider key, DecoderCallback decode) async {
assert(key == this);
final bytes = await PhotoGallery.getThumbnail(
mediumId: mediumId,
mediumType: mediumType,
height: height,
width: width,
highQuality: highQuality,
);
return await decode(Uint8List.fromList(bytes));
}
@override
Future<ThumbnailProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<ThumbnailProvider>(this);
}
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType) return false;
final ThumbnailProvider typedOther = other;
return mediumId == typedOther.mediumId;
}
@override
int get hashCode => mediumId.hashCode;
@override
String toString() => '$runtimeType("$mediumId")';
}
复制代码
拍照
拍照时调用的image_picker的方法,其实image_picker拍照完成之后不会把图片存到系统相册中,而是存在了缓存中,所以我们需要先把图片存入相册之后刷新列表
调用拍照方法并存入相册
static Future<String> takePhoto(
{double? maxWidth, double? maxHeight, int? quality}) async {
final ImagePicker _picker = ImagePicker();
final PickedFile? pickedFile = await _picker.getImage(
source: ImageSource.camera,
maxWidth: maxWidth,
maxHeight: maxHeight,
imageQuality: quality,
);
if (pickedFile != null) {
var data = await pickedFile.readAsBytes();
//写入系统相册
final result = await ImageGallerySaver.saveImage(Uint8List.fromList(data),
quality: 60, name: "image${DateTime.now().microsecond.toString()}",path:pickedFile.path);
debugPrint('保存的文件路径为${pickedFile.path.toString()}');
return pickedFile.path;
} else {
return '';
}
}
复制代码
拍照完成存入相册之后,重新获取相册数据并更新UI
void _takePhoto() async {
if (await Permission.camera.request().isGranted) {
var imgPath = await MediaPicker.takePhoto(quality: 100);
if (imgPath.isNotEmpty) {
///Android平台存入数据库,读取相册数据,有一定的延迟
if (Platform.isAndroid) {
Future.delayed(const Duration(milliseconds: 600), () {
_obtainMediaInfo(isAdd: true);
});
} else {
_obtainMediaInfo(isAdd: true);
}
}
} else {
_actionCallBack(MediaPickerActionType.camera);
}
}
复制代码
item的选中和取消选中
这里无非就是对相应的数据类型做判断,决定是否添加或者从lastSelectMedia中删除相应的数据
void _onItemAddCallBack(MediumInfo mediumInfo) async {
var values = widget.lastSelectMedia.value;
bool isContains = false;
late MediumInfo tempMedium;
values.forEach((element) {
if (element.id == mediumInfo.id) {
isContains = true;
tempMedium = element;
}
});
if (isContains) {
widget.lastSelectMedia.remove(tempMedium);
} else {
if (!isAddingItem) {
isAddingItem = true;
final bytes = await PhotoGallery.getThumbnail(
mediumId: mediumInfo.id,
mediumType: mediumInfo.medium.mediumType,
width: _thumbnailSize!.width.toInt() * 2,
height: _thumbnailSize!.height.toInt() * 2,
highQuality: true);
if (mediumInfo.type == MediumType.video) {
//添加文件路径
final file = await PhotoGallery.getFile(mediumId: mediumInfo.id);
widget.lastSelectMedia.add(MediumInfo(mediumInfo.medium,
bytes: Uint8List.fromList(bytes),
file: file.file,
orientation: file.orientation,
metaWidth: file.metaWidth,
metaHeight: file.metaHeight));
isAddingItem = false;
} else {
widget.lastSelectMedia.add(
MediumInfo(mediumInfo.medium, bytes: Uint8List.fromList(bytes)));
isAddingItem = false;
}
}
}
}
复制代码
关于缓存imageCache
在imageCache源码中可以看到,缓存默认上限是1000张图,100MB的内存
const int _kDefaultSize = 1000;
const int _kDefaultSizeBytes = 100 << 20; // 100 MiB
复制代码
这个对于长列表来说肯定体验是不友好的,调整之后会好一些。 在initState时修改imageCache
PaintingBinding.instance?.imageCache?.maximumSize = 2000; //图片缓存数量上限改成4000张
PaintingBinding.instance?.imageCache?.maximumSizeBytes =
500 * 1024 * 1024; //图片缓存大小上限改成1000M
复制代码
在dispose时imageCache清除
@override
void dispose() {
super.dispose();
PaintingBinding.instance?.imageCache?.clear();
PaintingBinding.instance?.imageCache?.maximumSize =
1000; //图片缓存数量上限改成系统默认1000张
PaintingBinding.instance?.imageCache?.maximumSizeBytes =
100 * 1024 * 1024; //图片缓存大小上限改成系统默认100M
PhotoGallery.cleanCache();
}
复制代码
但是这一操作其实治标不治本,只是改大了缓存的上限,但是对于缓存的优化还是远远不够的,如果手机里面照片特别多的话肯定会突破上限,那么根据imageCache的LRU算法,已经加载的图片还会需要重新渲染,这个是无法达到系统的UI效果的最大弊端,所以想要无限接近原生,这里依然有很多需要改进的点。