在 Flutter 中调用 setState 是一个常用方法, 但当 UI 没有立即刷新、动画掉帧、复杂布局卡顿时,你会发现:
只是知道 setState 根本无法解释 Flutter 为什么会慢、卡顿
要真正理解 Flutter 的 UI 刷新机制,就必须理解 Frame Pipeline。
调用 setState 后发生了什么
dart
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 信号
首先要清楚帧率这一概念,在维基百科的解释是
帧率:用于测量显示帧数的度量。测量单位为"每秒显示帧数"(f rame p er s econd,FPS)或"赫兹",一般来说FPS用于描述影片、电子绘图或游戏每秒播放多少帧。
所有电子显示屏都是按固定频率刷新的。
- 普通手机是 60Hz → 每秒 60 次刷新
- 高刷手机可能是 90Hz / 120Hz / 144Hz
系统会在每次屏幕准备刷新前,发出一个同步信号:Vsync(Vertical Synchronization) 。
Flutter 引擎就是根据这个信号来决定:
"现在是时候开始准备下一帧画面了。"
📌 Flutter 的每一帧,都是由系统的 Vsync 驱动的。
刷新触发
刷新的触发点是引擎层的 onBeginFrame() 回调
Frame 的开始,本质上就是:
scss
Vsync 到来 → Engine 调用 onBeginFrame() → Framework 开始处理本帧逻辑
这一刻,Framework 才有机会进入下一步:
Build、Layout、Paint...... 全部都在这条 Frame 流程中进行。
申请机制从入口上几乎都绕不过核心方法 SchedulerBinding.ensureVisualUpdate()
它的逻辑非常简单:
- 如果当前没有正在等待的 Frame
- 就请求下一次 Vsync
- 让 Flutter 有机会开始一帧
也就是说:
setState 的最终动作之一,就是触发 ensureVisualUpdate 去"要一帧"。
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
- 交给系统合成,显示到屏幕上
- Raster 阶段是 GPU 真正耗时的地方
- Shader 编译也是在 Raster 阶段发生(会导致掉帧)
- Flutter 提供 Shader warm-up (但这是下一篇的内容)
📌 到这里,一帧从 setState → Pipeline → 像素更新全部完成。
setState 到屏幕刷新
📌 setState 的本质不是更新 UI,而是触发下一帧的 Build。
它只是:
- 改状态
- 标记 dirty
- 申请一帧
真正的 UI 更新发生在下一帧的 Frame Pipeline 中。
小结
-
一次
setState,不会立刻更新 UI,而是:- 标记 dirty
- 丢进队列
- 申请下一帧
-
下一帧开始于系统的 Vsync:
- 引擎接收 Vsync,调用
onBeginFrame - Framework 通过 SchedulerBinding 执行 Frame Pipeline
- 引擎接收 Vsync,调用
-
在这一帧中,Flutter 依次执行:
- Build → Layout → Paint → Composite → Raster
- 最终把状态变化变成屏幕上的像素。
理解这一条链路之后,再看:
- DevTools 里的 Timeline
- "为什么某个页面 Build 很重"
- "为什么第一次动画会掉帧"
基于这些去做卡顿优化方案,将会有的放矢