前言

现在市面上图片挑选器大致能够分为以下两类:

  1. 原生完结UI
  2. Flutter完结UI

两种类型的对比如下

类型 优点 缺点
原生完结UI 功能,流通度很好 双端别离需求完结UI及相册逻辑
Flutter完结UI 双端UI统一 双端需求别离完结相册逻辑,流通度以及功能有瓶颈

简介

本文是根据第二种也便是Flutter完结UI完结的图片挑选器链接在此,使用到的库有

  1. photo_gallery用于获取相关相册内容,这儿做了一些改动,所以对其进行的源码依赖
  2. image_picker用于摄影
  3. permission_handler用于相册,相机等权限处理

现在完结的功能有

  1. 图片、视频共存与互斥显示
  2. 约束挑选item的数量
  3. 每行展现item的数量
  4. 视频时长约束
  5. 摄影
  6. 摄影之后默许选中
  7. 相册列表
  8. 切换相册
  9. 切换挑选器巨细窗

部分截图

Flutter图片选择器尝试

详细完结

构造办法

由于要实时同享已选的资源给调用者,还要与外部同享切换巨细窗等数据,在这儿使用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_gallerylistMedia办法,默许获取全部资源,就把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

disposeimageCache铲除

@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作用的最大弊端,所以想要无限挨近原生,这儿仍然有很多需求改进的点。