那些年
早在闲鱼使用 Flutter 之初,图片就是我们核心关注和重点优化的功能。图片展示体验的好坏会对闲鱼用户的使用体验产生巨大影响。你们是否也曾遇到过:
- 图片加载内存占用过多?
- 使用 Flutter 以后本地资源重复,利用率不高?
- 混合方案下 Flutter 原生图片加载效率不高?
针对上述问题,从第一版 Flutter 业务上线开始,闲鱼对图片框架的优化就从未停止。从开始的原生优化,到后面黑科技的外接纹理;从内存占用,到包大小;文本会逐一介绍。希望其中的优化思路和手段,能给大家带去一些启发。
原生模式
从技术层面看图片加载,其实简单来说,追求的是无非是加载的效率的最大化——用尽可能小的资源成本,尽可能快地加载尽可能多的图片。
闲鱼图片的第一个版本其实基本上是纯原生的方案。如果你不想魔改很多底层的逻辑,原生方案肯定是最简单和经济的方案。原生方案的功能模块如下:
如果你啥都没做直接上了,那么你可能会发现效果并没有达到你预期的那么美好。那么如果从原生的方案入手,我们有哪些具体的优化手段呢?
设置图片缓存
没错猜对了,是缓存。对于图片加载,最能想到的方案就是使用缓存。首先原生 Image 的组件是支持自定义图片缓存的,具体的实现类是 ImageCache。ImageCache 的设置维度是两个方向:
- 缓存图片的张数。通过 maximumSize 设置。默认是 1000 张。
- 缓存空间的大小。通过 maximumSizeBytes 来设置。默认值 100M。相比张数的限制,其实大小的设置方式更加符合我们的最终的预期。
通过合理设置 ImageCache 的大小,能充分利用缓存机制加速图片加载。不仅如此,闲鱼在这个点上还做了额外两个重要优化:
低端手机适配
在上线以后,我们陆续收到线上舆情的反馈,发现全部机型设置同一个缓存大小的做法并非最优。特别是大缓存设置在低端机器上面,不仅会出现体验变差,甚至还会影响稳定性。基于实际情况,我们实现了一个能从 Native 侧获取机器基础信息的 Flutter 插件。通过获取的信息,我们根据不同手机的配置设置不同的缓存策略。在低端机器上面适当降低图片缓存的大小,同时在高端手机上将其适当放大。这样能在不同配置的手机上获取最优的缓存性能。
磁盘缓存
熟悉 APP 开发的同学都知道,成熟的图片加载框架一般都有多级缓存。除了常见的内存缓存,一般都会配置一个文件缓存。从加载效率上来说,是通过空间换时间,提升加载速度。从稳定性来说,这又不会过分占用宝贵的内存资源,出现 OOM。但是可惜的是,Flutter 自带的图片加载框架并没有独立的磁盘缓存。所以我们在原生方案的基础上扩展了磁盘缓存能力。
在具体的架构实现上,我们并没有完全自己撸一个磁盘缓存。我们的策略还是复用现有能力。首先我们将 Native 图片加载框架的磁盘缓存的功能通过接口暴露出来。然后通过桥接的方式,将 Native 磁盘缓存能力嫁接到 Flutter 层。Flutter 侧进行图片加载的时候,如果内存没有命中,就去磁盘缓存中进行二次搜索。如果都没有命中才会走网络请求。
通过增加磁盘缓存,Flutter 图片加载效率进一步提升。
设置 CDN 优化
CDN 优化是另一个非常重要图片优化手段。CDN 优化的效率提升主要是:最小化传输图片的大小。常见策略包括:
根据显示大小裁剪
简单来说,你要加载图片的真实尺寸,可能会大于你实际展示窗口的大小。那么你就没必要加载完整大图,你只需要加载一个能覆盖窗口大小的图片即可。通过这种方式,裁剪掉不需要的部分,就能最小化传输图片的大小。从端侧角度来说,一来可以提升加载速度,二来可以降低内存占用。
适当压缩图片大小
这里主要是根据实际情况增加图片压缩的比例。在不影响显示效果的情况下,通过压缩进一步降低图片的大小。
图片格式
建议优先使用 webp 这样格式,图片资源相对小。Flutter 原生支持 webp(包括动图)。这里特别强调一下 webp 动图不仅大小要比 gif 小很多,而且还对透明效果有更好的支持。webp 动图是 gif 方案比较理想的一种替代方案。
基于上述原因,闲鱼图片框架在 Flutter 侧实现了一套 CDN 尺寸匹配的算法。通过该算法,请求图片会根据实际显示的大小,自动匹配到最合适的尺寸上并适当压缩。如果图片格式允许,图片尽可能转化成 webp 格式下发。这样 CDN 图片的传输就能尽可能高效。
其他优化
除了上面的策略,Flutter 还有一些其他的手段可以优化图片的性能。
图片预加载
如果你想在展示的图片的尽可能的快,官方也提供了一套预加载的机制:precacheImage。precacheImage 能预先将图片加载到内存,真正使用的时候就能秒出了。
Element 复用优化
其实这个算是一个 Flutter 通用的优化方案。复写 didWidgetUpdate 方案,通过比较前后两次 widget 中针对图片的描述是否一致,来决定是否重新渲染 Element。这样能避免同一个图片,不必要的反复渲染。
长列表优化
一般情况下,Listview 是 flutter 最为常见的滚动容器。在 Listview 中的性能好坏,直接影响最终的用户体验。
Flutter 的 Listview 跟 Native 的实现思路并不相同。其最大的特点是有一个 viewPort 的概念。超出 viewPort 的部分会被强制回收掉。
基于上述的原理,我们有两点建议:
1)cell 拆分
尽量避免大型的 cell 出现,这样能大幅降低 cell 频繁创建过程中的性能损耗。其实这里影响的不仅仅是图片加载过程。文字,视频等其他组件也都应该避免 cell 过于复杂导致的性能问题。
2)合理使用缓冲区
ListView 可以通过设置 cacheExtent 来设置预先加载的内容大小。通过预先加载可以提升 view 渲染的速度。但是这个值需要合理设置,并非越大越好。因为预加载缓存越大,对页面整体内存的压力就越大。
该方案的不足
这里需要客观指出:如果是一个纯 Flutter APP,原生方案是完善,够用的。但是如果从混合 APP 的角度来说,有如下两个缺陷:
1)无法复用 Native 图片加载能力
毫无疑问,原生的图片方案是完全独立的图片加载方案。对于一个混合 APP 来说,原生方案和 Native 的图片框架相互独立,能力无法复用。例如 CDN 裁剪 & 压缩等能力需要重复建设。特别是 Native一些独特的图片解码能力,Flutter 就很难使用。这会造成 APP 范围内的图片格式的支持不统一。
2)内存性能不足
从整个 APP 的视角来说,采用原生图片方案的情况下,其实我们维护了两个大的缓存池:一个是 Native 的图片缓存,一个是 Flutter 侧的图片缓存。两个缓存无法互通,这无疑是一个巨大的浪费。特别是对内存的峰值内存性能产生了非常大的压力。
打通 Native
经过多轮优化,基于原生的方案已经获得了非常大的性能提升。但是整个 APP 的内存水位线依然比较高(特别是 Ios 端)。现实的压力迫使我们继续对图片框架进行更深度的优化。基于上述原生方案缺点的分析,我们有了一个大胆的想法:能否完全复用 Native 的图片加载能力?
外接纹理
怎样打通 Flutter 和 Native 的图片能力?我们想到了外接纹理。外接纹理并非是 Flutter 自有的技术,它是音视频领域常用的一种性能优化手段。
这个阶段我们基于 shared-Context 的方案实现了 Flutter 和 Native 的纹理外接。通过该方案,Flutter 可以通过共享纹理的方式,拿到 Native 图片库加载好的图片并展示。为了实现这个纹理共享的通道,我们对 engine 层做了深度定制。细节过程如下:
该方案不仅打通了 Native 和 Flutter 的图片架构,整个过程图片加载的性能也得到了优化。
外接纹理是闲鱼图片方案的一次大跨越。通过该技术,我们不仅实现图片方案的本地能力复用,而且还能实现视频能力的纹理外接。这避免了大量重复的建设,提升了整个 APP 的性能。
多页面内存优化
这个优化策略真真是被逼出来的。在对线上数据分析以后,我们发现 Flutter 页面栈有一个非常有意思的特点:多页面栈情况下,底层的页面不会被释放。即便是在内存非常紧张的情况下,也不会执行回收。这样就会导致一个问题:随着页面的增多,内存消耗会线性增长。这里占比最高的就是图片资源的占比了。
是不是可以在页面处于页面栈底层的时候直接回收掉该页面内的图片呢?
在这个想法的驱动下,我们对图片架构进行了新一轮的优化。整个图片框架中的图片都会监听页面栈的变化。当方发现自己已经处于非栈顶的时候,就自动回收掉对应的图片纹理释放资源。这种方案能使图片占用的内存大小不会随着页面数的变多呈现持续线性增长。原理如下:
需要注意的是:这个阶段页面判断位置其实是需要页面栈(具体来说就是混合栈)提供额外的接口来实现的。系统之间的耦合相对较高。
意外收获:包大小
打通 Native 和 Flutter 侧图片框架以后,我们发现了一个意外收获:Native 和 Flutter 可以共用本地图片资源了。也就是说,我们不再需要将相同的图片资源在 Flutter 和 Native 侧各保留一份了。这样能大幅提升本地资源的复用率,从而降低整体的包大小。基于这个方案,我们实现了一套资源管理的功能,脚本能自动同步不同端的本地图片资源。通过这样提升本地资源利用率,降低包大小。
其他优化——PlaceHolder 强化
原生的 Image 是没有 PlaceHolder 功能的。如果想用原生方案的话,需要使用 FadeInImage。针对闲鱼的场景我们有很多定制,所以我们自己实现了一套 PlaceHolder 的机制。
从核心功能上来说,我们引入了加载状态的概念分为:
- 未初始化
- 加载中
- 加载完成
针对不同的状态,可以细粒度的控制 PlaceHolder 的展示逻辑。
整体架构
该方案的不足
毕竟改了 engine
随着闲鱼业务的不断推进,engine 的升级的成本是我们必须要考虑的事情。能否不改 engine 实现同样的功能是我们核心的述求(PS:我承认我们是贪心的)。
通道性能还有优化空间
外接纹理的方案需要通过桥的方式跟 Native 的能力做通信。这里包括图片请求的传递和图片加载各种状态的同步。特别是在 listview 快速滑动的时候,通过桥发送的数据量还是可观的。当前方案每个图片加载时都会单独进行桥的调用。在图片数量比较多的情况下,这显然会是一个瓶颈。
耦合过多
在实现图片回收方案的时候,目前方案需要栈提供是否在栈底层的接口。这里就产生方案耦合,很难抽象出一个独立干净的图片加载方案。
Clean & Efficient
时间来到了 2020 年,随着对 Flutter 基础能力理解的逐步深入,我们实现了一个整体方案更优的图片框架。
无侵入外接纹理
外接纹理可以不用修改 engine 么?答案是肯定的。
其实 Flutter 是提供了官方的外接纹理方案的。
而且 Native 操作的 texture 和 Flutter 侧显示的 texture 在底层是同一对象,并没有产生额外的数据 copy。这样就保证了纹理共享的足够高效。那为什么闲鱼之前会单独基于 shared-Context 自己实现一套呢?1.12 版本之前,官方 Ios 的外接纹理方案有性能问题。每次渲染的过程中(不管纹理是否有更新)都会频繁获取 CVPixelBuffer,造成不必要的性能损耗(过程有加锁损耗)。该问题已经在 1.12 版本中修复(官方 commit 地址),这样官方方案也足够满足需求。在这样的背景下,我们重新启用官方方案来实现外接纹理功能。
独立的内存优化
之前提到过,老版本的基于页面栈的图片资源回收需要强依赖栈功能的接口。一方面产生了不必要的依赖,更重要的是,整体方案无法独立成通用方案。为了解决这个问题,我们对 Flutter 底层进行了深入的研究。我们发现 Flutter 的 layer 层可以稳定感知到页面栈的变化。
然后每个页面通过 context 获取的 router 对象作为标识对一个页面中的所有的图片对象进行重新组织。所有获取到同一个 router 对象的标识成同一个页面。这样就能以页面为单位对所有的图片进行管理。整体上通过 LRU 的算法来模拟虚拟页面栈结构。这样就能对栈底页面的图片资源实现回收了。
其他优化
通道的高度复用
首先我们以一帧为单位对这一帧中的图片请求进行聚合,然后在一次通道请求中传递给 Native 的图片加载框架。这样能避免频繁的桥调用。特别在快速滚动等场景下优化效果尤为明显。
高效的纹理复用
使用外接纹理进行图片加载以后,我们发现复用纹理可以进一步提升性能。举一个简单的场景。我们知道电商场景中,商品展示经常会有标签,打底图这样的图片。这类图片往往在不同的商品上会出现大量重复。这时候,可以将已经渲染好的纹理,直接复用给不同的显示组件。这样能进一步优化 GPU 内存的占用,避免重复创建。为了精确对纹理进行管理,我们引入了引用计数的算法来管理纹理的复用。通过这些方案,我们实现了纹理跨页面高效复用。
此外,我们将纹理和请求的映射关系移动到了 Flutter 侧。这样能在最短路径上完成纹理的复用,进一步减少了桥的通信的压力。
整体架构
优化效果
由于最新的版本目前还在灰度,具体数据后续会写文跟大家详细介绍。下属数据主要以方案二为主。
内存优化
通过打通 Native,相比于首次上线版本,在显示效果不变的情况下,Ios 的 abort 率降低 25%,用户体验明显提升。
多页面栈内存优化**
多页面栈的内存优化,在多页面场景下对内存优化作用明显。我们做了一个极限试验效果如下(测试环境,非闲鱼 APP):
可见多页面栈的优化,可以将多 Flutter 页面的内存占用控制得更好。
包大小减少
通过接入外接纹理,本地资源得到了更好的复用,包大小降低 1M。早期闲鱼接入 Flutter,会以改造现有页面为切入点。资源重复情况比较严重,但是随着闲鱼 Flutter 新业务越来越多。Flutter 和 Native 的重复资源越来越少。外接纹理对包大小的影响已经逐步变弱。
后续计划
这是一场没有尽头的旅行,我们对闲鱼图片的优化还会持续。特别是我们最新的方案,受限篇幅,本文只是做了初步介绍。更多技术细节,包括测试数据,我们随后还会专门写文继续给大家做介绍。方案完善以后,我们也会逐步开源。