乐趣区

一个优秀的可定制化Flutter相册组件看这一篇就够了

背景

在做图片、视频相关功能的时候,相册是一个绕不开的话题,因为大家基本都有从相册获取图片或者视频的需求。最直接的方式是调用系统相册接口,基本功能是满足的,一些高级功能就不行了,例如自定义 UI、多选图片等。

我们调研了官方的 image_picker,它也是调用系统的相册接口来处理的,可定制程度不高,不能满足我们的要求。所以我们选择自己来开发 Flutter 相册组件。

我们的组件需要有如下的功能:

  • 在 app 内完成图片、视频的选取,完全不用依赖系统相册组件
  • 可以多选图片,支持指定选定图片的总数目
  • 在多选的时候 UI 反应出选择的序号。
  • 可以控制视频、图片的选择。例如:只让用户选择视频,图片是灰色的。
  • 大图预览的时候可以放大缩小,也可直接加入到选取列表。

设计思路

API 使用简单,功能丰富灵活,具有较高的订制性。业务方可以选择完全接入组件,也可以选择在组件上面进行 UI 定制。

Flutter 做 UI 展现层,具体的数据由各 Native 平台提供。这种模式,天然从工程上把 UI 代码和数据代码进行了隔离。我们在开发一个 native 组件的时候常常会使用 MVC 架构。Flutter 组件的开发的思路也基本类似。整体架构如下:

可以看出,在 Flutter 侧是一个典型的 MVC 架构,这里 Widget 就是 View,View 和 Model 绑定,在 Model 改变的时候 View 会重新 build 反映出 Model 的变化。View 的事件会触发 Controller 去 Native 获取数据然后更新 Model。Native 和 Flutter 通过 Method Channel 进行通信,两层之间没有强依赖关系,只需要按约定的协议进行通信即可。

Native 侧的组成部分,UIAdapter 主要是负责机型的适配、刘海屏、全面屏之类的识别。Permission 负责媒体读写权限的申请处理。Cache 主要负责缓存 GPU 纹理,在大图预览的时候提高响应速度。Decoder 负责解析 Bitmap,OpenGL 负责 Bitmap 转纹理。

需要说明的是:我们的这一套实现依赖于 flutter 外接纹理。在整个相册组件看到的大多数图片都是一个 GPU 纹理,这样给 java 堆内存的占用相对于以前的相册实现有大幅的降低。在低端机上面如果使用原生的系统相册,由于内存的原因,app 有被系统杀掉的风险。现象就是,从系统相册返回,app 重新启动了。使用 Flutter 相册组件,在低端机上面体验会有所改观。

一些细节

1. 分页加载

相册列表需要加载大量图片,Flutter 的 GridView 组件有好几个构造函数,比较容易犯的错误是使用了第一个函数,这需要在一开始就提供大量的 widget。应该选择第二个构造函数,GridView 在滑动的时候会回调 IndexedWidgetBuilder 来获取 widget,相当于一种懒加载。

GridView.builder({
    ...
    List<Widget> children = const <Widget>[],
    ...
  })
GridView.builder({
    ...
    @required IndexedWidgetBuilder itemBuilder,
    int itemCount,
    ...
  })

滑动过程中,图片滑过后,也就是不可见的时候要进行资源的回收,我们这里这里对应的就是纹理的删除。不断的滑动 GridView,内存在上升后会处于稳定,不会一直增长。如果快速的来回滑动纹理会反复的创建和删除,这样会有内存的抖动,体验不是很好。

于是,我们维护了一个图片的状态机,状态有 None,Loading,Loaded,Wait_Dispose,Disposed。开始加载的时候,状态从 None 进入 Loading,这个时候用户看到的是空白或者是占位图,当数据回调回来会把状态设置为 Loaded 的这时候会重新 build widget 树来显示图片 icon,当用户滑走的时候状态进入 Wait_Dispose,这时候并不会马上 Dispose,如果用户又滑回来则会从 Wait_Dispose 进入 Loaded 状态,不会继续 Dispose。如果用户没有往回滑则会从 Wait_Dispose 进入 Disposed 状态。当进入 Disposed 状态后,再需要显示该图片的时候就需要重新走加载流程了。

2. 相册大图展示:

当点击 GridView 的某张图片的时候会进行这张图片的大图展示,方便用户查看的更清楚。我们知道相机拍摄的图片分辨率都是很高的,如果完全加载,内存会有很大的开销,所以我们在 Decode Bitmap 的时候进行了缩放,最高只到 1080p。大图展示可以概括为三个步骤。

  • 1 从文件 Decode 出 Bitmap
  • 2 Bitmap 转换成为纹理,并释放 Bitmap
  • 3 纹理交给 Flutter 进行展示

在步骤 1 中,Android 原生的 Bitmap Decode 经验同样适用,先 Decode 出 Bitmap 的宽高,然后根据要展示的大小计算出缩放倍数, 然后 Decode 出需要的 Bitmap。

Android 相册的图片大多是有旋转角度的,如果不处理直接显示,会出现照片旋转 90 度的问题,所以需要对 Bitmap 进行旋转,采用 Matrix 旋转一张 1080p 的图片在我的测试机器上面大概需要 200ms,如果使用 OpenGL 的纹理坐标进行旋转,大于只需要 10ms 左右,所以采用 OpenGl 进行纹理的旋转是一个较好的选择。

在进行大图预览的时候会进入一个水平滑动的 PageView,Flutter 的 PageView 一般来说是不会去主动加载相邻的 page 的。举个例子,在显示 index 是 5 的 page 的时候 index 为 4,6 的 page 也不会提前创建的。这里有一个取巧的办法,对于 PageController 的 viewportFraction 参数我们可以设置成为 0.9999。对于前面这个例子,就是在显示 index 是 5 的 page 的时候,index 为 4,6 的 page 也需要显示 0.0001。这样 index 为 4,6 的 page 显示不到 1 个像素,基本上看不出来:

PageController(viewportFraction=0.9999)

还有另外一种办法,就是在 Native 侧做预加载。例如:在加载第 5 张图片的时候,相邻的 4,6 的图片纹理提前进行加载,当滑动到 4,6 的时候直接使用缓存的纹理。

纹理缓存后,一个直接的问题:什么时候释放纹理?等到预览页面退出的时候释放所有的纹理显示不是很合适,如果用户一直浏览内存则会无限增长。所以,我们维护了一个 5 个纹理的 LRU 缓存,在滑动过程中,最老的纹理会被释放掉。在页面退出的时候整个 LRU 的缓存会进行销毁。

3. 关于内存

相册图片使用 GPU 纹理,会大幅减少 Java 堆内存的占用,对整个 app 的性能有一定的提升。需要注意的是,GPU 的内存是有限的需要在使用完毕后及时删除,不然会有内存的泄漏的风险。另外,在 Android 平台删除纹理的时候需要保证在 GPU 线程进行,不然删除是没有效果的。

在华为 P8,Android5.0 上面进行了对比测试,Flutter 相册和原 native 相册总内存占用基本一致,在 GridView 列表页面,新增最大内存 13M 左右。它们的区别在于原 native 相册使用的是 Java 堆内存,Flutter 相册使用的是 Native 内存。

总结

相册组件 API 简单、易用,高度可定制。Flutter 侧层次分明,有 UI 订制需求的可以重写 Widget 来达到目的。另外这是一个不依赖于系统相册的相册组件,自身是完备的,能够和现有的 app 保持 UI、交互的一致性。同时为后面支持更多和相册相关的玩法打好基础。

后续计划

由于我们使用的是 GPU 纹理,可以考虑支持显示高清 4K 图片,而且客户端内存不会有太大的压力。但是 4k 图片的 Bitmap 转纹理需消耗更多的时间,UI 交互上面需要做些 loading 状态的支持。

组件功能丰富,稳定后,进行开源,回馈给社区。


本文作者:闲鱼技术 - 邻云

阅读原文

本文为云栖社区原创内容,未经允许不得转载。

退出移动版