招贤纳士
我们急切需要浏览器渲染引擎/Flutter 渲染引擎的人才,欢迎大牛们加入我们。
前言
1 月 16 日,UC 技术委员会联合掘金、谷歌开发者社区举办了 2021 年首届 Flutter 引擎为主题的技术沙龙活动。活动吸引了 150 多名同学报名,由于疫情控制现场人数的影响,最终我们只能安排 50 名同学来到现场。另外,有 2000 余名同学围观了现场直播。活动中,来自阿里巴巴集团内的五位技术专家与大家分享了基于 Flutter 构建的研发体系,开发与优化经验,动态化方案,以及 UC 定制的 Flutter 增强引擎 Hummer 的优势与新特性。
第一场分享是由 UC/夸克客户端负责人、UC 移动技术中台负责人辉鸿带来的《打造基于 Flutter 的 UC 移动技术中台》。
第二场分享是由 UC Flutter Hummer 引擎技术负责人佬龙带来的《Hummer (Flutter 定制引擎)优化及体系化建设探索》。
第三场分享是由 UC 浏览器视频技术专家礼渊带来的《基于 Flutter 的移动中间件技术体系和音视频技术》。
第四场分享是由闲鱼移动组技术专家云从带来的《闲鱼在 Flutter 上的体验优化实践》。本文约 5600 字,36 张图片,整体阅读时间约 30 分钟。
分享内容
大家下午好,欢迎参加本次分享,很高兴收到 UC 团队邀请参与本次分享,恰好闲鱼在 2020 年 7 月 ~ 9 月做了一波性能体验优化,所以总结了本次分享内容《闲鱼在 Flutter 上的体验优化实践》。
今天会分享以下内容:
第一,介绍 Flutter 在性能体验方面的行业挑战有哪些?
第二,介绍闲鱼在流畅度和内存方面的优化是如何做的,会从指标和工具建设、问题发现和定位、优化方向和手段介绍我们的实践历程;
最后,展望一下闲鱼在流畅度和内存方面未来工作展望 Flutter 行业挑战有哪些?
在性能体验方面,Flutter 一直以高性能著称,查看 FlutterGallery 也确实流畅度很高,然而在企业复杂业务场景下,流畅度表现和原生 APP 对比,就有点差强人意。
在框架设计方面, Flutter以Dart 作为 Framework 和业务开发语言,Dart 的线程模型和 js 类似,是一种单线程模型,因此在原生 APP 开发中常见的多线程优化方向不再适用。其次 Flutter 中著名的 3 棵树(Widget、Element、RenderObject)在一定程度上保证了性能,但同时也容易被业务开发忽略的是,Widget 到 RenderObject diff 更新是有性能消耗的。Dart 语言有类似 java 的基于可达性分析的内存垃圾回收机制,然而也容易让业务开发忽略的是 Flutter 还有 engine 层 C++ 部分的 External Memory。
在工具链建设方面,Android adb 检测流畅度在 Flutter 页面不在适用,平均 FPS 并不能完全体现用户体感,因此符合用户体感的指标和检测工具建设也存在空缺。此外,Flutter Dart 线上或线下的卡顿堆栈捕获组件也存在缺失。在内存泄漏检测方面,各个大厂还在积极建设中,如快手、阿里都有自己的方案。
在优化方向方面,原生 APP 开发中,研发可将大部分非 UI 操作逻辑放入后台线程实现优化;而在 Flutter 中多线程优化方向不再适用。在企业级的复杂场景下,我们按照官方建议优化常见性能问题,使用官方工具和自建工具发现慢函数并优化,但流畅度结果和预期还存在不小的差距,优化 Flutter 长列表流畅度存在难度。内存方面,由于检测工具建设原因,内存泄漏检测和定位,峰值内存优化都存在一定的难度。
针对以上挑战,闲鱼是如何在流畅度和内存上做性能优化呢?我们将从指标工具、问题定位、优化方向3个步骤介绍我们的优化思路。
流畅度
指标和工具建设
右边图片是闲鱼的商品详情页在高端机上的一个流畅度性能表现,可以看到,即便是高端机也出现了明显的卡顿掉帧。左边视频是苹果 2018 年 WWDC 上的分享,体感上明显左边视频更加卡顿,但检测到的 FPS 值左边却高于右边。所以,即便是业内最权威的指标 FPS 也不能完全反应用户体感,为此我们提出平均 FPS 和大卡顿次数(平均 1s 内发生 49ms 及以上的卡顿次数)作为流畅度指标。
有了指标,就需要有对应的监控工具得到数据。我们期望流畅度工具能做到无侵入、支持多平台(原生、flutter、weex、h5、小程序)、多维度(多种性能数据)、自动滑动(排除手动滑动产生的误差)。闲鱼团队在 Android 平台开发了独立 APP,见右图的悬浮框,点击开始后,能自动检测悬浮框底下的 APP 流畅度数据,其中平均 FPS=57,大卡顿次数 =0.306。帧分布数据展示了画面统计原始数据:16.6ms 画面 371 次,16.6ms*2 画面 6 次,16.6ms*3 画面 1 次。
流畅度检测工具实现原理是基于画面录屏数据:我们向系统注册录屏服务,然后每 16.6ms 从 VirtualDisplay 中读取压缩的画面数据,计算得到画面 hash 值,如果连续 hash 值相同,就可知画面没有发生变化,发生了卡顿掉帧。
问题发现和定位
有了指标和检测工具后,我们得到了 APP 的流畅度现状,那我们要如何优化呢?首先我们需要发现和定位业务侧是否有不合理的逻辑,这里推荐官方的几个强大工具:Flutter Performance、DevTools、Debug flags,并介绍工具在闲鱼项目的实际应用。
左边是闲鱼详情页,底下是 FDDetailBottomBar,在我们滑动列表的时候,在 Flutter Performance(右边图)能看到 FDDetailBottomBar 在不停的重建,然而实际视图并没有发生变化。以上,我们需要优化底部视图的更新逻辑,避免无效刷新。
上图是详情页猜你喜欢卡片,使用 DevTools 工具在 Render 线程查看 Timeline,可以发现有很多 ClipRRectLayer 和 ClipRectLayer,降低了渲染性能。因为卡片中有圆角所以存在 ClipRRectLayer,但 ClipRectLayer 是如何生成的呢?
使用 Debug Flags,将 debugDisableClipLayers 设置为 true,重新查看视图,可以发现图片显示宽高比和 Widget 宽高比并不一致,所以在显示过程中需要 ClipRectLayer 裁剪多余部分。
定位到了问题就好解决了,在 Native 侧请求图片时就强制指定宽高比,对得到的图片数据进行圆角裁剪再给到 Flutter Widget,这是就可以把 ClipRRect 去掉了。最后得到的效果可见 timeline 图。
图片请求强制保证高宽比实例如下: gw.alicdn.com/bao/uploade… image.png
gw.alicdn.com/bao/uploade… image.png image.png
除了官方常见工具,闲鱼也有自建工具帮助我们发现了问题。
FishRedux 是闲鱼自建的一个数据驱动视图,组装式的 Flutter 应用框架,在 debug 环境下,FishRedux 会打印 action 消费的性能日志,通过修改源码,让非 Debug 包也输出性能日志,能快速发现问题。如上图,闲鱼详情页滚动过程中会发送滚动事件广播,能看到其中一处滚动处理逻辑花费了 1.933ms 的时间。根据业务场景,我们优化掉无效的广播发送,以及部分组件对广播的无效消费。
flutter_trace_canary 是闲鱼自建的一个 Flutter 卡顿检测和堆栈收集的工具。首先官方 DevTools 工具已经能帮助分析性能问题,能在统计层面分析方法耗时,那闲鱼自建的原因有以下几点:
- 线下协助发现方法累计耗时不高(devtools上难以发现),但存在抖动的场景。
- 线下自动化测试场景,发现和自动收集滑动过程中的卡顿信息。
- 线上慢函数检测和堆栈收集。
flutter_trace_canary 的思想原理见上图,在 C 层采集线程固定时间间隔发送信号,在信号接收时采集 dart ui 线程调用堆栈,如果出现连续多次的调用堆栈,就可以认为发生了卡顿,此时的堆栈正是卡顿堆栈。
在实际优化过程中,我们设置每 1 ms 发送信号,通过 flutter_trace_canary 检测到 Flutter 高可用 SDK 在每帧调用 FrameFpsRecorder.getScrollOffset 方法耗时严重,如上图方法长度占用多个单位长度(1 个单位长度表示 1ms)。查看高可用 SDK 实现,发现 SDK 会在每一处帧回调通过递归遍历查找滚动视图,并计算滚动距离,以此判断是否处于滚动状态,只有在滚动状态才会开启 FPS 统计。定位到问题,高可用SDK增加了缓存逻辑,避免了无效的查找消耗。
优化方向和手段
在流畅度方面,基于官方工具和闲鱼自建工具,我们发现了很多业务代码实现原因导致的性能问题,后面我们要如何优化流畅度呢?我们将优化方向分为以下几点:
-
任务优化方向
-
通过移除无用计算、优化算法等减少消耗
-
包括上面讲述的,通过工具发现定位问题,并修复问题的 case
-
用户响应优先方向
-
若一帧时间内不能完成更新显示,将逻辑拆分到多帧中,优先响应用户,让用户看到界面反馈
-
画面跳变优化
Flutter 列表控件,如 SliverList、SliverGrid,可分为可视区域和 cache extend 区域,而滚动过程中,视图 Element 移出区域后,会被销毁掉,并没有和 RecyclerView UITableView 类似的复用机制。对此,我们构建一个二级映射关系:index → key 和 key → elements。在 destroyChild 时根据 index 找到 key,再把原本即将销毁的 element 放入对应的 key 对应的 elements 中。在createChild 需要创建 element 时,从这个二级映射中找出可用的 element 进行复用。
在闲鱼业务场景中,我们希望用户能流畅的浏览内容,为此在列表滚动过程中,就会触发 loadmore,而 loadmore 会触发 Flutter 列表控件刷新,原生实现会将 widgets 全部废弃,重新创建。
这也是最开始在 Flutter 行业挑战中说的,widget 到 renderObject 的 diff 更新存在开销的一个典型例子,因为 widgets 被废弃重新创建,最后更新过程中全部走了 update 或 inflateWidget 逻辑,由于 widget 设计的组合嵌套方式,会导致递归遍历成本并不低。
闲鱼研发了自己的 PowerScrollView,在 loadmore 时,我们保留了已有的 widgets,保证已有老 widget diff 更新 renderObject 时,只需花费 updateSlotForChild 的调用开销。
题外话:虽然官方设计三棵树时,认为 widget 是轻量的,只是简单的配置信息,并不直接涉及布局渲染相关,所以可以频繁重复创建。但在极致的性能优化场景,尤其是在流畅度场景,需要保证 16.6ms 完成全部计算,我们并不能忽略这部分开销
经过上面的优化,我们发现在 Android 高端机上,闲鱼猜你喜欢视图依旧存在卡顿,原因是 Widget 自身需要显示的内容较为复杂,再加上为了动态能力加入的 DX 组件,让 Widget 复杂度再上了一个台阶。过于复杂的 Widget 让一帧时间内难以完成渲染,为此我们可以考虑将 widget 拆解掉,一帧时间内仅完成部分渲染,让列表优先执行滚动,完成用户响应。
我们将 Widget 先拆解为 1 个骨架 Widget 和 2 个卡片 Widget,然后再把卡片 Widget 中的 FXImage 拆解出来。最后将骨架 Widget 设置为当前帧立即响应;2 个卡片 Widget 放入一个高优任务队列中,保证以后每帧渲染中,是独占一帧的时间;剩余的小 Widget 构建显示放入到低优任务队列中,每次取任务可以取多个,可以根据机型的性能不同设置不同的值。
利用延迟分帧 build 方案,将一个超大的 widget 拆分到 4 帧中渲染显示,1 次最高耗时从原本的 1819ms 降低到 89ms,有了时间余量,在非高端机上也能有一个较好的表现。
经过上面的优化,流畅度得到了很大的提升,高端机上的数值上已经和原生 Native 页面表现接近,但我们在体感上还是更明显的感受到 Flutter 页面的卡顿。我们故意在 item 创建的时候制造小卡顿,在绘制 offset 和时间曲线,可以发现 RecyclerView 在小卡顿场景下,offset 并没有发生跳变,而 Flutter 页面 offset 曲线会发生跳变。由此可以理解,因为 Flutter 列表在时间上停顿后,画面上还会跳变,加剧了我们的卡顿体感。
查看 ClampingScrollSimulation 源码,可以发现 Flutter 是基于一条 d/t 曲线公式计算偏移值,在 △t 发生翻倍时,offset(△d)也就会发生翻倍,画面产生跳变。在小卡顿场景下,我们修改原本的 d/t 曲线为 v/t 曲线,通过累加的方式计算 y 值。
经过优化后,在故意制造小卡顿的情况下,offset 时间曲线就变得比较光顺,在列表发生小卡顿时,体感能弱化不少,高分辨率机型上更为明显。
优化结果
经过上述优化,闲鱼详情页和搜索结果页的流畅度得到了明显的提升,绿色表示优化曲线,曲线越靠右表示 FPS 表现越好。其中使用自建流畅度检测工具,线下测试详情页,FPS 提升了 3 个点,大卡顿次数,在低端机上减少一半,高端机接近 0。
内存优化
问题发现和定位
内存优化的指标,就是内存泄漏和内存峰值,这里不再赘述。在内存泄漏方面,我们使用自定义 DevTools 工具基于 layer 做内存探查,使用 Observatory 定位泄漏引用路径。
Widget 更新 RenderObject,再到 RenderObject,然后会构建 Layer 树,在 C++ 侧也会生成对应的 Layer 数,最后提交 skia 渲染。Dart 对象通过垃圾回收机制回收对象,C++ 侧通过引用计数方式回收对象,这里 C++ 侧使用 WeakPersistenerHandle 持有指针,当对应的 dart 对象被回收时,C++ 侧引用计数减一,引用计数为 0 时触发回收,所以理论上Layer Dart 对象和 C++ 对象存在生命关联关系,当 C++ layer 未被回收,反过来表明 Dart 对象存在泄漏。 对照 Android LeakCanary,startActivity 后通过弱引用 Activity,在 Activity 退出后,检查 ReferenceQueue 队列来检查是否发生 Activity 泄漏。同样的,我们在 Flutter Navigator push 时记录 C++ 侧 Layer 的数量(内存中的对象数量和正在使用的数量),在页面退出时,主动通知 GC,之后几秒后再次记录 Layer 数量,对比 Layer 的数量变化来判断是否发生泄漏。
图中蓝色曲线表示使用中的 Layer 数量,黄色曲线表示内存中的 Layer 数量,最后一段,在进入页面前,Layer 数量一致,退出页面后,内存中的数量大于正在使用的 Layer 数量,表示发生了内存泄漏。
知道了有内存泄漏,那如何定位泄漏问题?我们可以用一种取巧的方法,在场景中放入一个自定义 Widget,然后在 Observatory 中直接搜索自定义 Widget 名,快速得到泄漏对象的引用路径。
优化方向和手段
发现了内存泄漏,我们就要修复泄漏,这也是内存优化的一个重要方向。除此之外,闲鱼团队使用图片外接纹理和内存内存复用来优化内存峰值。
Flutter 原生 Image Widget 会对应生成 RenderImage 对象,其持有 Image 对象,最后关联持有了 C++ 侧 SkImage 对象。Dart 内存和 External 内存的释放均受 Dart VM GC 控制,而 GC 的触发时机存在滞后性,其中首受 VM 统计的内存水位影响,查看 engine 源码,可以发现统计内存值 getAllocationSize 的部分实现是近似值,甚至在 EngineLayer 中的实现是写死了 3000。
所以在 Flutter 列表快速滑动时,由于 GC 执行滞后的原因,Dart 对象和 External 内存都不会被及时释放,产生了明显的内存峰值。
闲鱼团队使用外接纹理的方案,在原生 Native 层得到图片数据后得到纹理 Id(TextureId),使用 Texture Widget 用来显示图片纹理。这样带来的一个好处是,当 IFImage 被 dispose 时,我们可以主动销毁纹理内存,而不依赖 GC。在列表快速滑动时,生成的内存峰值就不再包含占用内存大头的 SkImage 部分。
使用外接纹理后,我们的图片显示流程如上所示。如果出现多个 FXImage 有相同的 url,方案中就会生成多个 TextureId,占用多份纹理内存。
为了复用纹理内存,我们对纹理 id 增加引用计数的机制,传入 url 时若 ImageCache 中没有纹理 id 缓存,则走左边流程生成纹理 id,将引用计数标记为 1;若 ImageCache 中已有纹理 id 缓存,则引用计数 +1,直接返回缓存纹理 id。在 Texture Widget 销毁的时候,将引用计数 -1,当引用计数为 0 的时候,才将纹理内存销毁。
优化结果
不包括外接纹理优化,闲鱼页面的内存优化如上图所示,发布详情编辑页面的一处泄漏被修复,减少了 10~30MB 内存。
未来和展望
以上就是本次闲鱼在流畅度和内存优化方面实践,最后也取得了很好的性能提升。但在未来,仍然有需要完善的地方。 首先,我们的使用的延迟分帧build方案,需要业务研发根据业务场景进行手动拆分,我们期望能实现一套类似 React Fiber 框架类似的自动解析分帧的能力,根据机器的性能做字使用的自动拆分。
其次,我们实现了基于堆栈聚合的 Flutter 卡顿检测工具,后续会将这个能力应用到线上,构建一套线上卡顿监控体系。 然后,闲鱼实现了一套基于 Layer 的内存泄漏发现工具,后续会继续构建内存泄漏的引用链路,C++ Layer → Dart Layer → RenderObject → Element → BuildContext。
最后,我们会基于自建 DevTools 可视化 engine 层内存使用情况,让业务研发能更多的注意到 External Memory 的使用情况。
以上就是本次分享内容,感谢大家的聆听。
关注公众号请搜索 U4内核技术,即时获取最新的技术动态