追本溯源 —— SetState 刷新做了什么

在 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 去"要一帧"。

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 包括:

  • TransformLayer
  • ClipRectLayer
  • OpacityLayer
  • PictureLayer

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
  • 在这一帧中,Flutter 依次执行:

    • Build → Layout → Paint → Composite → Raster
    • 最终把状态变化变成屏幕上的像素。

理解这一条链路之后,再看:

  • DevTools 里的 Timeline
  • "为什么某个页面 Build 很重"
  • "为什么第一次动画会掉帧"

基于这些去做卡顿优化方案,将会有的放矢

相关推荐
Heo1 小时前
先把 Rollup 搞明白,再去学 Vite!
前端·javascript·面试
前端一课2 小时前
第 32 题:Vue3 Template 编译原理(Template → AST → Transform → Codegen → Render 函数)
前端·面试
前端一课2 小时前
第 33 题:Vue3 v-model 原理(语法糖 → props + emit → modelValue → update:modelValue)
前端·面试
前端一课2 小时前
第 25 题:说一下 Vue3 的 keep-alive 原理?缓存是怎么做的?
前端·面试
Yeats_Liao2 小时前
CANN Samples(九):内存管理与性能优化
人工智能·深度学习·性能优化
前端一课2 小时前
第 30 题:Vue3 自定义渲染器(Custom Renderer)原理- 为什么 Vue 能渲染到 DOM / Canvas / WebGL / 三方平台
前端·面试
前端一课2 小时前
【vue高频面试题】第 23 题:Vue3 自定义指令(directive)完整解析
前端·面试
前端一课2 小时前
第 28 题:Vue3 的 Diff 算法核心原理(双端 Diff、PatchFlags、Block Tree、静态提升)
前端·面试
前端一课2 小时前
【vue高频面试题】第 21 题:Vue3 中的 Slot(插槽)— 基础、原理、使用场景、面试必问点
前端·面试