查看原文
其他

已开源!Flutter 基于分帧渲染的流畅度优化组件 Keframe

承香墨影 2022-09-09

以下文章来源于进击的Flutter ,作者Nayuta

大家好,这里是承香墨影!

今天给大家推荐一个,Flutter 中利用分帧渲染优化流程度的开源库,刚开源,还热乎着。这次开源可真波折,看着 @Nayuta 前前后后在公司内部流程走了一个多月吧,太艰难了。

Keframe 原理是基于分帧渲染,针对页面切换以及复杂列表的滚动,提升效果非常明显。

在 Flutter 中,Widget/Element/Render 三棵树的概念非常重要,而分帧渲染的原理,其实就是在 Tree 上分层,将一些复杂的节点及其子节点,用一些空 Widget 占位,而原本应该被渲染的节点,放在下一帧去渲染,从而避免出现太复杂的 UI,使得一帧的渲染超过 16.6ms,导致卡顿。

Keframe 的代码量不大,但实现原理值得研究。接下来正式介绍 Keframe,一起看看它的使用以及各项数据的表现。

列表流畅度优化

这是一个通用的流畅度优化方案,通过分帧渲染优化由构建导致的卡顿,例如页面切换或者复杂列表快速滚动的场景。

代码中 example 运行在 VIVO X23(骁龙 660),在相同的滚动操作下优化前后 200 帧采集数据指标对比(录屏在文章最后):

优化前优化后

监控数据来自:fps_monitor

  • 流畅:一帧耗时低于 18ms;
  • 良好:一帧耗时在 18ms-33ms 之间;
  • 轻微卡顿:一帧耗时在 33ms-67ms 之间;
  • 卡顿:一帧耗时大于 66.7ms;

fps_monitor: https://github.com/Nayuta403/fps_monitor

采用分帧优化后,卡顿次数从 平均 33.3 帧出现了一帧,降低到 200 帧中仅出现了一帧,峰值也从 188ms 降低到 90ms。卡顿现象大幅减轻,流畅帧占比显著提升,整体表现更流畅。

下方是详细数据。

页面切换流畅度提升

在打开一个页面或者 Tab 切换时,系统会渲染整个页面并结合动画完成页面切换。对于复杂页面,同样会出现卡顿掉帧。

借助分帧组件,将页面的构建逐帧拆解,通过 DevTools 中的性能工具查看。切换过程的峰值由 112.5ms 降低到 30.2 ms,整体切换过程更加流畅。

如何使用?

项目依赖:

pubspec.yaml 中添加 keframe 依赖

dependencies: 
  keframe: version

组件仅区分非空安全与空安全版本。

  • 非空安全使用:1.0.1
  • 空安全版本使用:2.0.0

github 地址:https://github.com/LianjiaTech/keframepub

Package:https://pub.dev/packages/keframe

快速上手:

如下图所示

假如现在页面由 A、B、C、D 四部分组成,每部分耗时 10ms,在页面时构建为 40ms。使用分帧组件 FrameSeparateWidget 嵌套每一个部分。页面构建时会在第一帧渲染简单的占位,在后续四帧内分别渲染 A、B、C、D。

对于列表,在每一个 item 中嵌套 FrameSeparateWidget,并将 ListView 嵌套在 SizeCacheWidget 内即可。

构造函数说明

FrameSeparateWidget :分帧组件,将嵌套的 widget 单独一帧渲染。

SizeCacheWidget:缓存子节点中,分帧组件嵌套的实际 widget 的尺寸信息。

Example 示例说明:

卡顿的页面往往都是由多个复杂 widget 同时渲染导致。通过为复杂的 widget 嵌套分帧组件 FrameSeparateWidget。渲染时,分帧组件会在第一帧同时渲染多个 palceHolder,之后连续的多帧内依次渲染复杂子项,以此提升页面流畅度。

例如 example 中的优化前示例:

ListView.builder(
  itemCount: childCount,
  itemBuilder: (c, i) => CellWidget(
    color: i % 2 == 0 ? Colors.red : Colors.blue,
    index: i,
  ),
)

其中 CellWidget 高度为 60,内部嵌套了三个 TextField 的组件(整体构建耗时在 9ms 左右)。

优化仅需为每一个 item 嵌套分帧组件,并为其设置 placeHolderplaceHolder 尽量简单,样式与实际 item 接近即可)。

在列表情况下,给 ListView 嵌套 SizeCacheWidget,同时建议将预加载范围 cacheExtent 设置大一点,例如 500(该属性默认为 250),提升慢速滑动时候的体验。

例如:

SizeCacheWidget(
  child: ListView.builder(
    cacheExtent: 500,
    itemCount: childCount,
    itemBuilder: (c, i) => FrameSeparateWidget(
      index: i,
      placeHolder: Container(
        color: i % 2 == 0 ? Colors.red : Colors.blue,
        height: 60,
      ),
      child: CellWidget(
        color: i % 2 == 0 ? Colors.red : Colors.blue,
        index: i,
      ),
    ),
  ),
),

下面是几种场景说明:

1、列表中实际 item 尺寸已知的情况

实际 item 高度已知的情况下(每个 item 高度为 60),将占位设置与实际 item 高度一致即可,查看 example 中分帧优化1。

FrameSeparateWidget(
  index: i,
  placeHolder: Container(
    color: i % 2 == 0 ? Colors.red : Colors.blue,
    height: 60,// 与实际 item 高度保持一致
  ),
  child: CellWidget(
    color: i % 2 == 0 ? Colors.red : Colors.blue,
    index: i,
  ),
)

2、列表中实际 item 高度未知的情况

现实场景中,列表往往是根据数据下发展示,无法一开始预知 item 的尺寸。

例如,example 中 分帧优化 2, placeHolder高度40)与实际 item (高度60)尺寸不一致, 由于每一个 item 分在不同帧完成渲染,因此会出现列表「抖动」的情况。

这时可以给 placeholder 设置一个近似的高度。并且在将 ListView 嵌套在 SizeCacheWidget 中。对于已渲染过的 widget 会强制设置 palceHolder 的尺寸,同时将 cacheExtent调大。这样在来回滑动过程中,已经渲染过的 item 将不会出现跳动情况。

例如,example 中「分帧优化 3」。

SizeCacheWidget(
  child: ListView.builder(
    cacheExtent: 500,
    itemCount: childCount,
    itemBuilder: (c, i) => FrameSeparateWidget(
      index: i,
      placeHolder: Container(
        color: i % 2 == 0 ? Colors.red : Colors.blue,
        height: 40,
      ),
      child: CellWidget(
        color: i % 2 == 0 ? Colors.red : Colors.blue,
        index: i,
      ),
    ),
  ),
),

实际效果如下:

3、预估一屏 item 的数量

如果能粗略估计一屏能展示的实际 item 的最大数量,例如 10。将 SizeCacheWidget 的 estimateCount 属性设置为 10*2。快速滚动场景构建响应更快,并且内存更稳定。例如,example 中的「分帧优化4」。

SizeCacheWidget(
    estimateCount: 20,
    child: ListView.builder(

此外,也可以给 item 嵌套透明度/位移等动画,优化视觉上的效果。效果如下图:

4、非列表场景

对于非列表场景,一般不存在流畅度问题,不过在初次进入的时候任然可能出现卡顿。同样的,可以将复杂的模块分到不同帧渲染,避免初次进入的卡顿。例如,我们将为优化例子中底部的操作区域嵌套分帧组件:

FrameSeparateWidget(
    child: operateBar(),
    index: -1,
)

分帧的成本

当然分帧方案也非十全十美,在我看来主要有两点成本:

首先,额外的构建开销:整个构建过程的构建消耗由「n * widget消耗 」变成了「n *( widget + 占位)消耗 + 系统调度 n 帧消耗」。

可以看出,额外的开销主要由占位的复杂度决定。如果占位只是简单的 Container,测试后发现整体构建耗时大概提升在 15% 左右。

这种额外开销对于当下的移动设备而言,成本几乎可以不计。

其次,视觉上的变化:如同上面的演示,组件会将 item 分帧渲染,页面在视觉上出现占位变成实际 widget 的过程。

但其实由于列表存在缓存区域(建议将缓存区调大),在高端机或正常滑动情况下用户并无感知。而在中低端设备上快速滑动能感觉到切换的过程,但比严重顿挫要好。

优化前后对比演示

注:gif 帧率只有20。

最后:一点点思考

列表优化篇到此告一段落,在整个开源实践过程中,有两点感触较深:

「点」与「面」的关系

我们在思考技术方案的时候可以由「点」到「面」,从一个较高的维度思考问题本质。而在执行的时候则需要由「面」到「点」的进行逐级拆分,抓住问题的关键节点,并且拟定进度计划,逐步破解。很多时候,这种向上和向下的逻辑思维才是我们的核心竞争力

以不变应万变

对于未知的东西,我们往往会过度的将它想复杂。在一开始分析列表构建原理的时候,我也苦于无从下手,走了很多弯路。但其实对于 Flutter 这套 「UI」 框架而言,核心仍然在于三棵树的构建机制。在这套体系内,抓住不变的东西,无论是生命周期、路由等等问题都可以从里面找到答案。

Keframe 已开源,欢迎点击「阅读原文」跳转至 Github fork star。

-- End --

本文对你有帮助吗?留言、转发、点好看是最大的支持,谢谢!

推荐阅读:

面试官:设计一个基于索引,setAll()时间复杂度为O(1)的数据结构

Android高版本HTTPS抓包解决方案及问题分析!

Kotlin 协程,怎么开始的又是怎么结束的?原理讲解!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存