最后核验: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'):
- 新的显示内容由谁描述?Widget。
- 框架怎么把新配置关联到原来的界面位置?Element。
- 谁把文本更新为可以布局、绘制的内容?文本对应的 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」按钮:

读者点击「加 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 保留
任何对象关系或文本不符合预期,入口会抛出错误而不是打印通过。它只用于教学验证,不应作为正式应用启动入口。

两张截图证明显示结果,对象保留由测试和运行检查证明,不能单凭画面判断。
首次运行或构建可能需要下载既有包和引擎产物。大陆环境沿用 第 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 入口。
- 我不会把主机点击测试、设备回调验证和系统触控混为一谈。