他把闲鱼APP长列表流畅度翻了倍(良心教程)
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 callSurfaceFlinger1013
,高版本 Android 已不支持
流畅度指标现状有:
FPS (Frames Per Second)
SF(SkippedFrame,跳帧) app 在单位时间 1 秒内,跳过执行 Choreographer 中 doFrame() 的次数
SM(Smooth,流畅度) app 在单位时间 1 秒内,实际执行 Choreographer 中 doFrame() 的次数。其中 SM=60-SF。
帧耗时数据 使用 adb 命令得到几个关键分位的帧平均耗时:
然而以上工具和指标定义在 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 发生卡顿并不影响检测工具 APP 的帧回调
为保证每次录屏画面读取和 hash 值计算在 16.6ms 内完成,需根据高低端机型调整画面宽高压缩比。
流畅度检测工具界面
2.5 小结和展望
无侵入
支持检测第三方 app
支持多平台:native,flutter,h5,小程序
多维度数据:平均 FPS,平均 1s 大卡帧次数,帧分布直方图,帧分布均方差
自动操作,避免人为操作差异
列表中有视频卡片
停止滑动时,若列表中有视频播放,由于画面一直在变化,检测工具无法判断是滑动停止;同时,由于视频 fps 值为 30 左右,会导致流畅度数据偏低
如何避免:检测过程中,需保证列表滑动不停止
低端机(y67)真实 fps 计算存在偏差
为保证低端机上(如 vivo y67)上计算大图像 hash 值在 16ms 以内,录屏画面压缩较大(宽度压缩 100,高度压缩 10),为此在大量空白或者大色块的场景下,无法检测到画面的细微变化,fps 计算存在偏低。
如何避免:避免低端机上检测大量空白或大色块的场景
3 原生 Android 长列表优化
3.1 异步构建视图缓存池
其中视图缓存池构建完成的时机在不同机型下不同,可能在列表首屏多卡片构建之前,或构建中,或在用户滑动操作之前完成,或一开始构建就抛出错误停止构建 注意:不能直接使用 AsyncLayoutInflater,AsyncLayoutInflater 在异步构建失败后有一个降级到 UI 线程构建的逻辑,为避免降级逻辑发生导致缓存池在 UI 线程构建,导致页面更加卡顿,需要移除这个降级逻辑:出现异步 inflater 失败,停止缓存池构建。
3.2 ViewDataUnbinder 快速抽离 UI 操作
1. 注解视图类 使用 ViewDataAnno 注解视图类,UIMethodAnno 注解 UI 操作方法。
2. 生成 ViewData 类
3. 业务代码修改
修改视图变量为 ViewData 类型
原视图数据绑定逻辑放置后台线程
3.3 优化结果
闲鱼首页,在恢复内容上屏速度(流畅度降低)后提升流畅度
4 Flutter 复杂长列表优化
4.1 工具使用和常见优化
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 情况
View
→ ToolWindows
→ FlutterPerformance
打开检测 Widget rebuild 情况,可以发现 FDButtonBar 被频繁重建,然而查看视图内容并没有发生变化。查看代码定位到 reducer.dart
中会根据滑动事件更新 state 中的 scrollPercent
,进而产生重建。而在详情页中, scrollPercent
在 Widget 构建中并未参与使用。闲鱼页面中使用了 fish-redux,在 reducer.dart 的方法中返回不同的 state 对象则表示需要重建 widget
4.1.2 使用 fish-redux 性能日志
profile 模式下时间日志
问卖家
的显示消失均需要根据滑动事件做判断。结合业务逻辑,可以发现,除了 问卖家
外,其他视图在滑动超出 600 之后,收到滑动事件后不会发生视图内容变化;而 问卖家
在滑动超出更大的一个值后会永远消失不显示,在一开始未超出这个值时,仅需要判断滑动方向即可。基于以上业务背景,在滑动超出 600 后,若 问卖家
是不再显示状态,则不发送滑动事件;否则仅在开始滑动的 30 距离内发送事件。4.1.3 优化 ClipPath 和 ClipRPath
ClipRectLayer
和 ClipRRectLayer
。debugDisableClipLayers
和 debugDisablePhysicalShapeLayers
重新检查视图,可以发现部分 ClipRectLayer 是因为图片内容超出视图边界产生,部分 ClipRRectLayer 是因为卡片 Widget 圆角设置以及基于外接纹理的图片控件里设置了 ClipRRect 设置(即便 radius 为0也会设置)4.1.4 其他优化建议
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 性能日志
列表控件源码见 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 分帧上屏
业务侧仅需 Text,但在 DX 技术中使用的是 DXTextWidget
猜你喜欢卡片在 红米 K30Pro(CPU 骁龙 865)的 Timeline 图
搜索结果卡片 Timeline 图,补充了 performLayout、updateChild、Widget build
多线程方案
在 Android 原生开发中很常见。但在 dart 世界中,不同线程(isolate)的内存是隔离的,此外由于 flutter 渲染流程三棵树,我们不好直接操作 RenderObject,多线程方案在 flutter 中较难实施(排除 IO 更新数据后显示等常规场景)
优化每个任务,挤压 CPU 运算量,保证一帧时间(16.6 ms)完成任务
flutter 中的主流优化思路,前面的优化手段都是这个思路
快速响应用户,让用户觉得够快,不阻塞用户的交互
即一帧时间内还有任务没有完成,则停止执行,保证列表先执行滑动,未执行任务在后续帧时间片上执行 参考 React Fiber 框架,基于时间分片的思路,协调阶段将一颗任务树转为一条任务链(parent 节点 → child 节点 → sibling 节点 → parent 节点),满足了任务链可中断执行,提前提交渲染,最后实现了将一条任务链拆解到多帧时间分片中消化。
能否将一个大 Widget build 任务为拆分多个小 Widget build 任务并大致平均的分配到多个时间分片上?
一个大 widget 分时间片上屏是否会影响体验?
Timeline 上任务耗时图
Flutter widget 拆分和分帧上屏
优化后猜你喜欢卡片 Timeline 图(红米 K30Pro,CPU 骁龙 865)
4.4 优化数据
详情页
线上高可用 fps 数据如下:线上低端机 fps 曲线。绿色为优化版本 曲线分布越靠右,流畅度越好
线上高端机 fps 曲线。绿色为优化版本
搜索页
线上高可用 fps 数据如下:线上低端机 fps 曲线。绿色为优化版本
线上高端机 fps 曲线。绿色为优化版本
4.5 滑动差值器优化
Android 原生 RecyclerView 和 Flutter SliverList fling 阶段 offset/time 曲线图
flutter ClampingScrollSimulation D/T 曲线
distance = velocity(time) * 16.6ms+ distance
注意:需要适配系统频率大于 60 hz 的机型(如 90hz,120hz),在一帧时间内有可能计算多次 distance
SmoothClampingScrollPhysics 无回弹差值器,停顿后偏移值不跳变。结束滑动的效果同 ClampingScrollSimulation
SmoothBouncingScrollPhysics 回弹差值器,停顿后偏移值不跳变
5 总结和展望
基于用户体验为导向构建了流畅度指标:平均 FPS,1s 大卡顿次数
针对指标,自建了流畅度检测工具,支持无侵入、跨平台、自动化
[Android] 显示 ViewDataUnbinder 组件在复杂业务逻辑中快速抽离 UI 操作
[Flutter] 修改 Flutter engine 源码,支持列表 element 复用
[Flutter] 实现大 Widget 分帧上屏组件
[Flutter] 差值器算法优化
如何将流畅度检测工具内部产品化,支持非研发同事使用?
如何使用已有的经验、工具、组件快速优化其他业务页面?
如何在研发阶段及时发现和防止无效 rebuild 等问题?
如何在 CI 平台及时发现页面流畅度恶化情况?
如何以业务无侵入的方式实现业务大 Widget 自动且合理地分帧上屏?