卷 II · 三棵树CH 06深度 06/24

三棵树:谁短命、谁长命、谁真的在画

直觉过境 「界面就是一棵树。」 改一下

是三棵。这一章是整本书的支点:八成的 Flutter 谜题——状态为什么丢、动画为什么重来、热重载为什么保得住输入框里的字、const 为什么能省钱——都在这三棵树的关系里有唯一答案。而且这一章你不用听我说,有一台真的同步器摆在那里,你点一下它就跑给你看。

Element 树RenderObjectBuildContext 真身帧管线

从上一章那个没回答的问题开始

Widget 每帧都被扔掉重造。那么:你在输入框里打的字,为什么没跟着一起消失?列表滚到一半的位置、动画播到第 37 帧的进度、StatefulWidget 里那个 count,凭什么活得下来?

答案就一句:它们根本不在 Widget 上。

你写 createState() 返回的那个 State 对象,被框架挂在了另一棵树的节点上。那棵树叫 Element 树,它不会每帧重建,它是框架真正在维护的东西。

三棵树,各干各的

WIDGET · 纸
你写的。不可变、极轻、每帧全部重造。只是一份配置。
ELEMENT · 折痕
框架维护的。长命、可变,一个 Widget 对应一个 Element。State 挂在这里。
RENDEROBJECT · 立体
真的在干活的。量尺寸、定位置、画像素、命中测试。建它最贵,所以拼命复用。

三者的关系可以用一句话概括:Widget 是「应该是什么样」,Element 是「现在是什么」,RenderObject 是「怎么把它画出来」。

数量关系上有个容易忽略的细节:Widget 和 Element 是一一对应的,但 RenderObject 少得多——只有渲染型 Widget(上一章的第三类)才会造 RenderObject。你写的 StatelessWidgetStatefulWidget、各种 ProviderTheme,全都只有 Element,没有 RenderObject。

亲手跑:真的三树同步器

下面这台真的实现了 Flutter 的 Element.updateChild 算法(包括 identical 短路、canUpdate 判定、多孩子的头扫尾扫与 key 匹配)。先点「首帧挂载」,再点「setState」,盯住那一排数字。

挂载后你会看到:一个最普通的计数器页面,8 个 Element,但只有 6 个 RenderObject——少掉的两个正是 CounterPageTapButton 这两个组件型 Widget,它们只负责「我等于下面那堆东西」,自己不画。build() 也只跑了 2 次,因为只有这两个家伙有 build 方法。

然后点 setState,看那行数字:

◆ 一次 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 _dirtysetState 就是把它设成 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 章会用引擎复现这个经典错误。)
  • 为什么每个 buildcontext 都不一样?因为它们是树上不同位置的不同节点。
  • 为什么不能把 context 存起来,异步回来再用?因为那个 Element 可能已经被销毁了。这就是那条你一定见过的 lint:use_build_context_synchronously——await 之后要先判 if (!context.mounted) return;

一帧里到底发生了什么

把镜头拉远,看一次完整的帧。当垂直同步信号(vsync)来的时候,Flutter 引擎会依次做四件事:

阶段谁在干干什么
1. BuildElement 树处理所有被标脏的 Element,跑 build(),用返回的新 Widget 去同步孩子
2. LayoutRenderObject 树约束向下、尺寸向上,算出每个节点的大小和位置(卷 III)
3. PaintRenderObject 树生成绘制指令,切分图层(Layer)
4. Composite引擎(Impeller)把图层交给 GPU 合成上屏

三个值得记住的点:

一、setState 不会立刻 build。它只是 markNeedsBuild()——把这个 Element 丢进「脏元素表」,然后等下一帧。所以在一个事件回调里连着调 10 次 setState,也只会重建一次。

二、脏元素按深度排序处理。保证父亲先于孩子重建,这样一次帧里同一个节点不会被建两遍。

三、这四个阶段的成本完全不同。Build 是最便宜的(造点小对象),Layout 和 Paint 才是真花钱的地方。所以「减少重建」的收益,往往不如「减少重新布局/重绘的范围」大——这也是 RepaintBoundary 存在的理由(第 23 章)。

Compose 也有三棵树,只是它不让你看

⇄ Compose 对照 · 同一个结构,不同的暴露程度
ComposeFlutter
描述可组合函数的一次次调用Widget 对象树
长命的中间层slot table(记录组、记忆值、状态槽)Element 树
真正渲染的LayoutNode 树RenderObject 树
状态存在哪slot table 的槽位里(rememberElement 上的 State 对象
你能直接碰到吗几乎碰不到,编译器和运行时全包了天天碰context 就是它

所以「Compose 更简单」这个感觉是真的——它把中间层藏起来了,你不知道也能写。代价是:一旦出现「为什么这里没重组」这种问题,你要理解稳定性推断、重组作用域这些同样不简单的隐式规则。

Flutter 选了另一条路:把中间层摊开给你看,规则少而硬(下一章你会看到,diff 的判据只有两个字段)。学的时候费点劲,出问题时能一路推理到底。

为什么要三棵,不能合成一棵

你可能会想:既然一一对应,为什么不把 Widget 和 Element 合成一个东西,省点事?答案是它们的生命周期根本不同,硬要合并就会互相拖累

Widget 想轻、想不可变、想每帧重造——这样声明式写起来才爽,你才能随手在 buildnew 一大堆而不心疼(第 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 凭什么敢复用?框架怎么判断「这份新配置还是原来那个东西」?下一章你会看到答案短得让人不敢相信:它只比较两个字段。