简介:阿里妹导读:图片加载是 APP 最多见也最基本的功能,也是影响用户体验的因素之一。在看似简单的图片加载背后却隐藏着不少技术难题。本文介绍闲鱼技术团队在 Flutter 图片优化上所作的尝试,分享闲鱼在典型的图片处理方案上的技术细节,但愿给你们带来一些启发。web
早在闲鱼使用 Flutter 之初,图片就是咱们核心关注和重点优化的功能。图片展现体验的好坏会对闲鱼用户的使用体验产生巨大影响。大家是否也曾遇到过:算法
针对上述问题,从初版 Flutter 业务上线开始,闲鱼对图片框架的优化就从未中止。从开始的原生优化,到后面黑科技的外接纹理;从内存占用,到包大小;文本会逐一介绍。但愿其中的优化思路和手段,能给你们带去一些启发。缓存
从技术层面看图片加载,其实简单来讲,追求的是无非是加载的效率的最大化——用尽量小的资源成本,尽量快地加载尽量多的图片。性能优化
闲鱼图片的第一个版本其实基本上是纯原生的方案。若是你不想魔改不少底层的逻辑,原生方案确定是最简单和经济的方案。原生方案的功能模块以下:网络
若是你啥都没作直接上了,那么你可能会发现效果并无达到你预期的那么美好。那么若是从原生的方案入手,咱们有哪些具体的优化手段呢?架构
没错猜对了,是缓存。对于图片加载,最能想到的方案就是使用缓存。首先原生 Image 的组件是支持自定义图片缓存的,具体的实现类是 ImageCache。ImageCache 的设置维度是两个方向:框架
经过合理设置 ImageCache 的大小,能充分利用缓存机制加速图片加载。不只如此,闲鱼在这个点上还作了额外两个重要优化:性能
低端手机适配测试
在上线之后,咱们陆续收到线上舆情的反馈,发现所有机型设置同一个缓存大小的作法并不是最优。特别是大缓存设置在低端机器上面,不只会出现体验变差,甚至还会影响稳定性。基于实际状况,咱们实现了一个能从 Native 侧获取机器基础信息的 Flutter 插件。经过获取的信息,咱们根据不一样手机的配置设置不一样的缓存策略。在低端机器上面适当下降图片缓存的大小,同时在高端手机上将其适当放大。这样能在不一样配置的手机上获取最优的缓存性能。优化
磁盘缓存
熟悉 APP 开发的同窗都知道,成熟的图片加载框架通常都有多级缓存。除了常见的内存缓存,通常都会配置一个文件缓存。从加载效率上来讲,是经过空间换时间,提高加载速度。从稳定性来讲,这又不会过度占用宝贵的内存资源,出现 OOM。可是惋惜的是,Flutter 自带的图片加载框架并无独立的磁盘缓存。因此咱们在原生方案的基础上扩展了磁盘缓存能力。
在具体的架构实现上,咱们并无彻底本身撸一个磁盘缓存。咱们的策略仍是复用现有能力。首先咱们将 Native 图片加载框架的磁盘缓存的功能经过接口暴露出来。而后经过桥接的方式,将 Native 磁盘缓存能力嫁接到 Flutter 层。Flutter 侧进行图片加载的时候,若是内存没有命中,就去磁盘缓存中进行二次搜索。若是都没有命中才会走网络请求。
经过增长磁盘缓存,Flutter 图片加载效率进一步提高。
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 侧的图片缓存。两个缓存没法互通,这无疑是一个巨大的浪费。特别是对内存的峰值内存性能产生了很是大的压力。
通过多轮优化,基于原生的方案已经得到了很是大的性能提高。可是整个 APP 的内存水位线依然比较高(特别是 Ios 端)。现实的压力迫使咱们继续对图片框架进行更深度的优化。基于上述原生方案缺点的分析,咱们有了一个大胆的想法:可否彻底复用 Native 的图片加载能力?
怎样打通 Flutter 和 Native 的图片能力?咱们想到了外接纹理。外接纹理并不是是 Flutter 自有的技术,它是音视频领域经常使用的一种性能优化手段。
这个阶段咱们基于 shared-Context 的方案实现了 Flutter 和 Native 的纹理外接。经过该方案,Flutter 能够经过共享纹理的方式,拿到 Native 图片库加载好的图片并展现。为了实现这个纹理共享的通道,咱们对 engine 层作了深度定制。细节过程以下:
该方案不只打通了 Native 和 Flutter 的图片架构,整个过程图片加载的性能也获得了优化。
外接纹理是闲鱼图片方案的一次大跨越。经过该技术,咱们不只实现图片方案的本地能力复用,并且还能实现视频能力的纹理外接。这避免了大量重复的建设,提高了整个 APP 的性能。
这个优化策略真真是被逼出来的。在对线上数据分析之后,咱们发现 Flutter 页面栈有一个很是有意思的特色:多页面栈状况下,底层的页面不会被释放。即使是在内存很是紧张的状况下,也不会执行回收。这样就会致使一个问题:随着页面的增多,内存消耗会线性增加。这里占比最高的就是图片资源的占比了。
是否是能够在页面处于页面栈底层的时候直接回收掉该页面内的图片呢?
在这个想法的驱动下,咱们对图片架构进行了新一轮的优化。整个图片框架中的图片都会监听页面栈的变化。当方发现本身已经处于非栈顶的时候,就自动回收掉对应的图片纹理释放资源。这种方案能使图片占用的内存大小不会随着页面数的变多呈现持续线性增加。原理以下:
须要注意的是:这个阶段页面判断位置实际上是须要页面栈(具体来讲就是混合栈)提供额外的接口来实现的。系统之间的耦合相对较高。
打通 Native 和 Flutter 侧图片框架之后,咱们发现了一个意外收获:Native 和 Flutter 能够共用本地图片资源了。也就是说,咱们再也不须要将相同的图片资源在 Flutter 和 Native 侧各保留一份了。这样能大幅提高本地资源的复用率,从而下降总体的包大小。基于这个方案,咱们实现了一套资源管理的功能,脚本能自动同步不一样端的本地图片资源。经过这样提高本地资源利用率,下降包大小。
原生的 Image 是没有 PlaceHolder 功能的。若是想用原生方案的话,须要使用 FadeInImage。针对闲鱼的场景咱们有不少定制,因此咱们本身实现了一套 PlaceHolder 的机制。
从核心功能上来讲,咱们引入了加载状态的概念分为:
针对不一样的状态,能够细粒度的控制 PlaceHolder 的展现逻辑。
毕竟改了 engine
随着闲鱼业务的不断推动,engine 的升级的成本是咱们必需要考虑的事情。可否不改 engine 实现一样的功能是咱们核心的述求(PS:我认可咱们是贪心的)。
通道性能还有优化空间
外接纹理的方案须要经过桥的方式跟 Native 的能力作通讯。这里包括图片请求的传递和图片加载各类状态的同步。特别是在 listview 快速滑动的时候,经过桥发送的数据量仍是可观的。当前方案每一个图片加载时都会单独进行桥的调用。在图片数量比较多的状况下,这显然会是一个瓶颈。
耦合过多
在实现图片回收方案的时候,目前方案须要栈提供是否在栈底层的接口。这里就产生方案耦合,很难抽象出一个独立干净的图片加载方案。
时间来到了 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 的重复资源愈来愈少。外接纹理对包大小的影响已经逐步变弱。
这是一场没有尽头的旅行,咱们对闲鱼图片的优化还会持续。特别是咱们最新的方案,受限篇幅,本文只是作了初步介绍。更多技术细节,包括测试数据,咱们随后还会专门写文继续给你们作介绍。方案完善之后,咱们也会逐步开源。