Flutter图片选择器尝试

前言

目前市面上图片选择器大致可以分为以下两类:

  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. 切换选择器大小窗

部分截图

WechatIMG29.jpeg

具体实现

构造方法

由于要实时共享已选的资源给调用者,还要与外部共享切换大小窗等数据,在这里使用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效果的最大弊端,所以想要无限接近原生,这里依然有很多需要改进的点。

猜你喜欢

转载自juejin.im/post/7109412573061546015
今日推荐