最后核验:2026-09-08

本篇结论

Widget 描述界面配置,Element 管理这些配置在界面树中的位置与更新,RenderObject 负责布局、绘制等渲染工作。点击计数按钮时,新的 Text 配置可以更新原有 Element,并继续使用原来的 RenderParagraph;重建 Widget 不等于把界面的所有对象推倒重来。

本篇用一个独立计数器观察 0 → 1 的变化,沿用现有 Material 入门界面。目标是理解更新过程,iOS 风格页面留到第 19 课,布局约束留到第 17 课。

学完你能做到

  • 区分 Widget、Element、RenderObject 的职责。
  • 解释 StatefulWidget 与 State 为什么分开。
  • 理解 setState 如何安排重建。
  • 通过测试比较更新前后的对象身份。
  • 区分对象保留、重新布局和重新绘制。
  • 在 iOS 模拟器运行独立章节入口。

开始前检查

先完成 第 15 课 。本篇新增三个文件:

examples/lesson_16_widget_trees.dart
app/test/lesson_16_widget_trees_test.dart
scripts/verify_lesson_16_on_device.dart

打开 完整示例 。它通过 app/ 的平台工程和依赖运行,但使用独立入口,app/lib/main.dart 仍保留原有 Spark 计数器。测试运行器与构建环境沿用 Flutter 3.47.2、Dart 3.13.2;本课不添加依赖。

按 第 08 课 设置 FLUTTER_UPSTREAM_HOME。本篇后续 Flutter 命令都从 app/ 执行,入口路径中的 ../ 指向仓库根目录。

01 一次点击提出三个问题

界面里显示 Text('0')。点击「加 1」之后,需要显示 Text('1'):

  1. 新的显示内容由谁描述?Widget。
  2. 框架怎么把新配置关联到原来的界面位置?Element。
  3. 谁把文本更新为可以布局、绘制的内容?文本对应的 RenderObject。

这三类对象分工不同,不是同一个对象的三个名称。也不是每个 Widget 都直接创建一个 RenderObject。Text 是组合型 Widget,其构建结果包含 RichText,后者关联 RenderParagraph。架构依据见 Flutter 官方架构概览 ,文本实现边界见 Text 与 RichText 。

本篇观察对象 保存或承担什么 一次计数更新时的观察目标
Text Widget 文本及样式配置 新配置从 0 变成 1
Text 对应的 Element 配置在树中的位置和更新关系 同一对象接受新配置
CounterPage 的 State _count 等可变状态 同一对象保留计数
RichText 对应的 RenderParagraph 文本布局与绘制 同一对象接收新文本

State 是与 StatefulElement 关联的状态对象。本篇只追踪这些具体对象,不要求读者背完整框架层级。

02 把可变计数放进 State

CounterPage 是不可变的 StatefulWidget,_CounterPageState 保存可变计数:

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

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

State 中的更新方法是:

int _count = 0;

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

Widget 的配置不可变,不意味着整个页面不能变化。可变数据放在 State 中,build() 根据当前值描述界面。State 的创建与生命周期见 State API 。

不要把 _count 定义成 build() 内每次都会重新初始化的局部变量,也不要在 build() 内执行 _count++。构建可能因多种原因发生,应读取状态并返回配置,避免把业务副作用绑到构建次数上。

03 setState 安排重建,不直接画屏幕

本例把 _increment 传给按钮的 onPressed。回调执行时,setState 先同步执行 _count++,再通知框架安排当前 State 的重建。

因此 setState 回调不能写成 async。将来需要异步加载时,先在回调外等待操作完成,再按生命周期要求同步更新状态。调用和调度语义见 setState API 。

重建过程中,这段代码产生新的计数配置:

Text(
  '$_count',
  key: const ValueKey('counter-text'),
  style: Theme.of(context).textTheme.displayLarge,
),

ValueKey 在本例保持不变,主要方便验证代码找到唯一计数节点。这里不改变键,也不演示列表重排;Key 的进一步作用留到第 18 课。

build() 的 context 表示当前 Widget 在树中的位置,Theme.of(context) 通过它查找上层主题。BuildContext 实际由 Element 实现,不是全局环境对象,见 BuildContext API 。

04 Element 判断新配置能否更新旧位置

本例里计数 Text 的父子位置不变,运行时类型不变,key 也相同。框架可以用新的 Widget 更新原来的 Element。

Widget.canUpdate 的规则是新旧 Widget 的 runtimeType 和 key 都相等。这个判断发生在框架进行树更新和节点匹配的过程中,不能理解成“全应用所有相同类型都共用一个 Element”。见 canUpdate API 。

同样,页面仍位于原位置,其 State 不需要因计数改变而重新创建。后续改变组件类型、位置或键时,结果可能不同;本篇的保留结论只针对当前示例。

05 RenderObject 保留,也可以重新布局和绘制

文本配置更新后,RichText 把新文本交给已有 RenderParagraph。保留对象意味着没有为这次更新替换它,不意味着它不再做布局或绘制。

因此要把三件事分开看:构建新的 Widget 配置、更新布局、更新绘制。它们相关,但不能用“build 执行了一次”推导出“整屏所有内容重新绘制了一次”,也不能用“RenderObject 没换”推导出“没有任何渲染成本”。本课不做性能测量。职责依据见 RenderObject API 。

本例只观察一段文本。Text 的 Element 与 RenderParagraph 之间还有其他节点,测试通过它的 RichText 后代取得对应渲染对象,不把这条链误画成一一对应。

06 用对象身份验证结论

打开 Widget 测试 。测试挂载 Lesson16App,保存更新前的 Text、Element、页面 State 和 RenderParagraph,再点击按钮并推进一帧:

await tester.tap(find.text('加 1'));
await tester.pump();

tap 在 Widget 测试环境发送点击,pump 推进测试帧,让安排的重建执行。与第 15 课的普通 test 不同,这里使用 testWidgets 来运行界面。依据见 Flutter Widget 测试入门 。

检查结果应为:

检查 预期
新 Text 的 data 1
新旧 Text 是否 identical false
新旧 Text Element 是否 identical true
新旧页面 State 是否 identical true
新旧 RenderParagraph 是否 identical true
原 RenderParagraph 的文本 已更新为 1

identical 比较是否同一个对象,区别于仅比较字段值相等,见 identical API 。测试再点击一次,确认计数为 2 且 State 仍保留。这里不依赖控制台中可能变化的对象编号,也不统计容易受框架其他更新影响的 build 次数。

07 在 iOS 模拟器运行独立入口

从仓库根目录进入 app/,列出设备:

cd app
"$FLUTTER_UPSTREAM_HOME/bin/flutter" devices

选择已启动的 iOS Simulator,将它的设备标识赋给当前终端变量。下面标识来自本次验证的 iPhone 17 Pro/iOS 26.5,读者替换成自己的值:

IOS_DEVICE_ID="BC0ED930-EAE5-461C-8A3D-D6286FC39DA2"
"$FLUTTER_UPSTREAM_HOME/bin/flutter" run \
  -d "$IOS_DEVICE_ID" -t ../examples/lesson_16_widget_trees.dart

-t 指定本次 Dart 入口,-d 指定设备。命令用法见 Flutter CLI 参考 ,本课参数另经实际运行验证。

初始画面应显示「Spark · 第 16 课」、计数 0 和「加 1」按钮:

第 16 课在 iOS 模拟器中的初始画面

读者点击「加 1」,应看到 1,再点击应看到 2。本次自动化环境缺少系统图形控制授权,因此没有把模拟器手动点击记为已验证;点击路径已在 Widget 测试验证,模拟器内另通过下面的回调入口验证更新。

当前运行终端按 q 退出,再执行:

"$FLUTTER_UPSTREAM_HOME/bin/flutter" run \
  -d "$IOS_DEVICE_ID" -t ../scripts/verify_lesson_16_on_device.dart

设备验证入口 启动同一个界面,首帧后取得节点,直接调用界面按钮的 onPressed,通过 endOfFrame 等待帧结束再比较对象身份,见 endOfFrame API 。它不会修改计数业务实现,也不是操作系统触控测试。验证成功时输出:

第 16 课设备验证通过:回调使计数 0 → 1,Text 更新,Element/State/RenderParagraph 保留

任何对象关系或文本不符合预期,入口会抛出错误而不是打印通过。它只用于教学验证,不应作为正式应用启动入口。

模拟器内调用按钮回调后显示 1

两张截图证明显示结果,对象保留由测试和运行检查证明,不能单凭画面判断。

首次运行或构建可能需要下载既有包和引擎产物。大陆环境沿用 第 03 课 的可信镜像路径;包下载与引擎产物下载应分别排查。维护方和适用范围见 Flutter 中国网络说明 。

08 执行回归检查

在 app/ 执行:

"$FLUTTER_UPSTREAM_HOME/bin/flutter" analyze
"$FLUTTER_UPSTREAM_HOME/bin/flutter" test --reporter expanded
"$FLUTTER_UPSTREAM_HOME/bin/flutter" build ios --simulator \
  -t ../examples/lesson_16_widget_trees.dart
"$FLUTTER_UPSTREAM_HOME/bin/flutter" build apk --debug \
  -t ../examples/lesson_16_widget_trees.dart

本课测试加入后共有 16 项测试。构建时必须保留 -t,否则默认构建的仍是 lib/main.dart,不能用默认入口的成功替代本课示例构建验证。

三端差异

  • iOS:主机 Widget 测试覆盖两次点击与对象身份;iPhone 17 Pro/iOS 26.5 Simulator 覆盖真实运行、按钮回调更新、对象身份检查和截图。本次未执行系统层手动触控。
  • Android:本课独立入口完成 debug APK 回归构建;未在 Android 设备执行本课交互,不新增 Android 平台行为结论。
  • Track B:未执行 Flutter-OH 构建或运行,不宣称本课已通过鸿蒙验证。

预期结果

  • 独立页面可以显示计数并通过按钮更新。
  • 观察到新 Text 配置更新原 Element,State 与 RenderParagraph 保留。
  • 能解释为什么对象保留不等于无需布局和绘制。
  • 本课测试、iOS 运行和对应入口的两端构建通过。

常见问题

StatelessWidget 是否永远不会 rebuild

不是。它没有独立的可变 State,但仍可以因配置或依赖变化重新构建。本例 Text 就是 StatelessWidget。

const CounterPage 为什么还能改变计数

const 限定 Widget 配置,计数存在单独的 State 中。State 更新不需要修改这个 Widget 对象。

所有 Widget 都有自己的 RenderObject 吗

不是。很多 Widget 只是组合其他 Widget。只有相关 RenderObjectWidget 节点负责创建和更新渲染对象,本例通过 RichText 观察 RenderParagraph。

setState 后是否马上就能读取新的屏幕

状态修改同步发生,后续构建由框架安排。测试通过 pump(),设备验证通过等待帧结束,来观察更新后的结果。

完成检查

  • 我能说明 Widget、Element、RenderObject 的职责。
  • 我知道计数为什么放在 State 中。
  • 我知道 setState 通知重建,而不是直接绘制。
  • 我能解释本例对象保留的匹配前提。
  • 我已运行 Widget 测试和独立 iOS 入口。
  • 我不会把主机点击测试、设备回调验证和系统触控混为一谈。

一手来源