Android开发FlutterAndroid开发技术

阿里技术团队整理:闲鱼APP长列表流畅度翻了倍(良心教程)

2020-11-07  本文已影响0人  今日Android

简介: 从“麻绳版顺滑”到“丝般顺滑”

作者:闲鱼技术-云从

1 整体思路

闲鱼在业务的快速迭代过程中,app 的长列表滑动流畅度逐步恶化,对用户浏览内容体验产生伤害。闲鱼作为国内 flutter 应用的先驱,APP 以 flutter 和原生 Native 的混合工程存在。这里分别就 Android 原生、flutter 页面和大家分享我们的优化思路。

本文分为三个部分:

流畅度优化整体思路图如下:

2 流畅度指标和检测工具构建

2.1 现状和难点

检测工具现状:以 Android 为例,现有流畅度工具可分为:

流畅度指标现状有:

  1. 在单位时间 1 秒内,跳过执行 Choreographer 中 doFrame() 的次数 (见 《移动App性能评测与优化》
  1. 在单位时间 1 秒内,实际执行 Choreographer 中 doFrame() 的次数。其中 SM=60-SF。(见 《移动App性能评测与优化》
Total frames rendered: 2245
Janky frames: 31 (1.38%)
50th percentile: 5ms
90th percentile: 10ms
95th percentile: 14ms
99th percentile: 18ms

然而以上工具和指标定义在 app 的复杂场景下,尚存在问题

  1. 多平台问题
    现 APP 技术有原生、h5、小程序、RN、weex、flutter 等。暂无一款无侵入的流畅度检测工具能同时支持多个平台、多种机型和多个指标数据,而侵入式的检测工具无法检测竞品 APP。
  2. 指标选择和用户体验一致性
    我们期望能有少量的几个指标数据,准确的表达用户流畅度体感。

平均 FPS(SM 和 SF 类似),不足以反映用户体验。如相同 30 FPS,可以是 1s 内 30 个 33.3 ms的画面,也可以是 29 个 16.6ms 的画面再加 1 个 516.9 ms 的画面,但用户体验并不相同。

  1. 流畅度数据影响因素多
    滑动速度和滑动状态:idle(停止)、drag(手指拖拽)、fling(自由滑动)都是影响流畅度数据的重要因素。

2.2 流畅度指标制定

维基百科 动画定义:一种通过定时拍摄一系列多个静止的固态图像(帧)以一定频率连续变化、运动(播放)的速度(如每秒16张)而导致肉眼的视觉残象产生的错觉——而误以为图画或物体(画面)活动的作品及其影片技术。

列表滑动同理,是 APP 以一定频率(60hz下16.6ms)和不同 offset 计算出一系列静止画面,让肉眼看到滑动动画。

当我们说列表滑动不流畅,是因为频率过低无法让肉眼产生视觉残留,或在时间(画面停留时长)和空间(画面内容)产生跳变,让用户感知到变化的不自然。以此我们可以定义指标如下:

综上,我们定义流畅度指标为平均 FPS 值和 1s 大卡顿次数。

2.3 流畅度检测工具实现

我们从 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

2.4 检测工具演示

流畅度检测工具 APP 以悬浮框的方式显示,下面为目标检测 APP:

流畅度检测工具界面

2.5 小结和展望

在流畅度指标方面,我们定义了平均 FPS 和 1s 大卡顿次数作为指标,更好的反应了用户体验。
在流畅度检测工具方面,我们实现了无侵入检测工具,支持以下特性:

此外,流畅度检测工具还有一些不足之处

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 线程执行视图操作。

具体使用代码样例如下

  1. 注解视图类
    使用 ViewDataAnno 注解视图类,UIMethodAnno 注解 UI 操作方法。
  1. 生成 ViewData 类
  1. 业务代码修改

    • 修改视图变量为 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 上 ViewTool WindowsFlutter 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 查看渲染线程性能消耗,可以发现有多个 ClipRectLayerClipRRectLayer

打开 Debug flag debugDisableClipLayersdebugDisablePhysicalShapeLayers 重新检查视图,可以发现部分 ClipRectLayer 是因为图片内容超出视图边界产生,部分 ClipRRectLayer 是因为卡片 Widget 圆角设置以及基于外接纹理的图片控件里设置了 ClipRRect 设置(即便 radius 为0也会设置)

理解原理后,我们对闲鱼图片控件新增参数,支持图片内容圆角设置和图片内容宽高裁剪,使 native 层生成的 Bitmap 已经满足圆角和宽高比要求。同时修复 radius 为0也会设置 ClipRRect 的问题。优化后的 Timeline 图如下:

4.1.4 其他优化建议

flutter 性能优化相关的优秀文章很多,本文不再对类似的排查和优化手段做赘述,这里做下简单汇总:

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 个方向:

  1. 多线程方案
    在 Android 原生开发中很常见。但在 dart 世界中,不同线程(isolate)的内存是隔离的,此外由于 flutter 渲染流程三棵树,我们不好直接操作 RenderObject,多线程方案在 flutter 中较难实施(排除 IO 更新数据后显示等常规场景)
  2. 优化每个任务,挤压 CPU 运算量,保证一帧时间(16.6 ms)完成任务
  3. 中的主流优化思路,前面的优化手段都是这个思路
  4. 快速响应用户,让用户觉得够快,不阻塞用户的交互。即一帧时间内还有任务没有完成,则停止执行,保证列表先执行滑动,未执行任务在后续帧时间片上执行
    参考 React Fiber 框架,基于时间分片的思路,协调阶段将一颗任务树转为一条任务链(parent 节点 → child 节点 → sibling 节点 → parent 节点),满足了任务链可中断执行,提前提交渲染,最后实现了将一条任务链拆解到多帧时间分片中消化。

排除方向 1、2 后,只剩下方向 3。再结合猜你喜欢卡片 Timeline 图可以发现,在卡片 Widget 创建的一帧发生时间不足,而后面的几帧内时间消耗都远没到 16.6 ms,可以想到方向 3 是正确的。那剩下的关键问题仅有以下 2 点:

  1. 能否将一个大 Widget build 任务为拆分多个小 Widget build 任务并大致平均的分配到多个时间分片上?
  2. 一个大 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 曲线为基础,我们提供了以下滑动差值器:

5 总结和展望

经过上述优化,在原生 Android 方面,闲鱼首页流畅度和内容上屏得到明显提升;在 Flutter 方面,闲鱼详情页和搜索页流畅度 FPS 提升了 3 个点,低端机大卡顿次数降低一半,中高端机型上流畅度提升到 57 或以上,大卡顿次数接近 0,相同小卡顿在体验上得到了提升。

流畅度优化是每一个 GUI 系统都一直在努力的事情,有很多优秀的工具介绍、官方和非官方的优化文章。这次优化过程中,我们也借鉴了很多别人的文章,发现和优化了一些问题,但本文尽量不去重复描述,推荐读者阅读相关优化文章或官方文档。
在以上优化手段尚无法实现最终目标时,我们也做了一些不一样的优化,期望能抛砖引玉,对读者有所帮助和启发:

后续我们会继续思考以下内容:

本文在开源项目:https://github.com/Android-Alvin/Android-LearningNotes 中已收录,里面包含不同方向的自学编程路线、面试题集合/面经、及系列技术文章等,资源持续更新中...

上一篇 下一篇

猜你喜欢

热点阅读