作者:闲鱼技术-云从
1 整体思路
闲鱼在业务的快速迭代过程中,app 的长列表滑动流畅度逐步恶化,对用户浏览内容体验产生伤害。闲鱼作为国内 flutter 应用的先驱,APP 以 flutter 和原生 Native 的混合工程存在。这里分别就 Android 原生、flutter 页面和大家分享我们的优化思路。
本文分为三个部分:
- 流畅度指标和检测工具构建
- 原生 Android 长列表优化
- flutter 长列表优化
流畅度优化整体思路图如下:
2 流畅度指标和检测工具构建
2.1 现状和难点
检测工具现状:以 Android 为例,现有流畅度工具可分为:
-
侵入式
- 集成 sdk,通过注册帧回调计算流畅度。Android 见 Choreographer 类
- profile 模式
-
无侵入式
- 执行系统命令,如
adb shell dumpsys gfxinfo ${packageName}
- 腾讯 GT APP,底层执行
service call SurfaceFlinger 1013
,高版本 Android 已不支持
- 执行系统命令,如
流畅度指标现状有:
- FPS (Frames Per Second)
- SF(SkippedFrame,跳帧)
- 在单位时间 1 秒内,跳过执行 Choreographer 中 doFrame() 的次数 (见 《移动App性能评测与优化》)
- SM(Smooth,流畅度)
- 在单位时间 1 秒内,实际执行 Choreographer 中 doFrame() 的次数。其中 SM=60-SF。(见 《移动App性能评测与优化》)
- 帧耗时数据
使用 adb 命令得到几个关键分位的帧平均耗时:
Total frames rendered: 2245
Janky frames: 31 (1.38%)
50th percentile: 5ms
90th percentile: 10ms
95th percentile: 14ms
99th percentile: 18ms
然而以上工具和指标定义在 app 的复杂场景下,尚存在问题
- 多平台问题
现 APP 技术有原生、h5、小程序、RN、weex、flutter 等。暂无一款无侵入的流畅度检测工具能同时支持多个平台、多种机型和多个指标数据,而侵入式的检测工具无法检测竞品 APP。 - 指标选择和用户体验一致性
我们期望能有少量的几个指标数据,准确的表达用户流畅度体感。
平均 FPS(SM 和 SF 类似),不足以反映用户体验。如相同 30 FPS,可以是 1s 内 30 个 33.3 ms的画面,也可以是 29 个 16.6ms 的画面再加 1 个 516.9 ms 的画面,但用户体验并不相同。
- 流畅度数据影响因素多
滑动速度和滑动状态:idle(停止)、drag(手指拖拽)、fling(*滑动)都是影响流畅度数据的重要因素。
2.2 流畅度指标制定
* 动画定义:一种通过定时拍摄一系列多个静止的固态图像(帧)以一定频率连续变化、运动(播放)的速度(如每秒16张)而导致肉眼的视觉残象产生的错觉——而误以为图画或物体(画面)活动的作品及其影片技术。
列表滑动同理,是 APP 以一定频率(60hz下16.6ms)和不同 offset 计算出一系列静止画面,让肉眼看到滑动动画。
当我们说列表滑动不流畅,是因为频率过低无法让肉眼产生视觉残留,或在时间(画面停留时长)和空间(画面内容)产生跳变,让用户感知到变化的不自然。以此我们可以定义指标如下:
-
时间角度
- 定义平均 FPS:定义一次检测的平均帧率。反应画面平均停留时长。
- 定义 1s 大卡顿次数:平均 1s 内出现占用 3 帧及以上的画面次数。反应画面停留时长跳变
-
空间角度
- offset 跳变值:在画面不掉帧的情况,若其中一个画面出现跳变,甚至花屏或者绿屏会让用户体验到不流畅。在 APP 滑动过程中,画面内容由 offset 决定,而 offset 跳变,和卡顿时长、差值器实现均有关联,现有差值器实现基本基于 D/T 曲线(距离/时间),为此平均 FPS 和 1s 大卡顿次数很大程度上体现了画面跳变,同时考虑到无侵入式检测 offset 的难度问题,暂不考虑 offset 跳变值。
综上,我们定义流畅度指标为平均 FPS 值和 1s 大卡顿次数。
2.3 流畅度检测工具实现
我们从 APP 录屏画面入手,计算流畅度指标值。当我们得到 APP 滑动过程中的录屏数据,可通过每 16.6ms 检测录屏画面是否发生变化,当连续画面未发生变化,则表示发生了卡顿。无变化的连续画面数则表示了卡顿的时长。
为得到目标 APP 录屏数据,检测工具 APP 向系统注册录屏服务,然后在检测工具 APP 的帧回调中不停读取录屏画面,并和上次检测画面 hash 值进行比对。
- 检测工具 APP 和目标 APP 进程隔离,为此目标 APP 发生卡顿并不影响检测工具 APP 的帧回调
- 为保证每次录屏画面读取和 hash 值计算在 16.6ms 内完成,需根据高低端机型调整画面宽高压缩比。
为排除滑动操作对流畅度数值的干扰,我们使用脚本操作检测工具 APP 和目标 APP 的滑动。自动化脚本原理为使用 adb 命令操作手机
点击:adb shell input tap $x $y
滑动:adb shell input swipe $x1 $y1 $x2 $y2 $duration
2.4 检测工具演示
流畅度检测工具 APP 以悬浮框的方式显示,下面为目标检测 APP:
流畅度检测工具界面
2.5 小结和展望
在流畅度指标方面,我们定义了平均 FPS 和 1s 大卡顿次数作为指标,更好的反应了用户体验。
在流畅度检测工具方面,我们实现了无侵入检测工具,支持以下特性:
- 无侵入
- 支持检测第三方 app
- 支持多平台:native,flutter,h5,小程序
- 多维度数据:平均 FPS,平均 1s 大卡帧次数,帧分布直方图,帧分布均方差
- 自动操作,避免人为操作差异
此外,流畅度检测工具还有一些不足之处
-
列表中有视频卡片
- 停止滑动时,若列表中有视频播放,由于画面一直在变化,检测工具无法判断是滑动停止;同时,由于视频 fps 值为 30 左右,会导致流畅度数据偏低
- 如何避免:检测过程中,需保证列表滑动不停止
-
低端机(y67)真实 fps 计算存在偏差
- 为保证低端机上(如 vivo y67)上计算大图像 hash 值在 16ms 以内,录屏画面压缩较大(宽度压缩 100,高度压缩 10),为此在大量空白或者大色块的场景下,无法检测到画面的细微变化,fps 计算存在偏低。
- 如何避免:避免低端机上检测大量空白或大色块的场景
3 原生 Android 长列表优化
Android 原生长列表优化已经非常成熟了,在工具方面有 traceview、blockcanary、DDMS、Android Profile 等。常见优化手段也很多:布局层级优化,过度渲染优化,频繁measure、layout优化,UI 线程耗时方法优化、冗余资源资源加载优化等,这里不再赘述。
除此之外闲鱼使用以下 2 点优化首页
3.1 异步构建视图缓存池
通过工具检测或耗时打印,发现列表初始滑动和 loadmore 时触发 item 视图构建耗时严重(RecyclerView.onCreateViewHolder)
查看首页显示和初始滑动流程,可以发现流程中其他 UI 操作过程和等待用户操作过程均有优化空间。
利用 AsyncLayoutInflater 原理异步构建视图缓存池,优化首页列表流程如下:
其中视图缓存池构建完成的时机在不同机型下不同,可能在列表首屏多卡片构建之前,或构建中,或在用户滑动操作之前完成,或一开始构建就抛出错误停止构建
注意:不能直接使用 AsyncLayoutInflater,AsyncLayoutInflater 在异步构建失败后有一个降级到 UI 线程构建的逻辑,为避免降级逻辑发生导致缓存池在 UI 线程构建,导致页面更加卡顿,需要移除这个降级逻辑:出现异步 inflater 失败,停止缓存池构建。
3.2 ViewDataUnbinder 快速抽离 UI 操作
在卡片数据绑定阶段(RecyclerView.onBindViewHolder),在低端机上耗时较为严重,原因是在卡片数据绑定方法中,而 UI 和非 UI 操作糅合在一起,由于 UI 逻辑必须在 UI 线程执行,最终导致全部逻辑只能在 UI 线程执行。
能想到定义视图数据层,将 UI 和非 UI 操作分离开,然而实际编码发现业务代码改动量大且容易出错,AB 测试逻辑难以实现。那有没有更好的方案,用最少量代码抽离 UI 操作呢?
核心思路:编译期根据视图类自动生成 ViewData 类,并替换视图类实例。ViewData 类和视图类拥有相同的关键方法签名,方法执行时记录视图操作,统一切换到 UI 线程执行视图操作。
具体使用代码样例如下
- 注解视图类
使用 ViewDataAnno 注解视图类,UIMethodAnno 注解 UI 操作方法。
其中注解说明
- 生成 ViewData 类
-
业务代码修改
- 修改视图变量为 ViewData 类型
- 原视图数据绑定逻辑放置后台线程
3.3 优化结果
闲鱼首页,在恢复内容上屏速度(流畅度降低)后提升流畅度
4 Flutter 复杂长列表优化
flutter 一直以高性能被大家所认知,参考 Flutter 是如何做到性能直逼 native 的?,这也是闲鱼当初选择 flutter 的一个重要原因。而在闲鱼的实际 flutter 页面,如商品详情页和搜索结果页,长列表滑动流畅度体验却不尽人意。
4.1 工具使用和常见优化
做性能优化前,需要理解 flutter 的渲染原理,如 Widget、Element、RenderObject 三棵树结构、Widget 到屏幕显示过程等,可参考 超详解析Flutter渲染引擎, 复杂业务如何保证Flutter的高性能高流畅度?。
针对性能问题,首推官方性能分析工具并结合使用 profile 模式查看性能问题,参考 Flutter Performance 分析工具简介。
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的高性能高流畅
4.1.1 检查 widget rebuild 情况
Android Studio 上 View
→ Tool Windows
→ Flutter Performance
打开检测 Widget rebuild 情况,可以发现 FDButtonBar 被频繁重建,然而查看视图内容并没有发生变化。查看代码定位到 reducer.dart
中会根据滑动事件更新 state 中的 scrollPercent
,进而产生重建。而在详情页中,scrollPercent
在 Widget 构建中并未参与使用。
闲鱼页面中使用了 fish-redux,在 reducer.dart 的方法中返回不同的 state 对象则表示需要重建 widget
// reducer.dart
// 滑动事件监听
static BottomBarState onScroll(BottomBarState state, Action action) {
...
return state.clone()..scrollPercent = scrollPercent;
...
}
4.1.2 使用 fish-redux 性能日志
fish-redux 是闲鱼研发一套在 flutter 上的 redux 框架,闲鱼 APP 中有广泛应用。fish-redux 中自带性能日志,源码查看 performance.dart,若需要打印 profile 或 release 模式下的性能日志,可自行修改源码。
闲鱼详情页滑动时,查看 adb 日志,可以发现大量的滑动广播通知,且存在耗时 1ms 以上事件处理。
11-15 15:03:43.684 27076 27271 I flutter : CommonBuyDetailPage performance: ItemBodyAction.onScrollBroadcast 261
11-15 15:03:43.701 27076 27271 I flutter : CommonBuyDetailPage performance: ItemBodyAction.onScrollBroadcast 1933
11-15 15:03:43.716 27076 27271 I flutter : CommonBuyDetailPage performance: ItemBodyAction.onScrollBroadcast 371
profile 模式下时间日志
因为详情页中存在视图间联动,如标题栏的显示隐藏渐变,问卖家
的显示消失均需要根据滑动事件做判断。结合业务逻辑,可以发现,除了问卖家
外,其他视图在滑动超出 600 之后,收到滑动事件后不会发生视图内容变化;而问卖家
在滑动超出更大的一个值后会永远消失不显示,在一开始未超出这个值时,仅需要判断滑动方向即可。基于以上业务背景,在滑动超出 600 后,若问卖家
是不再显示状态,则不发送滑动事件;否则仅在开始滑动的 30 距离内发送事件。
此外,可以利用 fish-redux 的特性:若 reducer.dart 中返回新的 state 对象则表示 widget 重建,检查全部的 reducer.dart 文件内方法实现,排查可能发生的无效 widget 重建。
4.1.3 优化 ClipPath 和 ClipRPath
使用 Timeline 查看渲染线程性能消耗,可以发现有多个 ClipRectLayer
和 ClipRRectLayer
。
打开 Debug flag debugDisableClipLayers
和 debugDisablePhysicalShapeLayers
重新检查视图,可以发现部分 ClipRectLayer 是因为图片内容超出视图边界产生,部分 ClipRRectLayer 是因为卡片 Widget 圆角设置以及基于外接纹理的图片控件里设置了 ClipRRect 设置(即便 radius 为0也会设置)
理解原理后,我们对闲鱼图片控件新增参数,支持图片内容圆角设置和图片内容宽高裁剪,使 native 层生成的 Bitmap 已经满足圆角和宽高比要求。同时修复 radius 为0也会设置 ClipRRect 的问题。优化后的 Timeline 图如下:
4.1.4 其他优化建议
flutter 性能优化相关的优秀文章很多,本文不再对类似的排查和优化手段做赘述,这里做下简单汇总:
-
widget build 优化
- setState 状态刷新位置尽量放置于视图树的低层级
- Provider 中获取 Model 的方式会影响刷新范围。推荐使用 Selector 或 Consumer 来获取祖先 Model,以维持最小刷新范围
- 对于长列表,避免使用 ListView() 构造函数,推荐使用 ListView.builder 构造函数
- reducer 中,state 对象中的视图数据真正发生变化的时候,新建 state 对象
-
主 isolate 优化
- 减少或延迟 widget build 中非视图逻辑,如曝光埋点延迟到滑动停止聚合触发
- 列表 Item 高度可知的情况下,推荐设置 itemExtent,减少滑动中频繁计算列表高度
- 使用 const 修饰无需变更的 widget 或普通对象
- 使用 AnimatedBuilder 时,避免在不依赖于动画的 widget 的构造方法中构建 widget 树。动画的每次变动都会重建这个 widget 树。而应该构建子树的那一部分,并将其作为 child 传递给 AnimatedBuilder
- 避免在动画中剪裁。如果可能,请在动画开始之前预先剪切图像
-
Render 线程优化
- 对于频繁更新的控件(如动画),使用 RepaintBoundary 隔离它,创建单独 layer 减少重绘区域
- 使用图片替换半透明效果
- 减少 saveLayer(ShaderMask、ColorFilter、Text Overflow)、clipPath的使用,提升 render 线程性能
- 避免使用 Opacity widget,尤其是在动画中避免使用。请用 AnimatedOpacity 或 FadeInImage 进行代替
- 避免使用带换行符的长文本
-
工具推荐
- 官方 DevTools 工具
- 利用 Debug flags 排查问题(推荐 Flutter Performance 分析工具简介)
- 善于利用框架日志,如 fish-redux 性能日志
4.2 列表 element 复用优化
flutter 列表控件划分为可视区域和 Cache 区域,往下滑动时 element 从底部被创建进入底部 Cache 区域后,再进入可视区域,再进去顶部 Cache 区域,最后被销毁。往上滑动逻辑类似。在不使用 keepAlive 的情况下,来回滑动,曾经创建过的 element 需要重新创建。而在我们的业务中,列表 item Widget 结构是接近的,此时如果能根据类型复用 element,就能一定程度的提升性能。
列表控件源码见 sliver_list.dart 中 RenderSliverList.performLayout()
element 缓存在 _childElements 数组中,以 index 为索引。源码见 sliver.dart
若 item Widget 结构差异很大,即便复用了 element,Element.updateChild 方法内部最终还是执行了 inflateWidget 方法,对于性能提升就没什么价值了
我们构建 index
→ ${widget.key}
→ List<element>
的映射关系:在 widget 创建处建立 index
→ ${widget.key}
映射,在 element 应该被销毁移除的逻辑处,将 element 缓存至 ${widget.key}
映射的 List<element>
处(注意 renderObject 对象需要从父节点移除)。列表滑动过程中,优先根据映射关系找到缓存中的 element 并使用(注意更新 element.renderObject.parentData 中的 index 值)
4.3 复杂 Widget 分帧上屏
以上全部优化手段尝试后,在闲鱼的详情页和搜索页上还是远没有达到预期。原因是猜你喜欢卡片和搜索页卡片本身就足够复杂,另外由于我们引入 DX 技术让 Widget 进一步变得巨大,最终导致的结果是:即便高端机,也无法在一帧时间内完成渲染。
然而抛开技术视角,从业务视角看,卡片展现内容和 DX 的动态能力都是必需的。那如何在满足业务诉求的情况下,实现超大 Widget 的高性能呢?
业务侧仅需 Text,但在 DX 技术中使用的是 DXTextWidget
猜你喜欢卡片在 红米 K30Pro(CPU 骁龙 865)的 Timeline 图
搜索结果卡片 Timeline 图,补充了 performLayout、updateChild、Widget build
在已知常见优化手段无法满足的情况下,我们回归 GUI 系统性能优化的起点去思考问题。流畅度优化思路,大体可以分为 3 个方向:
- 多线程方案
在 Android 原生开发中很常见。但在 dart 世界中,不同线程(isolate)的内存是隔离的,此外由于 flutter 渲染流程三棵树,我们不好直接操作 RenderObject,多线程方案在 flutter 中较难实施(排除 IO 更新数据后显示等常规场景) - 优化每个任务,挤压 CPU 运算量,保证一帧时间(16.6 ms)完成任务
- 中的主流优化思路,前面的优化手段都是这个思路
- 快速响应用户,让用户觉得够快,不阻塞用户的交互。即一帧时间内还有任务没有完成,则停止执行,保证列表先执行滑动,未执行任务在后续帧时间片上执行
参考 React Fiber 框架,基于时间分片的思路,协调阶段将一颗任务树转为一条任务链(parent 节点 → child 节点 → sibling 节点 → parent 节点),满足了任务链可中断执行,提前提交渲染,最后实现了将一条任务链拆解到多帧时间分片中消化。
排除方向 1、2 后,只剩下方向 3。再结合猜你喜欢卡片 Timeline 图可以发现,在卡片 Widget 创建的一帧发生时间不足,而后面的几帧内时间消耗都远没到 16.6 ms,可以想到方向 3 是正确的。那剩下的关键问题仅有以下 2 点:
- 能否将一个大 Widget build 任务为拆分多个小 Widget build 任务并大致平均的分配到多个时间分片上?
- 一个大 widget 分时间片上屏是否会影响体验?
Timeline 上任务耗时图
Flutter widget 拆分和分帧上屏
基于时间分片的大方向,我们把一个大 widget 拆分为一个空白框架和 2 个卡片 widget,再将卡片 widget 拆分为一个卡片框架和多个 FXImage Widget,Widget 框架中不立马显示的部分使用占位 Widget 临时代替。
由此构建一个高优大任务队列和一个低优小任务队列,高优大任务队列中的任务高优执行且独占一帧时间,低优小任务队列低优执行且一帧时间最多能执行 12 个任务。再利用 flutter 逐步标脏,将 build 任务延迟到后续时间分片上。
以上最终将一个超大 widget 构建从 1 帧时间分散到 4 帧时间内消化,优化了卡顿。
优化后猜你喜欢卡片 Timeline 图(红米 K30Pro,CPU 骁龙 865)
在体验方面,前面讲列表控件结构时已知有一个不可见的 Cache 区域,所以分帧上屏大部分是在这个不可见区域完成的,为此在高端机或正常滑动情况下用户并无感知。而在低端机上快速滑动能明显看到卡片空白情况,但整体相比严重顿挫体感要好。
4.4 优化数据
基于上面的优化手段,闲鱼详情页和搜索页流畅度 FPS 提升了 3 个点,低端机大卡顿次数降低一半,中高端机型上流畅度提升到 57 或以上,大卡顿次数接近 0。
详情页
线上高可用 fps 数据如下:
线上低端机 fps 曲线。绿色为优化版本
曲线分布越靠右,流畅度越好
线上高端机 fps 曲线。绿色为优化版本
搜索页
线上高可用 fps 数据如下:
线上低端机 fps 曲线。绿色为优化版本
线上高端机 fps 曲线。绿色为优化版本
4.5 滑动差值器优化
完成上面优化后,线下自建流畅度检测工具数据和线上 fps 数据曲线都有很大的提升,且数据指标接近原生 APP 流畅度。在中高端机型上,闲鱼详情页 FPS 已经被我们优化到了 57 及以上了,1s 大卡顿次数接近 0。在原生 APP 流畅度 FPS 数值达到 57 及以上时,滑动过程中基本上不会感受到卡顿,然而,flutter 页面的实际滑动操作中,还是能感受到卡顿。
回顾自建流畅度检测工具原理:基于每帧画面比对、无侵入,相同的自动化脚本,所以相信我们线下测试的数据(平均 FPS 和 1s 大卡顿次数)是准确的。性能数据接近,而体感有差异,且性能数据准确可信,所以可以确认流畅度指标(平均 FPS 和 1s 大卡顿次数)还不能完全反应体感。
再回顾 2.2 流畅度指标制定,可以发现我们并没有对空间维度的 offset 跳变(画面内容跳变)做检测。基于此,我们可以对比 Android 原生 RecyclerView 和 Flutter SliverList 在卡顿情况下 offset 变化情况
Android 原生 RecyclerView 和 Flutter SliverList fling 阶段 offset/time 曲线图
由上可以得到,同样在 FPS 值达到 57,Android RecyclerView 在用户体感上比 flutter 列表控件更好的原因:在小卡顿时,offset 偏移值并没有发生翻倍跳变。
查看 flutter 滑动算法,可以发现是基于一条 D/T 曲线计算滑动距离,所以发生卡顿时,输入 timeOffset 值发生翻倍,最终计算出来的 offset 值发生近乎翻倍。
flutter ClampingScrollSimulation D/T 曲线
为消除在发生小卡顿时,offset 跳变的情况,我们自定义了 physics 和 simulation,在 time 发生发生小跳变时,修改滑动距离算法,采用 V/T 曲线算法,distance 通过累加的方式计算,优化了 time offset 发生翻倍而导致曲线跳变的情况
distance = velocity(time) * 16.6ms + distance
注意:需要适配系统频率大于 60 hz 的机型(如 90hz,120hz),在一帧时间内有可能计算多次 distance
以 V/T 曲线为基础,我们提供了以下滑动差值器:
- SmoothClampingScrollPhysics
无回弹差值器,停顿后偏移值不跳变。 结束滑动的效果同 ClampingScrollSimulation
- SmoothBouncingScrollPhysics
回弹差值器,停顿后偏移值不跳变
5 总结和展望
经过上述优化,在原生 Android 方面,闲鱼首页流畅度和内容上屏得到明显提升;在 Flutter 方面,闲鱼详情页和搜索页流畅度 FPS 提升了 3 个点,低端机大卡顿次数降低一半,中高端机型上流畅度提升到 57 或以上,大卡顿次数接近 0,相同小卡顿在体验上得到了提升。
流畅度优化是每一个 GUI 系统都一直在努力的事情,有很多优秀的工具介绍、官方和非官方的优化文章。这次优化过程中,我们也借鉴了很多别人的文章,发现和优化了一些问题,但本文尽量不去重复描述,推荐读者阅读相关优化文章或官方文档。
在以上优化手段尚无法实现最终目标时,我们也做了一些不一样的优化,期望能抛砖引玉,对读者有所帮助和启发:
- 基于用户体验为导向构建了流畅度指标:平均 FPS,1s 大卡顿次数
- 针对指标,自建了流畅度检测工具,支持无侵入、跨平台、自动化
- [Android] 显示 ViewDataUnbinder 组件在复杂业务逻辑中快速抽离 UI 操作
- [Flutter] 修改 Flutter engine 源码,支持列表 element 复用
- [Flutter] 实现大 Widget 分帧上屏组件
- [Flutter] 差值器算法优化
后续我们会继续思考以下内容:
- 如何将流畅度检测工具内部产品化,支持非研发同事使用?
- 如何使用已有的经验、工具、组件快速优化其他业务页面?
- 如何在研发阶段及时发现和防止无效 rebuild 等问题?
- 如何在 CI 平台及时发现页面流畅度恶化情况?
- 如何以业务无侵入的方式实现业务大 Widget 自动且合理地分帧上屏?