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