三棵树:谁短命、谁长命、谁真的在画
是三棵。这一章是整本书的支点:八成的 Flutter 谜题——状态为什么丢、动画为什么重来、热重载为什么保得住输入框里的字、const 为什么能省钱——都在这三棵树的关系里有唯一答案。而且这一章你不用听我说,有一台真的同步器摆在那里,你点一下它就跑给你看。
从上一章那个没回答的问题开始
Widget 每帧都被扔掉重造。那么:你在输入框里打的字,为什么没跟着一起消失?列表滚到一半的位置、动画播到第 37 帧的进度、StatefulWidget 里那个 count,凭什么活得下来?
答案就一句:它们根本不在 Widget 上。
你写 createState() 返回的那个 State 对象,被框架挂在了另一棵树的节点上。那棵树叫 Element 树,它不会每帧重建,它是框架真正在维护的东西。
三棵树,各干各的
三者的关系可以用一句话概括:Widget 是「应该是什么样」,Element 是「现在是什么」,RenderObject 是「怎么把它画出来」。
数量关系上有个容易忽略的细节:Widget 和 Element 是一一对应的,但 RenderObject 少得多——只有渲染型 Widget(上一章的第三类)才会造 RenderObject。你写的 StatelessWidget、StatefulWidget、各种 Provider、Theme,全都只有 Element,没有 RenderObject。
亲手跑:真的三树同步器
下面这台真的实现了 Flutter 的 Element.updateChild 算法(包括 identical 短路、canUpdate 判定、多孩子的头扫尾扫与 key 匹配)。先点「首帧挂载」,再点「setState」,盯住那一排数字。
挂载后你会看到:一个最普通的计数器页面,8 个 Element,但只有 6 个 RenderObject——少掉的两个正是 CounterPage 和 TapButton 这两个组件型 Widget,它们只负责「我等于下面那堆东西」,自己不画。build() 也只跑了 2 次,因为只有这两个家伙有 build 方法。
然后点 setState,看那行数字:
新建 0 个 Element,销毁 0 个 Element,5 个 Element 被复用,4 个 RenderObject 被复用,2 个节点被 const 完全跳过,build() 调用 2 次。
整棵 Element 树原封不动,每个节点只是收到了一份新配置;那 6 个真正昂贵的 RenderObject 一个都没重建,只是被通知「你的属性变了,重新量一下/重新画一下」。
所以「Flutter 每帧重建整棵树」这句话是半真半假的:重建的是那张最便宜的纸,折痕和立体结构都留着。这就是为什么重建在 Flutter 里不贵。
Element 里到底有什么
把框架源码里 Element 的关键字段翻译成人话:
| 字段 | 作用 |
|---|---|
Widget widget | 当前这份配置。每帧被换成新的,Element 自己不换 |
Element? _parent / 孩子们 | 树的结构 |
RenderObject? renderObject | 渲染型才有;组件型往上找最近的 |
State? state(在 StatefulElement 上) | 你的状态活在这里 |
PersistentHashMap _inheritedWidgets | 祖先里所有 InheritedWidget 的索引,第 15 章的主角 |
int _depth | 深度。重建时按深度从浅到深排序,保证父先于子 |
bool _dirty | setState 就是把它设成 true |
顺手揭穿一个真相:BuildContext 就是 Element
你每天都在写 BuildContext context,但可能从没想过它是什么。答案朴素得让人意外——翻开框架源码:
// framework.dart,删掉了无关部分
abstract class BuildContext {
Widget get widget;
T? dependOnInheritedWidgetOfExactType<T extends InheritedWidget>();
RenderObject? findRenderObject();
Size? get size;
…
}
class Element extends DiagnosticableTree implements BuildContext {
… // ← Element 实现了 BuildContext
}
BuildContext 就是 Element 本身,只是换了一张只读的脸给你。它被设计成一个接口,是为了不让你在 build 里乱动树的结构。
这个真相一旦知道,一串困惑会同时消失:
- 为什么
context能往上找到Theme?因为它是树上的一个节点,节点当然知道自己的祖先。 - 为什么
Scaffold.of(context)有时候报错?因为你手上那个context是创建Scaffold的那个节点,它在Scaffold的上面,往上找当然找不到。(第 15 章会用引擎复现这个经典错误。) - 为什么每个
build的context都不一样?因为它们是树上不同位置的不同节点。 - 为什么不能把
context存起来,异步回来再用?因为那个 Element 可能已经被销毁了。这就是那条你一定见过的 lint:use_build_context_synchronously——await之后要先判if (!context.mounted) return;。
一帧里到底发生了什么
把镜头拉远,看一次完整的帧。当垂直同步信号(vsync)来的时候,Flutter 引擎会依次做四件事:
| 阶段 | 谁在干 | 干什么 |
|---|---|---|
| 1. Build | Element 树 | 处理所有被标脏的 Element,跑 build(),用返回的新 Widget 去同步孩子 |
| 2. Layout | RenderObject 树 | 约束向下、尺寸向上,算出每个节点的大小和位置(卷 III) |
| 3. Paint | RenderObject 树 | 生成绘制指令,切分图层(Layer) |
| 4. Composite | 引擎(Impeller) | 把图层交给 GPU 合成上屏 |
三个值得记住的点:
一、setState 不会立刻 build。它只是 markNeedsBuild()——把这个 Element 丢进「脏元素表」,然后等下一帧。所以在一个事件回调里连着调 10 次 setState,也只会重建一次。
二、脏元素按深度排序处理。保证父亲先于孩子重建,这样一次帧里同一个节点不会被建两遍。
三、这四个阶段的成本完全不同。Build 是最便宜的(造点小对象),Layout 和 Paint 才是真花钱的地方。所以「减少重建」的收益,往往不如「减少重新布局/重绘的范围」大——这也是 RepaintBoundary 存在的理由(第 23 章)。
Compose 也有三棵树,只是它不让你看
| 层 | Compose | Flutter |
|---|---|---|
| 描述 | 可组合函数的一次次调用 | Widget 对象树 |
| 长命的中间层 | slot table(记录组、记忆值、状态槽) | Element 树 |
| 真正渲染的 | LayoutNode 树 | RenderObject 树 |
| 状态存在哪 | slot table 的槽位里(remember) | Element 上的 State 对象 |
| 你能直接碰到吗 | 几乎碰不到,编译器和运行时全包了 | 天天碰:context 就是它 |
所以「Compose 更简单」这个感觉是真的——它把中间层藏起来了,你不知道也能写。代价是:一旦出现「为什么这里没重组」这种问题,你要理解稳定性推断、重组作用域这些同样不简单的隐式规则。
Flutter 选了另一条路:把中间层摊开给你看,规则少而硬(下一章你会看到,diff 的判据只有两个字段)。学的时候费点劲,出问题时能一路推理到底。
为什么要三棵,不能合成一棵
你可能会想:既然一一对应,为什么不把 Widget 和 Element 合成一个东西,省点事?答案是它们的生命周期根本不同,硬要合并就会互相拖累。
Widget 想轻、想不可变、想每帧重造——这样声明式写起来才爽,你才能随手在 build 里 new 一大堆而不心疼(第 5 章)。Element 想重、想可变、想活得久——它要挂着 State、要记着依赖关系、要维护父子指针。这两组诉求是矛盾的:一个东西不可能既「每帧扔掉」又「活着保存状态」。
于是 Flutter 把矛盾拆开:让轻的那部分(配置)每帧重造,让重的那部分(状态和结构)长期驻留,中间用 canUpdate(下一章)把新配置「贴」到旧结构上。这就是三棵树存在的根本理由——它是「声明式写法」和「状态要留存」这两个诉求之间的一座桥。RenderObject 单独成第三棵,则是因为「布局和绘制」比「结构维护」还要重(要缓存布局结果、要管图层),把它再分出去,Element 换配置时就能不惊动它。
Widget 是剧本——每场演出印一份新的,演完就扔,改词只要重印。Element 是剧组——演员和职位长期固定,换了剧本只是照新台词演,人没换(所以「角色的记忆」——State——留得住)。RenderObject 是舞台上真实的布景和灯光——搭一次很贵,剧本改几个字不用重搭,只要调一下。你每帧递上新剧本,剧组照着演、必要时微调舞台——这就是一帧。
这不是理论。跑起你的 App,打开 DevTools 的 Flutter Inspector:
- 默认看到的是 Widget 树(还能开「隐藏实现细节」看到框架内部那些层)。
- 右边切到 Details Tree,能看到每个节点对应的 RenderObject,包括它拿到的
constraints和算出的size——卷 III 排查布局问题全靠它。 - 命令行里
debugDumpApp()打印 Widget 树,debugDumpRenderTree()打印渲染树。后者才是布局问题的真相。
这一章的一句话
Flutter 维护三棵树:你写的 Widget 是一次性的配置,框架维护的 Element 是长命的骨架(State 和 BuildContext 都是它),RenderObject 才真的在量和画;一次 setState 只是给 Element 换了份新配置——零销毁、零重建,这就是「重建很便宜」的全部秘密。
但 Element 凭什么敢复用?框架怎么判断「这份新配置还是原来那个东西」?下一章你会看到答案短得让人不敢相信:它只比较两个字段。