闲鱼在业务的快速迭代过程中,app 的长列表滑动流畅度逐步恶化,对用户浏览内容体验产生伤害。闲鱼作为国内 flutter 应用的先驱,APP 以 flutter 和原生 Native 的混合工程存在。这里分别就 Android 原生、flutter 页面和大家分享我们的优化思路。
本文分为三个部分:
流畅度优化整体思路图如下:
检测工具现状:以 Android 为例,现有流畅度工具可分为:
侵入式
无侵入式
adb shell dumpsys gfxinfo ${packageName}
service call SurfaceFlinger 1013
,高版本 Android 已不支持流畅度指标现状有:
Total frames rendered: 2245
Janky frames: 31 (1.38%)
50th percentile: 5ms
90th percentile: 10ms
95th percentile: 14ms
99th percentile: 18ms
然而以上工具和指标定义在 app 的复杂场景下,尚存在问题
平均 FPS(SM 和 SF 类似),不足以反映用户体验。如相同 30 FPS,可以是 1s 内 30 个 33.3 ms的画面,也可以是 29 个 16.6ms 的画面再加 1 个 516.9 ms 的画面,但用户体验并不相同。
维基百科 动画定义:一种通过定时拍摄一系列多个静止的固态图像(帧)以一定频率连续变化、运动(播放)的速度(如每秒16张)而导致肉眼的视觉残象产生的错觉——而误以为图画或物体(画面)活动的作品及其影片技术。
列表滑动同理,是 APP 以一定频率(60hz下16.6ms)和不同 offset 计算出一系列静止画面,让肉眼看到滑动动画。
当我们说列表滑动不流畅,是因为频率过低无法让肉眼产生视觉残留,或在时间(画面停留时长)和空间(画面内容)产生跳变,让用户感知到变化的不自然。以此我们可以定义指标如下:
时间角度
空间角度
综上,我们定义流畅度指标为平均 FPS 值和 1s 大卡顿次数。
我们从 APP 录屏画面入手,计算流畅度指标值。当我们得到 APP 滑动过程中的录屏数据,可通过每 16.6ms 检测录屏画面是否发生变化,当连续画面未发生变化,则表示发生了卡顿。无变化的连续画面数则表示了卡顿的时长。
为得到目标 APP 录屏数据,检测工具 APP 向系统注册录屏服务,然后在检测工具 APP 的帧回调中不停读取录屏画面,并和上次检测画面 hash 值进行比对。
为排除滑动操作对流畅度数值的干扰,我们使用脚本操作检测工具 APP 和目标 APP 的滑动。自动化脚本原理为使用 adb 命令操作手机
点击:adb shell input tap $x $y
滑动:adb shell input swipe $x1 $y1 $x2 $y2 $duration
流畅度检测工具 APP 以悬浮框的方式显示,下面为目标检测 APP:
流畅度检测工具界面
在流畅度指标方面,我们定义了平均 FPS 和 1s 大卡顿次数作为指标,更好的反应了用户体验。
在流畅度检测工具方面,我们实现了无侵入检测工具,支持以下特性:
此外,流畅度检测工具还有一些不足之处
列表中有视频卡片
低端机(y67)真实 fps 计算存在偏差
Android 原生长列表优化已经非常成熟了,在工具方面有 traceview、blockcanary、DDMS、Android Profile 等。常见优化手段也很多:布局层级优化,过度渲染优化,频繁measure、layout优化,UI 线程耗时方法优化、冗余资源资源加载优化等,这里不再赘述。
除此之外闲鱼使用以下 2 点优化首页
通过工具检测或耗时打印,发现列表初始滑动和 loadmore 时触发 item 视图构建耗时严重(RecyclerView.onCreateViewHolder)
查看首页显示和初始滑动流程,可以发现流程中其他 UI 操作过程和等待用户操作过程均有优化空间。
利用 AsyncLayoutInflater 原理异步构建视图缓存池,优化首页列表流程如下:
其中视图缓存池构建完成的时机在不同机型下不同,可能在列表首屏多卡片构建之前,或构建中,或在用户滑动操作之前完成,或一开始构建就抛出错误停止构建
注意:不能直接使用 AsyncLayoutInflater,AsyncLayoutInflater 在异步构建失败后有一个降级到 UI 线程构建的逻辑,为避免降级逻辑发生导致缓存池在 UI 线程构建,导致页面更加卡顿,需要移除这个降级逻辑:出现异步 inflater 失败,停止缓存池构建。
在卡片数据绑定阶段(RecyclerView.onBindViewHolder),在低端机上耗时较为严重,原因是在卡片数据绑定方法中,而 UI 和非 UI 操作糅合在一起,由于 UI 逻辑必须在 UI 线程执行,最终导致全部逻辑只能在 UI 线程执行。
能想到定义视图数据层,将 UI 和非 UI 操作分离开,然而实际编码发现业务代码改动量大且容易出错,AB 测试逻辑难以实现。那有没有更好的方案,用最少量代码抽离 UI 操作呢?
核心思路:编译期根据视图类自动生成 ViewData 类,并替换视图类实例。ViewData 类和视图类拥有相同的关键方法签名,方法执行时记录视图操作,统一切换到 UI 线程执行视图操作。
具体使用代码样例如下
其中注解说明
业务代码修改
闲鱼首页,在恢复内容上屏速度(流畅度降低)后提升流畅度
flutter 一直以高性能被大家所认知,参考 Flutter 是如何做到性能直逼 native 的?,这也是闲鱼当初选择 flutter 的一个重要原因。而在闲鱼的实际 flutter 页面,如商品详情页和搜索结果页,长列表滑动流畅度体验却不尽人意。
做性能优化前,需要理解 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的高性能高流畅
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;
...
}
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 重建。
使用 Timeline 查看渲染线程性能消耗,可以发现有多个 ClipRectLayer
和 ClipRRectLayer
。
打开 Debug flag debugDisableClipLayers
和 debugDisablePhysicalShapeLayers
重新检查视图,可以发现部分 ClipRectLayer 是因为图片内容超出视图边界产生,部分 ClipRRectLayer 是因为卡片 Widget 圆角设置以及基于外接纹理的图片控件里设置了 ClipRRect 设置(即便 radius 为0也会设置)
理解原理后,我们对闲鱼图片控件新增参数,支持图片内容圆角设置和图片内容宽高裁剪,使 native 层生成的 Bitmap 已经满足圆角和宽高比要求。同时修复 radius 为0也会设置 ClipRRect 的问题。优化后的 Timeline 图如下:
flutter 性能优化相关的优秀文章很多,本文不再对类似的排查和优化手段做赘述,这里做下简单汇总:
widget build 优化
主 isolate 优化
Render 线程优化
工具推荐
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 值)
以上全部优化手段尝试后,在闲鱼的详情页和搜索页上还是远没有达到预期。原因是猜你喜欢卡片和搜索页卡片本身就足够复杂,另外由于我们引入 DX 技术让 Widget 进一步变得巨大,最终导致的结果是:即便高端机,也无法在一帧时间内完成渲染。
然而抛开技术视角,从业务视角看,卡片展现内容和 DX 的动态能力都是必需的。那如何在满足业务诉求的情况下,实现超大 Widget 的高性能呢?
业务侧仅需 Text,但在 DX 技术中使用的是 DXTextWidget
猜你喜欢卡片在 红米 K30Pro(CPU 骁龙 865)的 Timeline 图
搜索结果卡片 Timeline 图,补充了 performLayout、updateChild、Widget build
在已知常见优化手段无法满足的情况下,我们回归 GUI 系统性能优化的起点去思考问题。流畅度优化思路,大体可以分为 3 个方向:
排除方向 1、2 后,只剩下方向 3。再结合猜你喜欢卡片 Timeline 图可以发现,在卡片 Widget 创建的一帧发生时间不足,而后面的几帧内时间消耗都远没到 16.6 ms,可以想到方向 3 是正确的。那剩下的关键问题仅有以下 2 点:
Timeline 上任务耗时图
Flutter widget 拆分和分帧上屏
基于时间分片的大方向,我们把一个大 widget 拆分为一个空白框架和 2 个卡片 widget,再将卡片 widget 拆分为一个卡片框架和多个 FXImage Widget,Widget 框架中不立马显示的部分使用占位 Widget 临时代替。
由此构建一个高优大任务队列和一个低优小任务队列,高优大任务队列中的任务高优执行且独占一帧时间,低优小任务队列低优执行且一帧时间最多能执行 12 个任务。再利用 flutter 逐步标脏,将 build 任务延迟到后续时间分片上。
以上最终将一个超大 widget 构建从 1 帧时间分散到 4 帧时间内消化,优化了卡顿。
优化后猜你喜欢卡片 Timeline 图(红米 K30Pro,CPU 骁龙 865)
在体验方面,前面讲列表控件结构时已知有一个不可见的 Cache 区域,所以分帧上屏大部分是在这个不可见区域完成的,为此在高端机或正常滑动情况下用户并无感知。而在低端机上快速滑动能明显看到卡片空白情况,但整体相比严重顿挫体感要好。
基于上面的优化手段,闲鱼详情页和搜索页流畅度 FPS 提升了 3 个点,低端机大卡顿次数降低一半,中高端机型上流畅度提升到 57 或以上,大卡顿次数接近 0。
详情页
线上高可用 fps 数据如下:
线上低端机 fps 曲线。绿色为优化版本
曲线分布越靠右,流畅度越好
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。