本节摘要:声明式界面的全部魔法都在三棵树的协作里:Widget 是一次性的配置描述,Element 是常驻的协调者,RenderObject 是管几何与绘制的实干者。理解它们的分工,你就明白为什么 build 里重建整棵树不心疼、diff 的真相是什么、Key 何时才真正必要。本节是从"会用 Widget"到"理解 Flutter"的分水岭。
上一节"构建"工序产出了新的 Widget 配置树。但配置本身画不了任何东西——真正让界面动起来的,是藏在配置之下的另外两棵树。
Widget 树:配置,可随时丢弃。 Widget 是不可变对象(immutable),只描述"这里该是什么":一个 Text 描述"字号 18、内容某某",一个 Column 描述"竖着排、居中"。它轻到什么程度?每次 build 都整棵重建,框架连眼都不眨——因为重建的只是配置对象,不是界面。你写的每一个界面类都是这棵树上的节点,本书所有章节的代码都发生在这一层。
Element 树:协调者,常驻内存。 Element 持有 Widget 与底层实体的引用,负责两件事:一是生命周期与状态——StatefulWidget 的 State 对象就由 Element 持有,页面切换、热重载之间界面状态不丢,靠的就是它常驻;二是差量更新——build 产出新配置后,Element 逐个比对新旧 Widget,类型与 Key 都相同就更新配置复用实体,不同才拆掉重建。这就是 Flutter 的"diff",它不比较内容,只比较类型与 Key。
RenderObject 树:实干者,管几何与像素。 真正算尺寸、位置、参与绘制的是它。布局、绘制指令都发生在这层。它只在需要时才更新——文本没变、约束没变,RenderObject 就原地不动,这就是"重建很便宜、重排才昂贵"的机制根源:重建只在第一棵树,只要协调者判定配置没变实质,后两棵树纹丝不动。

空谈不如验证。下面这段代码,点击按钮只改一个字符串:
import 'package:flutter/material.dart'; class ThreeTreesDemo extends StatefulWidget { const ThreeTreesDemo({super.key}); @override State<ThreeTreesDemo> createState() => _ThreeTreesDemoState(); } class _ThreeTreesDemoState extends State<ThreeTreesDemo> { String note = '旧文案'; @override Widget build(BuildContext context) { print('build 执行:新的 Widget 配置已生成'); return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(note, key: const ValueKey('note')), const SizedBox(height: 12), ElevatedButton( onPressed: () => setState(() => note = '新文案 $note.length'), child: const Text('只改文字'), ), ], ), ); } }
点按钮后的实际过程:build 打印日志——Widget 树整棵重建;Element 比对 Center 对 Center、Column 对 Column、Text 对 Text,类型全同,逐个把新配置灌进旧实体;Text 的 RenderObject 发现文本变了,只重新排版这一处文字,其余几何不动。界面上你只看到一行字变了,机器上三棵树各干各的活——这个画面建立起来,后面所有优化手段都是它的推论。
三棵树的视角还能顺手解开一个第 1 章埋的谜:热重载为什么改了代码页面状态不丢?答案在 Element 常驻这件事上——热重载注入新代码后重建的是 Widget 树,Element 树没有动,它比对类型发现全同,State 原地保留;只有类型对不上(比如把 StatefulWidget 改成 StatelessWidget)才会拆树重建,状态随之丢失。这也解释了热重载的另一个限制:改 main 函数、改全局静态初始化之所以必须热重启,是因为那些代码根本不经过 Widget 树的重建路径。
把三个"重建相关"的词放一起辨析,很多模糊理解会立刻清晰:重 build(重跑 build 函数,产新配置,便宜)、重 Element(diff 后更新既有实体,通常不单独发生)、重 RenderObject(重新布局或重绘,昂贵)。优化语的准确翻译因此是"减少不必要的重 RenderObject",而不是"减少重 build"——后者便宜到不值得心疼,前者才是帧预算的大头。
既然 diff 只看类型与 Key,那么同一层级出现多个同类型的节点时,框架就分不清谁是谁,只能按位置配对。列表项交换位置、插入删除时,按位置配对会把状态配错人——这就是 Key 存在的理由。
三条实用规则:
// 账单列表:用业务主键做 Key,删除中间一项,剩余项的输入状态不会串位 final items = bills.map((b) => BillTile( key: ValueKey(b.id), // 轻记账账单的唯一 id bill: b, )).toList();
一个反直觉的提醒:把 Key 写在列表项自身是对的,但如果你把状态放在列表项的父级,Key 就救不了状态错位——状态放在哪一层,比 Key 写不写更根本。状态该放哪,第 4 章整个都在回答这个问题。
配置树每帧都在重建,那么是谁在触摸屏幕时告诉它"该重建了"?下一节沿输入回流线,看一次点按如何穿过命中测试与手势竞技场。