Flutter 基础体系 · 第 35/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 渲染流水线:Widget、Element、RenderObject、Layer 和帧

Flutter 的界面不是由 Widget 直接绘制到屏幕上的。一个典型的界面会经过以下几层对象和阶段:

flowchart LR
    A[Widget 配置树] --> B[Element 元素树]
    B --> C[RenderObject 渲染对象树]
    C --> D[布局 Layout]
    D --> E[绘制 Paint]
    E --> F[Layer 图层树]
    F --> G[Scene]
    G --> H[平台窗口与 GPU/浏览器合成]

这张图表示的是主要依赖关系,而不是每一帧都完整重建所有对象:

  • Widget 描述“界面应该是什么样”;
  • Element 保存 Widget 在树中的位置、父子关系和生命周期;
  • RenderObject 负责布局、绘制和命中测试;
  • Layer 保存需要提交给引擎的合成结构;
  • 一帧由框架调度,从状态变化开始,经过构建、布局、绘制和合成,最后交给平台显示。

理解这些对象之间的职责边界,是解释 setStateBuildContextGlobalKeyRepaintBoundary、布局溢出和卡顿问题的基础。


一、从状态变化到屏幕:完整路径

先看一个常见的状态变化:

setState(() {
  _count++;
});

它并不意味着 Flutter 立即执行一次完整绘制。更准确的过程如下:

  1. setState 执行传入的回调,修改状态;
  2. 当前 State 对应的 StatefulElement 被标记为需要重新构建;
  3. 框架安排下一帧;
  4. 下一帧开始时,框架重新执行脏 Elementbuild
  5. 新的 Widget 子树与旧 Widget 子树进行匹配;
  6. 能复用的 ElementRenderObject 尽量复用;
  7. 如果配置变化影响布局,则刷新布局;
  8. 如果只影响绘制,则刷新绘制;
  9. RenderObject 将绘制命令写入 Layer;
  10. Layer 树被提交为引擎可处理的 Scene;
  11. 平台和渲染后端完成光栅化、合成并显示。

可以把一次典型帧抽象为:

状态变化
  -> 标记脏对象
  -> 请求帧
  -> beginFrame
  -> build
  -> layout
  -> compositing bits
  -> paint
  -> semantics(必要时)
  -> compositeFrame
  -> rasterize / browser composite

这里有一个重要边界:drawFrame 完成并不等于用户已经看到了像素。Flutter UI 线程负责生成场景,光栅线程或浏览器渲染系统还需要处理这个场景。


二、Widget:不可变的界面配置

2.1 Widget 是配置,不是实际的界面对象

Widget 通常是不可变对象,用来描述某个位置上应当使用什么配置。例如:

const Text(
  'Hello',
  style: TextStyle(fontSize: 20),
)

这个 Text 对象包含文字和样式配置,但它本身不负责:

  • 保存树中的父子位置;
  • 执行布局;
  • 在 Canvas 上绘制;
  • 接收点击;
  • 记录挂载或卸载状态。

这些工作由后续对象完成。

常见 Widget 类型可以按职责分为:

类型 作用
StatelessWidget 根据输入配置直接生成子 Widget
StatefulWidget 为独立的 State 对象提供配置入口
RenderObjectWidget 配置一个 RenderObject
InheritedWidget 向后代传播依赖数据
ProxyWidget 包装或改变子树行为,例如 InheritedWidget 的部分实现

StatelessWidgetStatefulWidgetbuild 方法返回的通常仍然是 Widget,而不是 RenderObject。

2.2 Widget 可以频繁创建

下面的代码每次 build 都可能产生新的 Widget 对象:

class CounterText extends StatelessWidget {
  const CounterText({
    super.key,
    required this.value,
  });

  final int value;

  @override
  Widget build(BuildContext context) {
    return Text('count: $value');
  }
}

这并不意味着底层文本渲染对象每次都被销毁。框架会使用新 Widget 与旧 Widget 进行匹配,并在满足条件时复用已有的 Element 和 RenderObject。

因此,“Widget 重建”不等于“整个界面重新创建”,更不等于“整个屏幕重新光栅化”。

2.3 const 的实际作用

const 可以在编译期构造不可变 Widget:

const Text('固定文字')

它可能减少运行时对象创建,也能表达该子树配置不随当前 build 改变。但 const 不是一个“禁止绘制”的标记,也不是性能优化的唯一手段:

  • 父级重建时,const Widget 仍然可能参与子树匹配;
  • 如果祖先布局变化,底层 RenderObject 仍可能需要重新布局;
  • 如果祖先触发了较大的绘制区域重绘,const 并不能阻止对应像素被重新处理。

const 优化的是配置对象的构造和部分匹配场景,而不是自动创建绘制缓存。


三、Element:连接 Widget 与 RenderObject 的运行时节点

3.1 为什么需要 Element

如果只有 Widget,框架每次执行 build 都只能面对一批全新的配置对象,无法知道哪些运行时状态可以保留。

Element 解决了三个问题:

  1. 保存 Widget 在树中的位置;
  2. 保存与父子 Element 的关系;
  3. 关联状态对象和 RenderObject,并管理生命周期。

可以用下面的关系表示:

Widget:一次构建得到的配置
Element:树中一个稳定的位置和运行时节点
RenderObject:该位置对应的布局、绘制和命中测试实现

并非每个 Widget 都直接对应一个 RenderObject:

MaterialApp
  -> Stateless/Stateful Element
    -> 多个代理 Element
      -> RenderObjectElement
        -> RenderBox

TextPaddingContainer 等 Widget 往往要经过多个中间 Widget,最终才配置某个或多个 RenderObject。

3.2 Widget 匹配条件

父 Element 更新子节点时,框架需要判断旧 Widget 是否可以更新为新 Widget。简化后的匹配条件是:

旧 Widget 与新 Widget 的 runtimeType 相同
并且
旧 Widget.key 与新 Widget.key 相同

如果条件成立,通常会复用旧 Element,并调用其更新逻辑;否则旧 Element 会被移除,新 Widget 会创建新的 Element。

例如:

Column(
  children: [
    Text('A'),
    Text('B'),
  ],
)

如果下一次构建仍然产生两个没有 Key 的 Text,框架通常按位置匹配:

旧第 0 个 Text -> 新第 0 个 Text
旧第 1 个 Text -> 新第 1 个 Text

如果列表发生重排:

Column(
  children: [
    Text('B'),
    Text('A'),
  ],
)

没有 Key 时,框架通常仍然按位置匹配。对无状态文本而言这可能只是更新文字;但对带内部状态的子树而言,状态可能跟着位置而不是跟着业务对象移动。

3.3 Key 改变了匹配身份

Column(
  children: [
    CounterTile(key: const ValueKey('A')),
    CounterTile(key: const ValueKey('B')),
  ],
)

当两个子节点交换位置时,Key 使框架能够识别:

旧位置 0,key=A -> 新位置 1,key=A
旧位置 1,key=B -> 新位置 0,key=B

这里的 Key 不是数据库主键,也不是 RenderObject 的地址。它只参与特定父节点范围内的 Widget 匹配。

常见 Key 的边界:

  • ValueKey:根据值比较,适合稳定业务标识;
  • ObjectKey:根据对象身份比较;
  • UniqueKey:每次创建都不同,通常会强制替换对应子树;
  • GlobalKey:允许在更大范围内访问 Element,并支持有限的子树移动,但维护成本较高。

使用 UniqueKey 解决“状态错位”时,要意识到它也会主动丢弃原有 Element、State 和 RenderObject,不能把它当作无代价修复方案。

3.4 Element 生命周期

不同 Element 类型细节不同,但典型生命周期可抽象为:

创建
  -> mount
  -> rebuild/update
  -> deactivate
  -> activate(如果被重新插入)
  -> unmount

StatefulWidget 而言,常见的 State 生命周期是:

createState
  -> initState
  -> didChangeDependencies
  -> build
  -> didUpdateWidget
  -> build
  -> deactivate
  -> dispose

didUpdateWidget 表示 Widget 配置变了,但对应的 State 仍被复用。例如:

class UserPanel extends StatefulWidget {
  const UserPanel({
    super.key,
    required this.userId,
  });

  final String userId;

  @override
  State<UserPanel> createState() => _UserPanelState();
}

class _UserPanelState extends State<UserPanel> {
  @override
  void didUpdateWidget(covariant UserPanel oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.userId != widget.userId) {
      // 重新订阅或重新加载与 userId 相关的数据。
    }
  }

  @override
  Widget build(BuildContext context) {
    return Text(widget.userId);
  }
}

如果 Widget 类型或 Key 不匹配,旧 State 可能直接被销毁,didUpdateWidget 不会替代重新创建过程。

3.5 BuildContext 实际上是什么

BuildContext 是 Element 对外暴露的接口类型。它不是一个全局上下文,也不是“当前页面对象”。

因此下面这些行为有明确的树位置含义:

Theme.of(context)
MediaQuery.of(context)
Navigator.of(context)

它们会从当前 Element 向祖先查找相应的 InheritedWidget 或导航节点。

异步代码中常见的错误是:

onPressed: () async {
  await Future<void>.delayed(const Duration(seconds: 1));

  if (!context.mounted) {
    return;
  }

  Navigator.of(context).pop();
}

await 期间,当前 Element 可能已经被移除。访问未挂载 Element 的 context 可能导致异常,因此异步回调在使用上下文或调用 setState 前应检查挂载状态。


四、RenderObject:布局、绘制和命中测试的执行者

4.1 RenderObject 的职责

RenderObject 是渲染树中的运行时对象,负责根据父节点提供的信息进行布局,并在需要时绘制自己。

对普通二维盒模型,最常见的子类是 RenderBox。它通常处理:

  • 宽度和高度;
  • 位置;
  • 子节点布局;
  • 绘制;
  • 命中测试;
  • 语义信息的一部分。

RenderObject 不负责业务状态,也不负责决定应用中的数据来源。它接收来自 Widget 层的配置,然后把配置转化为几何结果和绘制操作。

4.2 布局的约束传播模型

Flutter 的盒模型可以形式化为:

父节点向子节点传递约束 Constraints
子节点在约束范围内选择 Size
父节点根据子节点 Size 决定自己的几何结构

RenderBox,约束通常可以表示为:

C=(wmin,wmax,hmin,hmax)C = (w_{min}, w_{max}, h_{min}, h_{max})

子节点选择的尺寸 S=(w,h)S=(w,h) 必须满足:

wminwwmaxw_{min} \leq w \leq w_{max}

hminhhmaxh_{min} \leq h \leq h_{max}

例如,父节点给出:

宽度:300 到 300
高度:0 到 600

子节点可以选择:

Size(300, 80)

但不能选择:

Size(200, 80)   // 宽度不满足最小约束
Size(300, 700)  // 高度超过最大约束

布局的一个重要方向是:

约束通常从父向子传递
尺寸通常从子向父返回
位置通常由父决定

这就是“constraints go down, sizes go up, parents set positions”的实际含义。

4.3 完整布局算例

考虑:

SizedBox(
  width: 300,
  height: 200,
  child: Padding(
    padding: const EdgeInsets.all(20),
    child: SizedBox(
      width: 100,
      height: 80,
      child: ColoredBox(color: Colors.blue),
    ),
  ),
)

布局可以简化为以下步骤:

第一步:根节点向外层 SizedBox 传递约束

假设屏幕允许的约束足以容纳这个盒子,外层 SizedBox 选择:

Size(300, 200)

第二步:Padding 获得可用空间

内边距总共占用:

左右:20 + 20 = 40
上下:20 + 20 = 40

因此子节点最多获得:

宽度:300 - 40 = 260
高度:200 - 40 = 160

第三步:内层 SizedBox 选择自己的尺寸

它要求:

Size(100, 80)

这个尺寸满足 260 × 160 的约束,因此最终尺寸就是:

Size(100, 80)

第四步:父节点设置子节点位置

Padding 把子节点放在:

Offset(20, 20)

因此蓝色区域在外层盒子中的位置是 (20, 20),大小为 100 × 80

如果内层 SizedBox 改成:

const SizedBox(width: 400, height: 300)

它不能在无条件下获得 400 × 300,因为父级传入的最大约束只有 260 × 160。具体行为取决于 Widget 和 RenderObject 的约束处理方式,可能被约束为可用尺寸,也可能在某些布局组合中产生溢出或异常。

4.4 为什么 Column 中会出现无限高度错误

下面的结构存在典型风险:

SingleChildScrollView(
  child: Column(
    children: [
      Expanded(
        child: Container(color: Colors.red),
      ),
    ],
  ),
)

SingleChildScrollView 的滚动方向通常会给子树一个无界的主轴约束。对垂直滚动而言,Column 可能获得:

最大高度 = infinity

Expanded 的含义是:把剩余的有限空间分配给子节点。此时“剩余空间”不是一个有限数值,布局无法满足 Expanded 的计算条件,于是可能出现:

RenderFlex children have non-zero flex but incoming height constraints are unbounded

这个错误不是 Expanded 失效,而是父子布局协议的前提不成立:Flex 需要有限的主轴空间,滚动容器却提供了无界主轴空间。

4.5 performLayout 的最小自定义示例

下面是一个可运行的最小自定义 RenderObject。它绘制一个圆点,大小尽量为 80 × 80,但最终尺寸仍服从父级约束。

import 'dart:math' as math;
import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: ColoredDot(color: Colors.blue),
        ),
      ),
    ),
  );
}

class ColoredDot extends LeafRenderObjectWidget {
  const ColoredDot({
    super.key,
    required this.color,
  });

  final Color color;

  @override
  RenderColoredDot createRenderObject(BuildContext context) {
    return RenderColoredDot(color);
  }

  @override
  void updateRenderObject(
    BuildContext context,
    RenderColoredDot renderObject,
  ) {
    renderObject.color = color;
  }
}

class RenderColoredDot extends RenderBox {
  RenderColoredDot(this._color);

  Color _color;

  Color get color => _color;

  set color(Color value) {
    if (value == _color) {
      return;
    }

    _color = value;
    markNeedsPaint();
  }

  @override
  void performLayout() {
    size = constraints.constrain(const Size(80, 80));
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    final paint = Paint()..color = _color;

    final center = offset + Offset(size.width / 2, size.height / 2);
    final radius = math.min(size.width, size.height) / 2;

    context.canvas.drawCircle(center, radius, paint);
  }

  @override
  bool hitTestSelf(Offset position) {
    return true;
  }
}

运行条件是一个正常的 Flutter 项目:

flutter create render_demo
cd render_demo
# 将 lib/main.dart 替换为上面的代码
flutter run

这段代码展示了 Widget、Element 和 RenderObject 的更新路径:

  1. ColoredDot 是不可变的 Widget 配置;
  2. Flutter 首次挂载时调用 createRenderObject 创建 RenderColoredDot
  3. 以后如果 ColoredDot.color 改变且 Widget 身份仍匹配,调用 updateRenderObject
  4. setter 发现颜色发生变化,于是调用 markNeedsPaint()
  5. 因为尺寸没有变化,不需要通过 markNeedsLayout() 触发布局;
  6. 下一帧重新执行绘制。

这里的 hitTestSelf 只表示该 RenderObject 可以命中。真正的手势识别仍通常由上层 GestureDetectorListener 等 Widget 完成。

4.6 markNeedsLayoutmarkNeedsPaintsetState 的区别

三者针对的对象层级不同:

调用 作用
setState 标记 StatefulElement 需要重新执行 build
markNeedsLayout 标记 RenderObject 及相关祖先需要重新布局
markNeedsPaint 标记 RenderObject 所在绘制范围需要重绘

例如自定义 RenderObject 的颜色变化通常只影响像素:

_color = value;
markNeedsPaint();

而宽高、子节点布局方式或影响父级尺寸的属性变化可能需要:

markNeedsLayout();

如果在只需重绘的场景中调用 setState,结果通常仍然正确,但会额外执行 Widget 构建和匹配。反过来,如果尺寸已经变化却只调用 markNeedsPaint,RenderObject 可能继续使用旧尺寸,导致显示错误。


五、绘制:RenderObject 如何生成图形命令

5.1 Paint 不等于立即输出像素

RenderObject 的 paint 方法通常使用 Canvas

@override
void paint(PaintingContext context, Offset offset) {
  context.canvas.drawRect(
    offset & size,
    Paint()..color = Colors.red,
  );
}

这一步通常只是向当前绘制上下文记录绘制操作。它不等于 CPU 立刻把每个像素写入屏幕,也不等于已经完成 GPU 光栅化。

绘制阶段需要处理:

  • 当前 RenderObject 的局部绘制;
  • 子 RenderObject 的绘制;
  • 裁剪;
  • 变换;
  • 透明度;
  • 阴影、图片、文字等绘制操作;
  • 是否需要进入新的合成层。

5.2 布局变化与绘制变化不是一回事

假设一个矩形只改变颜色:

几何尺寸不变
位置不变
颜色改变

通常只需要重新绘制。

如果矩形宽度改变:

几何尺寸改变
可能影响父节点布局
可能影响兄弟节点位置

就可能需要重新布局,之后再绘制。

如果一个节点被放入新的父级布局位置,即使它自身的局部绘制内容不变,父级也可能需要重新布局或重新绘制包含它的区域。

5.3 父子绘制顺序

RenderObject 的绘制顺序通常由父 RenderObject 决定。以一个简化的容器为例:

父背景
  -> 子节点 A
  -> 子节点 B
  -> 前景装饰

后绘制的内容可能覆盖先绘制的内容。因此 Widget 树中的兄弟顺序往往会影响视觉叠放顺序,但具体绘制顺序由相应 RenderObject 的实现决定,不能仅凭所有 Widget 的表面嵌套关系推断。


六、Layer:绘制结果中的合成结构

6.1 Layer 不等于 RenderObject

Layer 是用于提交和合成的图层结构,RenderObject 是负责布局和绘制的渲染对象。二者不是一一对应关系:

一个 RenderObject 可能绘制到当前 Layer
多个 RenderObject 可能共同绘制到同一个 Layer
某些 RenderObject 在需要合成时会创建或使用额外 Layer

因此,下面两种说法都不准确:

每个 Widget 都有一个 Layer
每个 RenderObject 都一定对应一个 Layer

常见 Layer 类型包括:

  • PictureLayer:保存绘制记录;
  • OpacityLayer:对子内容应用透明度;
  • ClipRectLayerClipRRectLayer:保存裁剪;
  • TransformLayer:保存变换;
  • OffsetLayer:保存偏移和子树;
  • PlatformViewLayer:承载原生平台视图等特殊内容。

具体 Layer 类型和引擎内部处理会随 Flutter 版本和渲染后端变化,应用代码不应依赖某个内部 Layer 树形状。

6.2 为什么需要 Layer

Layer 的主要价值是把绘制结构与合成结构分开。

考虑一个复杂背景和一个独立动画按钮:

页面背景
  -> 复杂图片、文字、装饰
  -> 按钮平移

如果按钮每帧移动时都让整个页面重新绘制,工作量可能很大。如果按钮或其祖先形成适当的合成边界,框架可以保留背景绘制结果,只更新按钮相关的变换或合成信息。

RepaintBoundary 是常见的显式边界:

RepaintBoundary(
  child: AnimatedBuilder(
    animation: animation,
    builder: (context, child) {
      return Transform.translate(
        offset: Offset(animation.value, 0),
        child: child,
      );
    },
    child: const ExpensivePanel(),
  ),
)

这里需要区分两件事:

  1. RepaintBoundary 可以把绘制失效限制在边界内;
  2. 是否产生额外的合成成本、缓存是否有收益,取决于内容、变化频率、内存和渲染后端。

它不是“加得越多越快”。边界过多可能增加 Layer 管理、纹理缓存和合成开销;边界过少则可能导致变化区域过大。

6.3 Compositing bits

框架在布局和绘制之间会处理 compositing bits,用来判断 RenderObject 子树是否需要合成。某些属性会使合成变得必要,例如:

  • 变换;
  • 不透明度;
  • 裁剪;
  • 过滤;
  • 平台视图;
  • 显式重绘边界。

“需要合成”不等于“必须重新绘制所有内容”。合成阶段可能只更新变换、透明度或 Layer 关系,但如果 Layer 内部的绘制内容也失效,仍需要重新生成相应绘制记录。


七、一帧是如何被调度和执行的

7.1 请求帧

Flutter 的帧调度通常围绕 SchedulerBinding 工作。以下事件可能请求下一帧:

  • setState 使 Widget 需要重建;
  • RenderObject 调用 markNeedsLayoutmarkNeedsPaint
  • 动画控制器产生新的动画值;
  • 滚动位置发生变化;
  • 外部窗口尺寸、设备方向或系统 Insets 改变;
  • 平台事件要求更新界面。

请求帧不一定立即执行。框架会把多个变化合并到即将到来的帧中,这也是 Flutter 能够批量处理更新的原因。

7.2 帧的主要阶段

一帧可以按以下顺序理解:

1. Vsync 和 beginFrame

显示系统或引擎通知 Flutter:可以准备一帧了。Flutter 记录时间戳,并推进依赖时间的动画和定时回调。

2. Build

BuildOwner 处理被标记为 dirty 的 Element:

State.setState
  -> StatefulElement.markNeedsBuild
  -> dirty list
  -> buildScope
  -> Element.rebuild

build 返回新 Widget 后,框架进行子树匹配。只有被标记的 Element 以及必要的后代会被重新构建,不是所有 Widget 都无条件调用 build

3. Layout

PipelineOwner 刷新需要布局的 RenderObject。父节点通常先向子节点传递约束,子节点完成布局后把尺寸结果返回给父节点。

如果一个 RenderObject 改变了会影响父级尺寸的属性,布局失效可能向上冒泡到合适的边界。

4. Compositing bits

框架计算哪些渲染子树需要 Layer 合成。

5. Paint

需要重绘的 RenderObject 向 PaintingContext 记录绘制操作,生成或更新 Layer 中的绘制内容。

6. Semantics

如果语义树发生变化,框架更新辅助功能所需的信息,例如:

  • 屏幕阅读器看到的文本;
  • 按钮和输入框的角色;
  • 可访问性状态;
  • 辅助操作。

Semantics 是辅助功能树,不应与 Layer 树混为一谈。

7. Composite

Renderer 层把 Layer 树组成 Scene,并将其提交给 Flutter 引擎。

8. Rasterize 和显示

引擎的渲染后端把绘制记录转换为像素,平台窗口显示最终结果。

7.3 UI 线程完成不代表光栅完成

在 Android、iOS 和桌面应用中,Flutter 通常具有负责 Dart/UI 工作的线程,以及负责光栅化的线程;另外还可能有平台线程、IO 线程等。具体线程模型属于 Flutter Engine 实现的一部分,不应把某个版本的内部线程细节当成稳定 API。

因此存在两类常见卡顿:

UI 卡顿:
build、layout、paint 或 Dart 代码没有及时完成。

Raster 卡顿:
UI 已经提交了 Scene,但光栅化、纹理上传、图片解码或 GPU 工作没有及时完成。

在刷新率为 ff Hz 的显示设备上,理想帧间隔是:

T=1000f msT = \frac{1000}{f}\text{ ms}

例如 60 Hz 时约为 16.67 ms,120 Hz 时约为 8.33 ms。但这只是显示节奏,不是 Flutter 对每一帧提供的硬性业务预算。平台调度、系统负载、光栅复杂度和输入事件都会影响实际结果。


八、一个状态变化的端到端示例

下面的示例包含:

  • StatefulWidget 保存状态;
  • setState 触发 Element 重建;
  • 一个固定的 const 子树;
  • 一个根据值变化的 Widget;
  • 通过 ValueKey 保持列表项身份。
import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(home: CounterPage()));
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int count = 0;
  bool reversed = false;

  @override
  Widget build(BuildContext context) {
    final labels = reversed ? const ['B', 'A'] : const ['A', 'B'];

    return Scaffold(
      appBar: AppBar(title: const Text('Pipeline demo')),
      body: Column(
        children: [
          const SizedBox(height: 24),
          Text(
            'count: $count',
            style: const TextStyle(fontSize: 24),
          ),
          const SizedBox(height: 24),
          for (final label in labels)
            CounterTile(
              key: ValueKey(label),
              label: label,
            ),
          const Spacer(),
          FilledButton(
            onPressed: () {
              setState(() {
                count++;
              });
            },
            child: const Text('increment'),
          ),
          TextButton(
            onPressed: () {
              setState(() {
                reversed = !reversed;
              });
            },
            child: const Text('reverse'),
          ),
          const SizedBox(height: 24),
        ],
      ),
    );
  }
}

class CounterTile extends StatefulWidget {
  const CounterTile({
    super.key,
    required this.label,
  });

  final String label;

  @override
  State<CounterTile> createState() => _CounterTileState();
}

class _CounterTileState extends State<CounterTile> {
  int localCount = 0;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text('item ${widget.label}'),
      trailing: Text('$localCount'),
      onTap: () {
        setState(() {
          localCount++;
        });
      },
    );
  }
}

点击 increment 时,过程是:

_CounterPageState.setState
  -> CounterPage 对应的 StatefulElement 变脏
  -> CounterPage.build 再次执行
  -> 新旧子 Widget 匹配
  -> count 文本配置变化
  -> 相关 RenderObject 更新
  -> 重新布局或绘制
  -> 生成新一帧

点击某个 CounterTile 时,只有该 CounterTile 对应的 State 通过 setState 变脏。祖先 CounterPage 不会因为这个局部状态变化而自动重新执行 build

点击 reverse 时,两个 CounterTile 的顺序交换。由于 Key 分别是 AB,它们的 localCount 会跟随对应的业务项移动,而不是简单地留在原位置。

如果删除 Key,列表项的内部状态可能按位置复用:

旧位置 0:A,localCount=3
旧位置 1:B,localCount=7

交换后按位置复用:
新位置 0:B,但可能得到原 A 的 State
新位置 1:A,但可能得到原 B 的 State

这不是渲染算法错误,而是没有向框架提供足够的身份信息。


九、Widget、Element、RenderObject 与 Layer 的生命周期关系

它们的生命周期并不完全同步:

Widget:
  可以在每次 build 中新建,也可以因为 const 被复用

Element:
  在匹配成功时尽量保留,匹配失败时销毁或替换

RenderObject:
  通常由 RenderObjectElement 创建,并在 Element 复用时继续保留

Layer:
  根据合成和绘制需要创建、更新或移除

一个配置变化的典型路径是:

新 Widget
  -> 旧 Element.update(newWidget)
  -> RenderObjectElement.updateRenderObject(...)
  -> RenderObject 属性 setter
  -> markNeedsLayout / markNeedsPaint

如果 Widget 匹配失败,则路径变成:

旧 Element.deactivate/unmount
旧 RenderObject detach/dispose 相关资源
新 Widget.createElement
新 Element.mount
新 RenderObjectWidget.createRenderObject

这也是为什么某些资源不能在 build 中随意创建。控制器、订阅、原生资源和动画对象应与合适的 State 或生命周期绑定,否则频繁构建可能造成资源泄漏或重复订阅。


十、失败表现与诊断方法

10.1 “我调用了 setState,但界面没变”

需要区分几种情况:

State 已经被销毁

Future<void> load() async {
  final result = await repository.fetch();

  if (!mounted) {
    return;
  }

  setState(() {
    data = result;
  });
}

没有 mounted 检查时,异步任务返回后可能对已卸载的 State 调用 setState

修改的变量没有参与 build 或 RenderObject 更新

setState(() {
  internalCache = newValue;
});

如果 build 没有使用 internalCache,界面配置没有变化,自然看不到结果。

自定义 RenderObject 忘记标记失效

set color(Color value) {
  _color = value;
  // 忘记 markNeedsPaint()
}

RenderObject 不会自动知道普通字段发生了变化。属性 setter 必须根据影响范围调用相应的失效方法。

10.2 “布局溢出”或“无限尺寸”

常见错误包括:

  • 在无界高度的滚动方向中使用需要有限空间的 Expanded
  • Row 中放入无法处理无界宽度的子节点;
  • 自定义 RenderBox 返回不满足父约束的尺寸;
  • 误用 double.infinity,但祖先没有提供有限的最大约束。

诊断时应沿父子链记录每一步约束:

父节点传入什么约束?
子节点选择了什么尺寸?
该尺寸是否满足约束?
父节点如何计算自己的尺寸?

只看最终报错位置通常不够,真正的问题可能发生在更早的约束传播阶段。

10.3 “界面重建很多次”

重建次数本身不等于问题。首先要确认:

哪些 Element 被重建?
重建是否执行了昂贵计算?
是否产生了大量临时对象?
是否进一步触发布局或绘制?

开发模式下可以使用框架提供的调试开关辅助观察,例如:

debugPrintRebuildDirtyWidgets = true;
debugPrintMarkNeedsLayoutStacks = true;
debugPrintMarkNeedsPaintStacks = true;

这些变量属于调试用途,通常不应在生产构建中依赖。

还可以使用:

  • Flutter DevTools 的 Widget Inspector;
  • Performance 页面和 Timeline;
  • showPerformanceOverlay: true 查看帧性能叠加层;
  • debugPaintSizeEnabled = true 查看布局边界;
  • debugRepaintRainbowEnabled = true 观察重绘区域。

例如:

MaterialApp(
  showPerformanceOverlay: true,
  home: const CounterPage(),
)

性能叠加层适合快速判断 UI 或 Raster 哪一侧出现压力,但它本身也有调试开销,不能把开启调试选项后的结果直接当成发布性能。

10.4 “加了 RepaintBoundary 仍然卡”

可能原因包括:

  1. 卡顿发生在 build 或 layout,而不是 paint;
  2. 边界内部本身每帧都需要重新绘制;
  3. 边界外层仍然发生大范围布局;
  4. Layer 增加导致合成、内存或纹理压力;
  5. 图片解码、纹理上传或 GPU 光栅成为瓶颈;
  6. Web 浏览器主线程同时承担其他工作。

诊断要先确定瓶颈阶段,再决定是否使用重绘边界。不能仅凭“重绘很多”就增加边界。


十一、平台差异

11.1 Android、iOS 和桌面

在 Android、iOS、Windows、macOS、Linux 等原生平台上,Flutter 应用通常由 Flutter Engine 创建平台窗口,并把 Scene 交给相应的渲染后端。

常见特征是:

  • Dart 代码和 Widget/Element/RenderObject 流程由 Flutter UI 侧执行;
  • 光栅化通常由独立线程处理;
  • 平台输入、窗口和原生 API 由平台侧参与;
  • GPU 后端可能使用 Skia 或其他 Flutter 支持的后端;
  • 某些平台和版本可能使用 Impeller,具体默认情况具有版本和平台差异。

应用层可以依赖 Flutter 暴露的渲染行为,但不应依赖固定的内部线程名、固定的 GPU API 或某个 Layer 的具体实现。

桌面端还要考虑:

  • 窗口尺寸可能连续变化;
  • 鼠标、键盘和高精度滚轮事件更常见;
  • DPI、窗口缩放和多显示器环境影响逻辑像素到物理像素的映射;
  • 可用的 GPU 能力和驱动差异可能比移动设备更大。

11.2 Web

Flutter Web 不是把原生 Flutter 窗口直接放进浏览器,而是通过 Web 渲染器将 Flutter 的绘制和合成结果交给浏览器环境。具体渲染器和输出方式会随 Flutter 版本、编译目标及浏览器能力变化。

需要特别注意:

  • 普通 Web 应用的 Dart/Flutter 工作通常运行在浏览器主线程;
  • 浏览器主线程还可能处理 DOM、JavaScript、输入和布局;
  • 浏览器合成器与 Flutter 原生平台的线程行为不同;
  • Canvas、图片、字体和浏览器 GPU 能力会影响实际性能;
  • 不能把移动端“UI 线程与 Raster 线程分离”的经验直接等同于 Web。

在 Web 上,WidgetElementRenderObject 的框架层职责仍然成立,但最后的光栅化和窗口合成由浏览器环境参与完成。


十二、几个容易混淆的边界

12.1 “Widget 树就是渲染树”

不对。

Widget 树:配置
Element 树:运行时位置和生命周期
RenderObject 树:布局、绘制、命中测试
Layer 树:合成结构

它们有对应关系,但节点数量、生命周期和职责都不同。

12.2 “build 会直接绘制”

不对。build 返回 Widget 配置。真正的布局和绘制由 RenderObject 在后续流水线阶段执行。

12.3 “每次 setState 都会重绘整个屏幕”

不对。setState 首先标记对应 Element 需要重建。之后是否触发布局、哪些 RenderObject 需要绘制、哪些 Layer 需要更新,取决于配置变化和失效范围。

12.4 “只要使用 const,就不会重建”

不对。const Widget 的配置对象可以复用,但祖先的布局或绘制仍可能变化;它也不能阻止必要的语义更新和渲染流程。

12.5 “Layer 就是缓存的位图”

不完全正确。Layer 可以保存绘制记录、变换、裁剪、透明度或平台视图等合成信息。是否转化为纹理、如何缓存、何时重新光栅化由引擎和渲染后端决定。

12.6 “布局和绘制总是严格一对一发生”

不对。布局、绘制和合成有各自的失效标记。一次状态变化可能只导致重建;一次几何变化通常导致布局和绘制;一个合成属性变化可能更新 Layer,而不必重新执行所有子内容的绘制。


十三、如何用这套模型分析实际问题

遇到一个界面问题时,可以按对象层级逐步定位:

第一步:状态是否发生变化

确认事件回调、异步任务或外部数据源是否真的修改了值。

第二步:哪个 Element 被标记为脏

如果使用 setState,确认调用的是仍然挂载的 State,并观察是否触发了预期的 build

第三步:新旧 Widget 是否成功匹配

检查:

runtimeType 是否改变?
Key 是否改变?
列表是否重排?

如果匹配失败,State 和 RenderObject 可能被替换。

第四步:RenderObject 属性改变了什么

判断变化属于:

只影响绘制?
影响本节点布局?
影响父节点或兄弟节点位置?
影响合成?
影响语义?

然后对应观察 markNeedsPaintmarkNeedsLayout、Layer 或 semantics 的变化。

第五步:卡顿发生在哪个阶段

使用 DevTools Timeline 区分:

Dart/build 很重
layout 很重
paint 很重
Raster 很重
图片或纹理处理很重
浏览器主线程很重

只有先确定阶段,优化 Widget 构建、布局结构、重绘边界或图片资源才有因果依据。


结语

Flutter 的渲染流水线不是“Widget 直接绘图”,而是多个职责明确的树和阶段协作:

Widget
  -> Element
  -> RenderObject
  -> layout
  -> paint
  -> Layer
  -> Scene
  -> rasterize / browser composite

Widget 提供可比较的不可变配置,Element 让框架能够在树中复用状态和运行时节点,RenderObject 执行约束传播、尺寸计算、绘制和命中测试,Layer 则把绘制结果组织为可提交和可合成的结构。一帧把这些阶段按顺序连接起来,但不同阶段可能拥有独立的失效范围和性能瓶颈。

掌握这条因果链后,setState 是否昂贵、Key 为什么影响状态、Expanded 为什么在滚动容器中报错、RepaintBoundary 为什么有时有效,以及 UI 卡顿和 Raster 卡顿如何区分,都可以从对象职责和帧阶段推导出来,而不必依赖经验猜测。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。