在 Flutter 中调用 setState 是一个常用方法, 但当 UI 没有立即刷新、动画掉帧、复杂布局卡顿时,你会发现:
只是知道 setState 根本无法解释 Flutter 为什么会慢、卡顿
要真正理解 Flutter 的 UI 刷新机制,就必须理解 Frame Pipeline。
调用 setState 后发生了什么
setState(() {
_counter++;
});
最开始接触 Flutter 时候对这段代码的理解就是:
改了 _counter,然后 Flutter 立刻重新渲染,把新值画到屏幕上
是这样么?
实际上完全不是
调用 setState() 的那一刻,屏幕并不会立刻刷新,甚至连「重新 build」也还没开始。
Element 标记
首先要记住一个非常重要的事实:
- Widget 是配置
- Element 才是“活着的实例”
当你在 State 里调用 setState() 时,Flutter 做的第一件事是:
把当前关联的 Element 标记为 dirty(脏),下次刷新需要重建 。
添加处理队列
标记完 dirty 之后,Flutter 并不会当场就处理,而是把它交给一个管理者:BuildOwner。
简单理解:
BuildOwner维护着一个 待重建的 Element 列表(dirty 列表)- 每次有地方
setState(),对应的 Element 就会被加到这个列表里
目的:
-
合并多个 setState
一帧里你可能连续多次setState(),Flutter 不会每次都重新跑一套流程,而是把所有要更新的 Element 集中放在 dirty 列表里,一次性统一处理。 -
避免在当前调用栈里频繁重建
如果 setState 直接触发重建,我们在事件回调里、异步回调里频繁setState(),UI 就可能在一个调用栈里反复 build,开销非常大,也难以维护。
迄今为止,做了这些动作: 状态更改 ——> 标记 Element ——> 放置重建队列
markNeedsBuild
在完成队列添加后,并不会立即重建,将申请下一帧重建
-
setState →
markNeedsBuild() -
markNeedsBuild()→ 通知 Scheduler:“有事情要画,记得安排一帧。” -
Scheduler → 调用
scheduleFrame() -
scheduleFrame()→ 等待下一次屏幕 Vsync 信号,再正式开始一帧
总结 setState 不是一个“立刻重绘”的按钮,而是一个“预约下一帧重建”的开关
Flutter 的一帧如何开始?—— Frame Scheduling
Flutter 不是一直在死循环刷新屏幕,它是 被动渲染 的框架,只有在必要时才会产生一帧;在 setSate 完成申请一帧 后将进入 Frame Scheduling(帧调度机制)
Flutter 一帧的产生依赖 3 个关键角色:
- 系统(Vsync):告诉 Flutter 什么时候可以刷新
- 引擎(onBeginFrame):接收 Vsync、开始一帧
- Framework(SchedulerBinding):安排 Frame、执行 Pipeline
Vsync 信号
首先要清楚帧率这一概念,在维基百科的解释是
帧率:用于测量显示帧数的度量。测量单位为“每秒显示帧数”(frame per second,FPS)或“赫兹”,一般来说FPS用于描述影片、电子绘图或游戏每秒播放多少帧。
所有电子显示屏都是按固定频率刷新的。
- 普通手机是 60Hz → 每秒 60 次刷新
- 高刷手机可能是 90Hz / 120Hz / 144Hz
系统会在每次屏幕准备刷新前,发出一个同步信号:Vsync(Vertical Synchronization) 。
Flutter 引擎就是根据这个信号来决定:
“现在是时候开始准备下一帧画面了。”
📌 Flutter 的每一帧,都是由系统的 Vsync 驱动的。
刷新触发
刷新的触发点是引擎层的 onBeginFrame() 回调
Frame 的开始,本质上就是:
Vsync 到来 → Engine 调用 onBeginFrame() → Framework 开始处理本帧逻辑
这一刻,Framework 才有机会进入下一步:
Build、Layout、Paint…… 全部都在这条 Frame 流程中进行。
申请机制从入口上几乎都绕不过核心方法 SchedulerBinding.ensureVisualUpdate()
它的逻辑非常简单:
- 如果当前没有正在等待的 Frame
- 就请求下一次 Vsync
- 让 Flutter 有机会开始一帧
也就是说:
setState 的最终动作之一,就是触发 ensureVisualUpdate 去“要一帧”。
flowchart TD
A["setState"] --> B["markNeedsBuild(标记 dirty)"]
B --> C["ensureVisualUpdate(请求下一帧)"]
C --> D["scheduleFrame(等待 Vsync)"]
D --> E["等待系统发来一个 Vsync 信号"]
E --> F["onBeginFrame(本帧开始)"]
F --> G["进入 Frame Pipeline:Build → Layout → Paint → Composite → Raster"]
G --> H["屏幕显示更新"]
Frame Pipeline
Build → Layout → Paint → Composite → Raster
Flutter 如何从一个“需要更新的状态”一路走到“屏幕像素更新”的?
这就是 Flutter Frame Pipeline(渲染流水线) 的工作。
可以把它想象成一条工厂产线,每一帧都是一件产品(画面),而 Flutter 需要在 16.6ms 内完成整个生产流程:
Build → Layout → Paint → Composite → Raster
Build 构建 UI 结构
在 setState 时,把 Element 标记为 dirty。 Build 阶段会遍历这些 dirty elements,然后调用它们的 build 方法。
Widget 是配置(immutable),Element 才是“活的实例”
在 Build 阶段,每次 build 会创建新的 Widget 对象,但不会创建新的 Element:
- Widget 是轻量的配置对象
- Element 保存生命周期、State、以及和 RenderObject 的关联
- 新 Widget 会通过 Element 与之前的树结构匹配(diff)
更新 RenderObject(布局 & 绘制对象)
如果 Widget 配置变化引起渲染需求变化,Flutter 会:
- 更新 RenderObject 的属性
- 或在需要时重建 RenderObject
RenderObject 是真正参与 Layout 和 Paint 的核心对象。
📌 Build 的最终产物是一个准备好布局的 RenderObject 树。
Layout 测量和定位
父给子 constraints,子返回尺寸
Flutter 的布局是从父到子递归传递的:
- 父 RenderObject 给子节点一个 constraints(最大/最小宽高等)
- 子节点必须在这个 constraints 内决定自己的尺寸
例如:
- Column 会告诉子组件 “你宽度必须等于 Column 的宽度,至于高度你自己看着办”
- Text 会根据文本内容和 constraints 决定最终尺寸
- Container 根据外部 constraints 和内部 child 的尺寸决定自身大小
这一机制保证了:
- 布局行为完全可预测
- 布局是自上而下的数据流而非混乱的二维布局
📌 经过 Layout,整个 RenderObject 树已经“测量完成”,知道每个对象该画在屏幕哪里。
Paint 绘制指令
Paint 阶段不是立即把像素画上屏幕,而是:
把所有绘制操作记录成一条条绘制指令,写入 Picture 或 Layer。
Flutter 会在每个 RenderObject 的 paint 方法中:
- 绘制背景、文字、边框、阴影等
- 调用 child 的 painting 方法递归绘制
- 记录所有绘制命令到
PaintingContext
📌 Paint 阶段的核心产物是 Layer Tree 的“绘制脚本”。
Composite 生成 Layer Tree
将绘制指令组合成可优化的图层树
Paint 阶段记录的是绘制指令,但 Flutter 还需要把这些指令整理成一个结构良好的 Layer Tree。
Layer 的作用包括:
- 表示一块可以单独处理的区域(如裁剪、透明度、变换等)
- 帮助 Flutter 做缓存和局部重绘(只更新变化的部分)
- 为滚动、动画等提供更高效的渲染基础
常见的 Layer 包括:
TransformLayerClipRectLayerOpacityLayerPictureLayer等
Layer Tree 会被发送给引擎,作为后续 Raster 的输入。
📌 Composite 的产物是:一棵描述整屏内容的 Layer Tree。
Raster 最终绘制
Skia + GPU → 屏幕像素; 唯一真正“画图”的阶段
Pipeline 的最后一个阶段,但它已经脱离 Dart 层,完全交由引擎和 GPU 来执行。
Rasterizer 的主要流程是:
- 接收 Layer Tree
- 使用 Skia 把每个 Layer 转成 GPU 可以理解的图元
- 上传纹理、编译或复用 Shader
- 最终写入 Framebuffer
- 交给系统合成,显示到屏幕上
flowchart TD
D3["Layer Tree"] --> E2["Engine Shell 接收 LayerTree"]
E2 --> R1["遍历 LayerTree,准备绘制列表"]
R1 --> R2["图片解码与纹理上传"]
R2 --> R3["Shader 编译或复用"]
R3 --> R4["Skia 光栅化写入 Framebuffer"]
R4 --> G1["系统合成:Surface / CoreAnimation / SurfaceFlinger"]
G1 --> G2["最终显示到屏幕"]
%% COLORS
style D3 fill:#e1bee7,stroke:#6a1b9a
style E2 fill:#e1bee7,stroke:#6a1b9a
style R1 fill:#f8bbd0,stroke:#ad1457
style R2 fill:#f8bbd0,stroke:#ad1457
style R3 fill:#f8bbd0,stroke:#ad1457
style R4 fill:#f48fb1,stroke:#ad1457
style G1 fill:#c8e6c9,stroke:#2e7d32
style G2 fill:#81c784,stroke:#2e7d32
- Raster 阶段是 GPU 真正耗时的地方
- Shader 编译也是在 Raster 阶段发生(会导致掉帧)
- Flutter 提供 Shader warm-up (但这是下一篇的内容)
📌 到这里,一帧从 setState → Pipeline → 像素更新全部完成。
setState 到屏幕刷新
📌 setState 的本质不是更新 UI,而是触发下一帧的 Build。
它只是:
- 改状态
- 标记 dirty
- 申请一帧
真正的 UI 更新发生在下一帧的 Frame Pipeline 中。
flowchart TD
U["用户输入:点击 / 滚动 / 手势"] --> E1["PointerEvent 分发,GestureArena 解析"]
VSYNC["Vsync 信号 (屏幕刷新节奏)"] --> F0["SchedulerBinding.beginFrame"]
E1 --> F1["事件回调:onTap / onScroll / onChanged,触发状态更新"]
F0 --> A1["Animation / Ticker 更新,滚动惯性计算"]
A1 --> A2["标记脏:markNeedsBuild / markNeedsLayout / markNeedsPaint"]
F1 --> A2
A2 --> B1["build 阶段:遍历 dirty Elements"]
B1 --> B2["执行 Widget.build,更新 Widget / Element 树"]
B2 --> B3["更新 RenderObject 属性"]
B3 --> C1["layout 阶段:flushLayout"]
C1 --> C2["performLayout 计算大小位置"]
C2 --> D1["paint 阶段:flushPaint"]
D1 --> D2["RenderObject.paint 录制 Canvas 指令"]
D2 --> D3["组装 Layer Tree"]
%% COLORS
style U fill:#fff7cc,stroke:#bfa600
style E1 fill:#fff7cc,stroke:#bfa600
style VSYNC fill:#fff7cc,stroke:#bfa600
style F0 fill:#c8e1ff,stroke:#0366d6
style F1 fill:#c8e1ff,stroke:#0366d6
style A1 fill:#c8e1ff,stroke:#0366d6
style A2 fill:#cdecef,stroke:#0b7a75
style B1 fill:#cdecef,stroke:#0b7a75
style B2 fill:#cdecef,stroke:#0b7a75
style B3 fill:#cdecef,stroke:#0b7a75
style C1 fill:#cdecef,stroke:#0b7a75
style C2 fill:#cdecef,stroke:#0b7a75
style D1 fill:#cdecef,stroke:#0b7a75
style D2 fill:#cdecef,stroke:#0b7a75
style D3 fill:#88d8c0,stroke:#0b7a75
小结
-
一次
setState,不会立刻更新 UI,而是:- 标记 dirty
- 丢进队列
- 申请下一帧
-
下一帧开始于系统的 Vsync:
- 引擎接收 Vsync,调用
onBeginFrame - Framework 通过 SchedulerBinding 执行 Frame Pipeline
- 引擎接收 Vsync,调用
-
在这一帧中,Flutter 依次执行:
- Build → Layout → Paint → Composite → Raster
- 最终把状态变化变成屏幕上的像素。
理解这一条链路之后,再看:
- DevTools 里的 Timeline
- “为什么某个页面 Build 很重”
- “为什么第一次动画会掉帧”
基于这些去做卡顿优化方案,将会有的放矢