Flutter 流畅度优化组件“Keframe”

通过帧分裂渲染,优化页面切换或复杂列表快速滚动等场景下的卡顿问题。

以下是在 VIVO X23 (骁龙 660) 上运行的示例(可以直接下载)。相同操作下,200 帧优化前后的收集数据指标对比(演示在文章末尾)

优化前 优化后
优化前 优化后

监控工具来自:fps_monitor

  • 流畅:FPS 大于 55,表示每帧时间小于 18ms
  • 良好:FPS 在 30-55 之间,即每帧时间 18ms-33ms
  • 轻微卡顿:FPS 在 15-30 之间,即每帧时间 33ms-67ms
  • 卡顿:FPS 小于 15,表示每帧时间大于 66.7ms

使用帧分裂优化后,卡顿帧数从平均 33.3 帧降低到 200 帧中仅出现 1 帧,轻微卡顿从 188ms 降低到 90ms。卡顿现象大幅减少,流畅帧比例显著提高,整体性能更平滑。以下是详细信息。

优化前 优化后
平均出现卡顿的帧数 33.3 200
平均轻微卡顿的帧数 8.6 66.7
耗时最长 188.0ms 90.0ms
平均耗时 27.0ms 19.4ms
流畅帧比例 40% 64.5%

页面切换流畅度提升

打开页面或 Tab 切换时,系统会渲染整个页面并通过动画完成页面切换。对于复杂的页面,也会出现帧卡顿。

通过框架组件,页面构建被分解成一帧一帧的,并通过 DevTools 中的性能工具进行查看。切换期间,单帧的峰值从 **112.5ms 降低到 30.2ms**,整个切换过程更加流畅。

image.png image.png

如何使用

项目依赖:

pubspec.yaml 中添加 keframe 的依赖

dependencies:
  keframe: version

组件区分普通版本和 null-safe 版本

普通版本使用:1.0.2

Null-safe 版本使用:2.0.2

快速学习

如下图所示:

image.png

假设页面现在由 A、B、C、D 四部分组成,每部分耗时 10ms,页面构建共耗时 40ms。

使用 frameseparateWidget 组件嵌套每个部分。页面第一帧渲染一个简单的占位符,后续四帧分别渲染 A、B、C、D。

对于列表,将 FrameSeparateWidget 嵌套在每个 Item 中,并将 ListView 嵌套在 SizeCacheWidget 中。

image.png


构造函数说明

FrameSeparateWidget:一个帧分裂组件,在单帧内渲染嵌套的 widget。

类型 名称 必需 describe
按键 key no
整数 index no 帧组件 ID,在使用 SizeCacheWidget 的场景下会传递,并在 SizeCacheWidget 中保持对应的 Size 信息索引
Widget child 实际需要渲染的 widget
Widget placeHolder no 占位符 widget,尽量设置一个简单的占位符,默认为 Container()

SizeCacheWidget:缓存 **帧组件在子节点中嵌套的实际 widget 的大小** 信息。

类型 名称 必需 describe
按键 key no
Widget child 如果在子节点中包含帧组件,那么 **缓存的是实际 widget 的大小**
整数 estimateCount no 预估屏幕上的子节点数量,可以提高快速滚动的响应速度

示例说明

卡顿页面通常是多个复杂 widget 同时渲染。通过对复杂 widget 嵌套 FrameSeparateWidget。渲染时,框架组件在第一帧同时渲染多个 palceHolder,然后在后续帧中渲染复杂的子项,以提高页面流畅度。

例如

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

cellWidget 的高度为 60,内部嵌套了三个 TextField 组件(整体构建时间约 9ms)。

优化过程只需为每个 Item 嵌套框架组件,并为每个 Item 设置 placeHolder(占位符应尽可能简单,并且看起来像实际的 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 的高度即可。参见示例中的帧优化 1。

FrameSeparateWidget(
                index: i,
                placeHolder: Container(
                  color: i % 2 == 0 ? Colors.red : Colors.blue,
                  height: 60,// Keep the same height as the actual item
                ),
                child: CellWidget(
                  color: i % 2 == 0 ? Colors.red : Colors.blue,
                  index: i,
                ),
              )

2. 列表中实际 Item 的大小未知

在实际开发中,列表通常是根据数据动态渲染的,一开始无法预知 Item 的大小。

例如,在示例帧优化 2 中,placeHolder(高度 40)与实际 Item(高度 60)大小不一致。
由于每个 Item 在不同帧中渲染,列表会发生 抖动

可以为占位符设置一个大概的高度。并将 ListView 嵌套在 SizeCacheWidget 中。

对于已经渲染过的 widget,强制设置 palceHolder 的大小,并增大 cacheExtent。这样,渲染过的 Item 在前后滑动时就不会跳跃。

例如,示例中的帧优化 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,
                  ),
                ),
              ),
            ),

实际效果如下:

Screenrecording_20210611_194905.gif

3. 预估屏幕上的 Item 数量

如果能大致预估屏幕上最多能显示的实际 Item 数量,例如 10 个。则将 SizeCacheWidgeestimateCount 属性设置为 10*2。快速滚动场景下,构建响应更及时,内存也更稳定。

例如,示例中的帧优化 4。

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

此外,还可以为 Item 嵌套透明度/位移等动画,以优化视觉效果。

实际效果如下:

Screenrecording_20210315_133310.gif Screenrecording_20210315_133848.gif

4. 非列表场景

非列表场景通常不会有流畅度问题,但首次进入时仍可能出现卡顿。

同样,可以将复杂模块分解成不同帧进行渲染,以避免首次进入的延迟。

例如,我们在优化示例中将底部操作区域嵌套了框架组件。

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

帧分裂的成本

当然,帧分裂方案并非完美。在我看来,主要有两个成本

  1. 额外的构建成本:整个构建过程的构建成本从“N * Widget 成本”变为“N * (Widget + Placeholder 成本) + 系统调度 N 帧的成本”。可以看出,额外开销主要来自 Placeholder 的复杂性。如果 Placeholder 是简单的 Container,经过测试,整体构建时间可能会增加 10-20%。对于当今的移动设备来说,这种额外的开销可以忽略不计。

  2. 视觉变化:如上文所示,组件会分帧渲染 Item,页面在视觉上会占据空间,直到变为实际的 widget。
    但实际上,由于列表存在于缓存区域(建议增大 cacheExtent),用户在高配置设备或正常滑动的情况下不会感觉到。
    在低端设备上,快速滑动可能会感觉到过渡,但比严重的卡顿要好。


如有任何疑问,请随时与我联系。如果这启发了您,请不要忘记点赞 ✨✨✨✨ 谢谢!

优化前后演示

优化前 优化后
优化前 优化后

GitHub

https://github.com/LianjiaTech/keframe