闲鱼在业务的快速迭代过程中,app 的长列表滑动流畅度逐步恶化,对用户浏览内容体验产生伤害。闲鱼作为国内 flutter 应用的先驱,APP 以 flutter 和原生 Native 的混合工程存在。这里分别就 Android 原生、flutter 页面和大家分享我们的优化思路。
流畅度指标和检测工具构建
flutter 长列表优化
2 流畅度指标和检测工具构建
2.1 现状和难点
侵入式
profile 模式
执行系统命令,如
adb shell dumpsys gfxinfo ${packageName}流畅度指标现状有:
SF(Skippedframe,跳帧) app 在单位时间 1 秒内,跳过执行 Choreographer 中 doframe 的次数
帧耗时数据 使用 adb 命令得到几个关键分位的帧平均耗时:
nerror="javascript:errorimg.call(this);">
多平台问题
指标选择和用户体验一致性
流畅度数据影响因素多
2.2 流畅度指标制定
列表滑动同理,是 APP 以一定频率(60hz下16.6ms)和不同 offset 计算出一系列静止画面,让肉眼看到滑动动画。
时间角度
定义 1s 大卡顿次数:平均 1s 内出现占用 3 帧及以上的画面次数。反应画面停留时长跳变
offset 跳变值:在画面不掉帧的情况,若其中一个画面出现跳变,甚至花屏或者绿屏会让用户体验到不流畅。在 APP 滑动过程中,画面内容由 offset 决定,而 offset 跳变,和卡顿时长、差值器实现均有关联,现有差值器实现基本基于 D/T 曲线(距离/时间),为此平均 FPS 和 1s 大卡顿次数很大程度上体现了画面跳变,同时考虑到无侵入式检测 offset 的难度问题,暂不考虑 offset 跳变值。
2.3 流畅度检测工具实现
为得到目标 APP 录屏数据,检测工具 APP 向系统注册录屏服务,然后在检测工具 APP 的帧回调中不停读取录屏画面,并和上次检测画面 hash 值进行比对。
为保证每次录屏画面读取和 hash 值计算在 16.6ms 内完成,需根据高低端机型调整画面宽高压缩比。
2.4 检测工具演示
流畅度检测工具界面
在流畅度指标方面,我们定义了平均 FPS 和 1s 大卡顿次数作为指标,更好的反应了用户体验。在流畅度检测工具方面,我们实现了无侵入检测工具,支持以下特性:
支持检测第三方 app
多维度数据:平均 FPS,平均 1s 大卡帧次数,帧分布直方图,帧分布均方差
此外,流畅度检测工具还有一些不足之处
停止滑动时,若列表中有视频播放,由于画面一直在变化,检测工具无法判断是滑动停止;同时,由于视频 fps 值为 30 左右,会导致流畅度数据偏低
低端机(y67)真实 fps 计算存在偏差
如何避免:避免低端机上检测大量空白或大色块的场景
Android 原生长列表优化已经非常成熟了,在工具方面有 traceview、blockcanary、DDMS、Android Profile 等。常见优化手段也很多:布局层级优化,过度渲染优化,频繁measure、layout优化,UI 线程耗时方法优化、冗余资源资源加载优化等,这里不再赘述。
3.1 异步构建视图缓存池
查看首页显示和初始滑动流程,可以发现流程中其他 UI 操作过程和等待用户操作过程均有优化空间。
nerror="javascript:errorimg.call(this);">
其中视图缓存池构建完成的时机在不同机型下不同,可能在列表首屏多卡片构建之前,或构建中,或在用户滑动操作之前完成,或一开始构建就抛出错误停止构建
3.2 ViewDataUnbinder 快速抽离 UI 操作
能想到定义视图数据层,将 UI 和非 UI 操作分离开,然而实际编码发现业务代码改动量大且容易出错,AB 测试逻辑难以实现。那有没有更好的方案,用最少量代码抽离 UI 操作呢?
nerror="javascript:errorimg.call(this);">
具体使用代码样例如下
其中注解说明
nerror="javascript:errorimg.call(this);">
3. 业务代码修改
原视图数据绑定逻辑放置后台线程
闲鱼首页,在恢复内容上屏速度(流畅度降低)后提升流畅度
flutter 一直以高性能被大家所认知,这也是闲鱼当初选择 flutter 的一个重要原因。而在闲鱼的实际 flutter 页面,如商品详情页和搜索结果页,长列表滑动流畅度体验却不尽人意。
做性能优化前,需要理解 flutter 的渲染原理,如 Widget、Element、RenderObject 三棵树结构、Widget 到屏幕显示过程等,可参考 《复杂业务如何保证Flutter的高性能高流畅度?》。
Profile 模式只能在真机上运行,不能在模拟器上运行:基本和 Release 模式一致,除了启用了服务扩展和 tracing,以及一些为了最低限度支持 tracing 运行的东西(比如可以连接 observatory 到进程)。命令 flutter run --profile 就是以这种模式运行的,通过 sky/tools/gn --android --runtime-mode=profile 或者 sky/tools/gn --ios --runtime-mode=profile 来 build。因为模拟器不能代表真实场景,所以不能在模拟器上运行引自:《Flutter性能调优、复杂业务保证Flutter的高性能高流畅》
Android Studio 上
View→ToolWindows→FlutterPerformance打开检测 Widget rebuild 情况,可以发现 FDButtonBar 被频繁重建,然而查看视图内容并没有发生变化。查看代码定位到reducer.dart中会根据滑动事件更新 state 中的scrollPercent,进而产生重建。而在详情页中,scrollPercent在 Widget 构建中并未参与使用。4.1.2 使用 fish-redux 性能日志
闲鱼详情页滑动时,查看 adb 日志,可以发现大量的滑动广播通知,且存在耗时 1ms 以上事件处理。
nerror="javascript:errorimg.call(this);">
因为详情页中存在视图间联动,如标题栏的显示隐藏渐变,
问卖家的显示消失均需要根据滑动事件做判断。结合业务逻辑,可以发现,除了问卖家外,其他视图在滑动超出 600 之后,收到滑动事件后不会发生视图内容变化;而问卖家在滑动超出更大的一个值后会永远消失不显示,在一开始未超出这个值时,仅需要判断滑动方向即可。基于以上业务背景,在滑动超出 600 后,若问卖家是不再显示状态,则不发送滑动事件;否则仅在开始滑动的 30 距离内发送事件。4.1.3 优化 ClipPath 和 ClipRPath
打开 Debug flag
debugDisableClipLayers和debugDisablePhysicalShapeLayers重新检查视图,可以发现部分 ClipRectLayer 是因为图片内容超出视图边界产生,部分 ClipRRectLayer 是因为卡片 Widget 圆角设置以及基于外接纹理的图片控件里设置了 ClipRRect 设置(即便 radius 为0也会设置)nerror="javascript:errorimg.call(this);">
4.1.4 其他优化建议
widget build 优化
Provider 中获取 Model 的方式会影响刷新范围。推荐使用 Selector 或 Consumer 来获取祖先 Model,以维持最小刷新范围
reducer 中,state 对象中的视图数据真正发生变化的时候,新建 state 对象
减少或延迟 widget build 中非视图逻辑,如曝光埋点延迟到滑动停止聚合触发
使用 const 修饰无需变更的 widget 或普通对象
避免在动画中剪裁。如果可能,请在动画开始之前预先剪切图像
对于频繁更新的控件(如动画),使用 RepaintBoundary 隔离它,创建单独 layer 减少重绘区域
减少 saveLayer(ShaderMask、ColorFilter、Text Overflow)、clipPath的使用,提升 render 线程性能
避免使用带换行符的长文本
官方 DevTools 工具
善于利用框架日志,如 fish-redux 性能日志
flutter 列表控件划分为可视区域和 Cache 区域,往下滑动时 element 从底部被创建进入底部 Cache 区域后,再进入可视区域,再进去顶部 Cache 区域,最后被销毁。往上滑动逻辑类似。在不使用 keepAlive 的情况下,来回滑动,曾经创建过的 element 需要重新创建。而在我们的业务中,列表 item Widget 结构是接近的,此时如果能根据类型复用 element,就能一定程度的提升性能。
我们构建
index→${widget.key}→List<element>的映射关系:在 widget 创建处建立index→${widget.key}映射,在 element 应该被销毁移除的逻辑处,将 element 缓存至${widget.key}映射的List<element>处(注意 renderObject 对象需要从父节点移除)。列表滑动过程中,优先根据映射关系找到缓存中的 element 并使用(注意更新 element.renderObject.parentData 中的 index 值)以上全部优化手段尝试后,在闲鱼的详情页和搜索页上还是远没有达到预期。原因是猜你喜欢卡片和搜索页卡片本身就足够复杂,另外由于我们引入 DX 技术让 Widget 进一步变得巨大,最终导致的结果是:即便高端机,也无法在一帧时间内完成渲染。然而抛开技术视角,从业务视角看,卡片展现内容和 DX 的动态能力都是必需的。那如何在满足业务诉求的情况下,实现超大 Widget 的高性能呢?
nerror="javascript:errorimg.call(this);">
猜你喜欢卡片在 红米 K30Pro(CPU 骁龙 865)的 Timeline 图
nerror="javascript:errorimg.call(this);">
在已知常见优化手段无法满足的情况下,我们回归 GUI 系统性能优化的起点去思考问题。流畅度优化思路,大体可以分为 3 个方向:
在 Android 原生开发中很常见。但在 dart 世界中,不同线程(isolate)的内存是隔离的,此外由于 flutter 渲染流程三棵树,我们不好直接操作 RenderObject,多线程方案在 flutter 中较难实施(排除 IO 更新数据后显示等常规场景)
flutter 中的主流优化思路,前面的优化手段都是这个思路
即一帧时间内还有任务没有完成,则停止执行,保证列表先执行滑动,未执行任务在后续帧时间片上执行 参考 React Fiber 框架,基于时间分片的思路,协调阶段将一颗任务树转为一条任务链(parent 节点 → child 节点 → sibling 节点 → parent 节点),满足了任务链可中断执行,提前提交渲染,最后实现了将一条任务链拆解到多帧时间分片中消化。
nerror="javascript:errorimg.call(this);">
能否将一个大 Widget build 任务为拆分多个小 Widget build 任务并大致平均的分配到多个时间分片上?
Timeline 上任务耗时图
nerror="javascript:errorimg.call(this);">
基于时间分片的大方向,我们把一个大 widget 拆分为一个空白框架和 2 个卡片 widget,再将卡片 widget 拆分为一个卡片框架和多个 FXImage Widget,Widget 框架中不立马显示的部分使用占位 Widget 临时代替。由此构建一个高优大任务队列和一个低优小任务队列,高优大任务队列中的任务高优执行且独占一帧时间,低优小任务队列低优执行且一帧时间最多能执行 12 个任务。再利用 flutter 逐步标脏,将 build 任务延迟到后续时间分片上。
nerror="javascript:errorimg.call(this);">
优化后猜你喜欢卡片 Timeline 图(红米 K30Pro,CPU 骁龙 865)
4.4 优化数据
详情页线上高可用 fps 数据如下:nerror="javascript:errorimg.call(this);">
线上高端机 fps 曲线。绿色为优化版本
线上低端机 fps 曲线。绿色为优化版本
4.5 滑动差值器优化
回顾自建流畅度检测工具原理:基于每帧画面比对、无侵入,相同的自动化脚本,所以相信我们线下测试的数据(平均 FPS 和 1s 大卡顿次数)是准确的。性能数据接近,而体感有差异,且性能数据准确可信,所以可以确认流畅度指标(平均 FPS 和 1s 大卡顿次数)还不能完全反应体感。
Android 原生 RecyclerView 和 Flutter SliverList fling 阶段 offset/time 曲线图
查看 flutter 滑动算法,可以发现是基于一条 D/T 曲线计算滑动距离,所以发生卡顿时,输入 timeOffset 值发生翻倍,最终计算出来的 offset 值发生近乎翻倍。
为消除在发生小卡顿时,offset 跳变的情况,我们自定义了 physics 和 simulation,在 time 发生发生小跳变时,修改滑动距离算法,采用 V/T 曲线算法,distance 通过累加的方式计算,优化了 time offset 发生翻倍而导致曲线跳变的情况
注意:需要适配系统频率大于 60 hz 的机型(如 90hz,120hz),在一帧时间内有可能计算多次 distance
SmoothClampingScrollPhysics 无回弹差值器,停顿后偏移值不跳变。结束滑动的效果同 ClampingScrollSimulation
5 总结和展望
流畅度优化是每一个 GUI 系统都一直在努力的事情,有很多优秀的工具介绍、官方和非官方的优化文章。这次优化过程中,我们也借鉴了很多别人的文章,发现和优化了一些问题,但本文尽量不去重复描述,推荐读者阅读相关优化文章或官方文档。在以上优化手段尚无法实现最终目标时,我们也做了一些不一样的优化,期望能抛砖引玉,对读者有所帮助和启发:
针对指标,自建了流畅度检测工具,支持无侵入、跨平台、自动化
[Flutter] 修改 Flutter engine 源码,支持列表 element 复用
[Flutter] 差值器算法优化
如何将流畅度检测工具内部产品化,支持非研发同事使用?
如何在研发阶段及时发现和防止无效 rebuild 等问题?
如何以业务无侵入的方式实现业务大 Widget 自动且合理地分帧上屏?
