cubegao

从 setState 到屏幕刷新:Flutter 渲染流程全解析

2021-10-20

一、从一段聊天页面的卡顿说起

在企业 IM 项目的聊天页面中,我们曾遇到一个奇怪的问题:收到新消息后调用 setState,列表滑动会出现明显的掉帧。按理说只有几条新消息,不应该卡成这样。排查后发现问题不在「数据量」,而在「重建路径」——某段代码在 build 中做了高成本计算,每帧都在重复执行。

这个案例让我意识到:看得懂 setState,只是会用 Flutter;看得懂 setState 到屏幕像素之间的完整链路,才是真正掌握了 Flutter。

本文将串联从 setState 调用到屏幕刷新的每一环,结合企业 IM 中的真实案例,把渲染管线中的关键机制和优化点讲清楚。

二、整体流程图

在深入细节之前,先建立一个全局视角。Flutter 的渲染管线是一条流水线,每一阶段有明确的输入与输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
setState / 数据变更

Build 阶段(生成 Widget 树)

Layout 阶段(自顶向下传约束,自底向上算尺寸)

Paint 阶段(生成 Layer Tree)

Composite 阶段(合成为一帧)

Rasterize 阶段(提交到 GPU)

屏幕显示

这六个阶段并不是每次都全部执行。Flutter 的设计哲学是「能跳过的就跳过」,每个阶段都有独立的脏标记检测,只有真正需要更新的部分才会重走对应流程。

下面逐个阶段拆解。

三、Build 阶段:从 setState 到 Widget 重建

3.1 setState 到底做了什么

setState 的源码非常精简:

1
2
3
4
void setState(VoidCallback fn) {
final dynamic result = fn() as dynamic;
_element.markNeedsBuild();
}

它只做了两件事:执行你传入的回调,然后调用 _element.markNeedsBuild() 将当前 Element 标记为「脏」。

markNeedsBuild 的逻辑是:把当前 Element 加入全局的脏列表,然后向 SchedulerBinding 注册一个帧回调。等下一个 Vsync 信号到来时,Flutter 统一处理所有脏 Element。

这个设计有两个关键收益:

  1. 批量合并更新:同一帧内的多次 setState 会被合并,只触发一次 build,避免重复计算。
  2. 异步调度setState 本身不立即触发 build,而是等待 Vsync 信号,与屏幕刷新率同步。

3.2 build 的执行时机

在 Vsync 信号到来后,SchedulerBinding.drawFrame 被调用,依次执行 buildOwner.buildScope,遍历所有标记为脏的 Element,依次调用它们的 rebuild 方法。

rebuild 内部根据 Element 的类型走不同路径:

  • ComponentElement(对应 StatelessWidget / StatefulWidget):调用 build() 生成新 Widget,然后进入 updateChild 流程。
  • RenderObjectElement(对应 RenderObjectWidget):调用 performRebuild 更新子节点布局。

3.3 业务实践:减少不必要的 rebuild

在企业 IM 中,聊天页面的消息列表可能有几百条消息。如果每次收到新消息都导致整棵树 rebuild,性能必然出问题。

优化手段有三个层次:

层次一:用 const 构造函数

1
2
3
4
5
6
7
8
9
// 避免在 build 中创建非必要的可变 Widget
Widget build(BuildContext context) {
return Column(
children: [
const InputBar(), // const 标记,不会每次重建
MessageList(messages), // 只有 messages 变化时才重建
],
);
}

const Widget 在编译期就是常量,Flutter 在 diff 阶段会直接判定它无需更新。

层次二:提取局部刷新

把频繁变化的 Widget 提取为独立的 StatefulWidget,让 setState 只影响子树的边界。

在表情面板场景中,emoji 的选中状态是高频变化的。如果不做拆分,选一个表情就会触发整个聊天页面 rebuild。拆分后,选中态只影响表情面板内部的子 Widget。

层次三:使用 RepaintBoundary

RepaintBoundary 能阻断 paint 阶段的向上传递(下一节会详细展开),同时也能减少不必要的 build 传播范围。

四、Layout 阶段:约束自顶向下,尺寸自底向上

4.1 单次传递的高效布局算法

Flutter 的布局是单次传递(one-pass layout),比 CSS 的多次回流(reflow)高效得多:

  1. 父节点向子节点传递约束(Constraints Go Down):比如「你最多宽 375px,高不限」。
  2. 子节点在约束内确定自身尺寸(Sizes Go Up):比如「我用 375x40」。
  3. 父节点根据子节点尺寸确定自身布局(Parent Sets Position)。

代码层面,核心是这三个方法:

1
2
3
4
5
abstract class RenderObject {
void layout(Constraints constraints, { bool parentUsesSize = false });
void performLayout(); // 子类实现,执行实际布局计算
void markNeedsLayout(); // 标记需要重新布局
}

4.2 脏标记与 Relayout Boundary

与 Build 阶段类似,Layout 也有脏标记机制。当某个 RenderObject 的布局参数变化时,调用 markNeedsLayout() 将自己标记为脏,并沿树向上传播到最近的 Relayout Boundary

Relayout Boundary 满足一个条件:父节点不依赖子节点的尺寸(即 parentUsesSizefalse)。它就像一个防火墙,把脏标记的传播范围限制在内部,外面的节点不受影响。

这个机制的意义在于:聊天列表新增一条消息时,不需要让整个页面的所有元素都重走 layout。 脏标记只会在消息列表这一层内部传播,顶部导航栏、底部输入框的 RenderObject 完全不受影响。

4.3 业务案例:输入框高度自适应的 Layout 优化

IM 聊天中的富文本输入框,高度会随内容从 40pt 增长到 120pt。每次高度变化都会触发 markNeedsLayout(),进而触发消息列表的重新布局。

优化手段是给输入框包一层 SizeChangedLayoutNotifier,在回调中仅更新列表的 padding,而不是让整个页面重排:

1
2
3
4
5
6
7
8
SizeChangedLayoutNotifier(
child: ExpandableInputBar(),
onSizeChanged: (size) {
setState(() {
_bottomPadding = size.height;
});
},
)

这样 Layout 阶段的重排范围只到 SizeChangedLayoutNotifier 的边界为止。

五、Paint 阶段:生成 Layer Tree

5.1 绘制过程

Layout 完成后,进入 Paint 阶段。Flutter 的绘制同样采用脏标记 + 边界阻断的策略:

  • RenderObject.markNeedsPaint() 标记当前节点为「需要重绘」。
  • 脏标记沿树向上传播,直到遇到 RepaintBoundaryisRepaintBoundarytrue 的节点)。
  • 从 RepaintBoundary 向下,所有脏节点在同一个 PictureLayer 中完成绘制。

RepaintBoundary 同时也是一个「绘制缓存点」:它的绘制结果会被缓存在 GPU 纹理中。后续帧中,只要 RepaintBoundary 内部的节点没有重绘需求,Flutter 就直接复用缓存的纹理,跳过 Paint 流程。

5.2 业务案例:聊天页面的 RepaintBoundary 布局

在企业 IM 的聊天页面中,RepaintBoundary 的布局策略如下:

1
2
3
4
5
6
7
8
9
10
ChatPage(整体背景,一般不需要 RepaintBoundary)
├── TitleBar(RepaintBoundary)
│ └── 头像、标题、未读数
├── MessageList(大量消息)
│ ├── MessageBubble-1(RepaintBoundary,每条消息独立)
│ ├── MessageBubble-2(RepaintBoundary)
│ ├── MessageBubble-3(RepaintBoundary)
│ └── ...
└── InputBar(RepaintBoundary)
└── 输入框、表情按钮、发送按钮

关键收益:

  • TitleBar 是静态内容,缓存后几乎不触发重绘,即使列表疯狂滚动。
  • 每条 MessageBubble 独立缓存:图片加载完成、链接高亮变化只会导致单条消息重绘,不会触发整屏的 Paint。
  • InputBar 隔离:键盘弹起、输入文字等高频变化,只在输入框区域内处理。

但 RepaintBoundary 不是越多越好。 每条消息都是一个独立的 GPU 纹理,如果同时有 200 条消息可见,200 个纹理同时存在会显著增加 GPU 内存。对于纯文本消息,共享重绘反而比独立缓存更高效。我们的实际策略是:只用 RepaintBoundary 包裹包含图片 / 视频 / 文件下载进度的「富媒体消息」,纯文本消息不加隔离。

六、Composite 与 Rasterize:提交到屏幕

6.1 Composite(合成)

Paint 阶段产出的是一棵 Layer Tree。Composite 阶段由 FlutterEngineSceneBuilder 将 Layer Tree 转换为 Scene 对象,交给 Skia(或 Impeller)引擎。

合成的核心是「合并多个图层为一帧」——GPU 纹理、变换矩阵、透明度和裁剪区域在这一步被统一处理。Flutter 的合成是在 UI 线程完成的,所以 Composite 阶段不能做任何耗时操作,否则会卡住帧的提交。

6.2 Rasterize(光栅化)

Rasterize 发生在 GPU 线程,把 Scene 中的矢量指令转换为屏幕像素。这一步耗时取决于场景复杂度——图层数量、路径复杂度、离屏渲染范围等。

在企业 IM 场景中,高斯模糊背景(如聊天背景模糊效果)大范围裁剪(如圆角裁剪头像列表) 是最容易触发离屏渲染的操作,会显著增加 Rasterize 耗时。优化策略是预裁剪图片而非运行时裁剪,以及用纯色背景替代模糊效果。

七、完整链路的时间分布

以聊天页面滚动一帧为例,各阶段在典型中端设备上的耗时大致如下:

阶段 典型耗时 关键影响因素
Build 0.5-2ms Widget 数量、build 中计算量
Layout 1-3ms 树深度、布局复杂度、是否触发边界外重排
Paint 1-5ms 脏区域面积、路径复杂度、图层数量
Composite 0.3-1ms 图层数量、混合模式复杂度
Rasterize 2-6ms 纹理数量、离屏渲染、分辨率

单帧总预算为 16.67ms(60fps)或 11.11ms(90fps)。任一阶段超预算,就会被系统判定为掉帧。

一个常见误区是只盯着 Build 阶段优化,但实际业务中 Paint 和 Rasterize 往往是瓶颈所在。当聊天页面前后台切换、键盘弹起等场景触发重绘时,Paint 开销可能占整帧耗时的 50% 以上。

八、性能定位工具

遇到卡顿时,不要凭直觉猜测,而是用工具定位:

  • Flutter DevTools Performance 面板:逐帧查看各阶段耗时,快速定位瓶颈在哪个阶段。
  • Debug 模式下 debugPrintRebuildDirtyWidgets:打印每次 build 的 Widget 名称,发现哪些 Widget 在「不必要地重建」。
  • debugRepaintRainbowEnabled:打开重绘彩虹模式,RepaintBoundary 被视为单元的边界,颜色变化表示该区域发生了重绘。一眼看出哪些区域是重绘热点。
  • debugProfilePaintsEnabled:在 Timeline 中显示每个绘制命令的耗时。

九、总结

setState 到屏幕像素,Flutter 的一帧经历 Build → Layout → Paint → Composite → Rasterize 五个阶段。真正理解这条管线后,性能优化的思路就会清晰:

  • Build 阶段:缩小脏 Element 范围,用 const、独立 StatefulWidget、InheritedWidget 减少不必要的 rebuild。
  • Layout 阶段:利用 Relayout Boundary 阻断脏标记向上传播,控制重排范围。
  • Paint 阶段:利用 RepaintBoundary 隔离重绘区域,但只在富媒体等高频变化场景使用,避免纹理数量爆炸。
  • Composite 阶段:减少图层数量,简化合成复杂度。
  • Rasterize 阶段:避免离屏渲染,控制纹理数量和分辨率。

每一层优化都不是孤立的——RepaintBoundary 解决了 Paint 层的瓶颈,却可能增加 Rasterize 层的负担。最终的性能调优,是在理解每层代价的基础上做出的权衡。

扫描二维码,分享此文章