一、从一段聊天页面的卡顿说起
在企业 IM 项目的聊天页面中,我们曾遇到一个奇怪的问题:收到新消息后调用 setState,列表滑动会出现明显的掉帧。按理说只有几条新消息,不应该卡成这样。排查后发现问题不在「数据量」,而在「重建路径」——某段代码在 build 中做了高成本计算,每帧都在重复执行。
这个案例让我意识到:看得懂 setState,只是会用 Flutter;看得懂 setState 到屏幕像素之间的完整链路,才是真正掌握了 Flutter。
本文将串联从 setState 调用到屏幕刷新的每一环,结合企业 IM 中的真实案例,把渲染管线中的关键机制和优化点讲清楚。
二、整体流程图
在深入细节之前,先建立一个全局视角。Flutter 的渲染管线是一条流水线,每一阶段有明确的输入与输出:
1 | setState / 数据变更 |
这六个阶段并不是每次都全部执行。Flutter 的设计哲学是「能跳过的就跳过」,每个阶段都有独立的脏标记检测,只有真正需要更新的部分才会重走对应流程。
下面逐个阶段拆解。
三、Build 阶段:从 setState 到 Widget 重建
3.1 setState 到底做了什么
setState 的源码非常精简:
1 | void setState(VoidCallback fn) { |
它只做了两件事:执行你传入的回调,然后调用 _element.markNeedsBuild() 将当前 Element 标记为「脏」。
markNeedsBuild 的逻辑是:把当前 Element 加入全局的脏列表,然后向 SchedulerBinding 注册一个帧回调。等下一个 Vsync 信号到来时,Flutter 统一处理所有脏 Element。
这个设计有两个关键收益:
- 批量合并更新:同一帧内的多次
setState会被合并,只触发一次build,避免重复计算。 - 异步调度:
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 | // 避免在 build 中创建非必要的可变 Widget |
const Widget 在编译期就是常量,Flutter 在 diff 阶段会直接判定它无需更新。
层次二:提取局部刷新
把频繁变化的 Widget 提取为独立的 StatefulWidget,让 setState 只影响子树的边界。
在表情面板场景中,emoji 的选中状态是高频变化的。如果不做拆分,选一个表情就会触发整个聊天页面 rebuild。拆分后,选中态只影响表情面板内部的子 Widget。
层次三:使用 RepaintBoundary
RepaintBoundary 能阻断 paint 阶段的向上传递(下一节会详细展开),同时也能减少不必要的 build 传播范围。
四、Layout 阶段:约束自顶向下,尺寸自底向上
4.1 单次传递的高效布局算法
Flutter 的布局是单次传递(one-pass layout),比 CSS 的多次回流(reflow)高效得多:
- 父节点向子节点传递约束(Constraints Go Down):比如「你最多宽 375px,高不限」。
- 子节点在约束内确定自身尺寸(Sizes Go Up):比如「我用 375x40」。
- 父节点根据子节点尺寸确定自身布局(Parent Sets Position)。
代码层面,核心是这三个方法:
1 | abstract class RenderObject { |
4.2 脏标记与 Relayout Boundary
与 Build 阶段类似,Layout 也有脏标记机制。当某个 RenderObject 的布局参数变化时,调用 markNeedsLayout() 将自己标记为脏,并沿树向上传播到最近的 Relayout Boundary。
Relayout Boundary 满足一个条件:父节点不依赖子节点的尺寸(即 parentUsesSize 为 false)。它就像一个防火墙,把脏标记的传播范围限制在内部,外面的节点不受影响。
这个机制的意义在于:聊天列表新增一条消息时,不需要让整个页面的所有元素都重走 layout。 脏标记只会在消息列表这一层内部传播,顶部导航栏、底部输入框的 RenderObject 完全不受影响。
4.3 业务案例:输入框高度自适应的 Layout 优化
IM 聊天中的富文本输入框,高度会随内容从 40pt 增长到 120pt。每次高度变化都会触发 markNeedsLayout(),进而触发消息列表的重新布局。
优化手段是给输入框包一层 SizeChangedLayoutNotifier,在回调中仅更新列表的 padding,而不是让整个页面重排:
1 | SizeChangedLayoutNotifier( |
这样 Layout 阶段的重排范围只到 SizeChangedLayoutNotifier 的边界为止。
五、Paint 阶段:生成 Layer Tree
5.1 绘制过程
Layout 完成后,进入 Paint 阶段。Flutter 的绘制同样采用脏标记 + 边界阻断的策略:
RenderObject.markNeedsPaint()标记当前节点为「需要重绘」。- 脏标记沿树向上传播,直到遇到 RepaintBoundary(
isRepaintBoundary为true的节点)。 - 从 RepaintBoundary 向下,所有脏节点在同一个
PictureLayer中完成绘制。
RepaintBoundary 同时也是一个「绘制缓存点」:它的绘制结果会被缓存在 GPU 纹理中。后续帧中,只要 RepaintBoundary 内部的节点没有重绘需求,Flutter 就直接复用缓存的纹理,跳过 Paint 流程。
5.2 业务案例:聊天页面的 RepaintBoundary 布局
在企业 IM 的聊天页面中,RepaintBoundary 的布局策略如下:
1 | ChatPage(整体背景,一般不需要 RepaintBoundary) |
关键收益:
- TitleBar 是静态内容,缓存后几乎不触发重绘,即使列表疯狂滚动。
- 每条 MessageBubble 独立缓存:图片加载完成、链接高亮变化只会导致单条消息重绘,不会触发整屏的 Paint。
- InputBar 隔离:键盘弹起、输入文字等高频变化,只在输入框区域内处理。
但 RepaintBoundary 不是越多越好。 每条消息都是一个独立的 GPU 纹理,如果同时有 200 条消息可见,200 个纹理同时存在会显著增加 GPU 内存。对于纯文本消息,共享重绘反而比独立缓存更高效。我们的实际策略是:只用 RepaintBoundary 包裹包含图片 / 视频 / 文件下载进度的「富媒体消息」,纯文本消息不加隔离。
六、Composite 与 Rasterize:提交到屏幕
6.1 Composite(合成)
Paint 阶段产出的是一棵 Layer Tree。Composite 阶段由 FlutterEngine 的 SceneBuilder 将 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 层的负担。最终的性能调优,是在理解每层代价的基础上做出的权衡。
扫描二维码,分享此文章