把 Flutter 的更新理解成「整棵 Widget 树销毁重建」,会既高估 Widget 的成本,又低估 Key 用错的成本。三棵树分工不同:Widget 是不可变配置,Element 是实例化的生命周期,RenderObject 是布局与绘制。Diff 发生在 Element 层。Key 不是优化开关,是 身份证明:你还是不是同一个人,决定 State、焦点、滚动位置、Layer 缓存跟谁走。

读完你应该能独立回答三个问题:为什么列表交换两项没有 Key 会「跟错人」、canUpdate 为 false 时销毁的是哪棵树上的对象、GlobalKey 能跨位置挪 Element 的代价是什么。

1. 先画边界:Widget 可以扔,Element 才是人

每一帧 build 都可以 new 一棵新的 Widget 树。这很便宜:Widget 通常是 const 或短命小对象,没有 State,没有 native 资源。框架拿新 Widget 去对 已经挂在树上的 Element:

  • 能对上:Element 留下,换一份配置,可能更新对应 RenderObject 的字段。
  • 对不上:旧 Element 走 deactivate →(本帧未被 GlobalKey 救回)unmount,新 Widget inflate 出新 Element。

不变量:State 和 RenderObject 的寿命跟 Element,不跟 Widget。 你看到 build 跑了,不表示 State.initState 跑了,更不表示 RenderObject 被 new 了。

这和 React 的「组件实例」类似,但 Flutter 的 Element 同时握着 State 和 RenderObject。复用错了,不只是业务 state 错,连 size、layer 缓存、焦点节点都会错。

2. canUpdate:身份证只有两行

static bool canUpdate(Widget oldWidget, Widget newWidget) {
  return oldWidget.runtimeType == newWidget.runtimeType
      && oldWidget.key == newWidget.key;
}

runtimeType 必须同一运行时类型,不是「长得像」。Text 和 _Text 内部类、两个不同的 StatefulWidget 子类,都不能更新同一 Element。key 必须同时为 null 或相等;一个有 ValueKey(1)、一个无 key,直接判死刑。

true:element.update(newWidget),ComponentElement 再 rebuild,RenderObjectElement 调 updateRenderObject。false:不在这个节点上「变成另一种东西」,而是拆掉重建。

没有第三行「props 深比较」。Widget 的字段是否变化,是 update 之后各 Element 自己决定要不要 markNeedsLayout / markNeedsPaint。Diff 只解决身份,不解决「要不要重绘」。

3. updateChild:一个孩子的完整故事

父 Element 对单个 child 的更新,控制流可以收成:

Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
  if (newWidget == null) {
    if (child != null) deactivateChild(child);
    return null;
  }
  if (child != null) {
    if (child.widget == newWidget) {
      if (child.slot != newSlot) updateSlotForChild(child, newSlot);
      return child;
    }
    if (Widget.canUpdate(child.widget, newWidget)) {
      if (child.slot != newSlot) updateSlotForChild(child, newSlot);
      child.update(newWidget);
      return child;
    }
    deactivateChild(child);
  }
  return inflateWidget(newWidget, newSlot);
}

child.widget == newWidget 是同一引用(const 或你缓存了 Widget 实例),连 update 都跳过。这是 const 有用的真正位置:不是「不 build 父」,是「子 Element 认为配置没换」。

slot 是这个孩子在父 RenderObject 里的位置(例如 IndexedSlot)。同一 Element 换 slot,不必重建,但父要调整 sibling 链表和 parentData。列表插入一项而不改后面项的 Key,后面项只换 slot,State 仍在。

4. 多孩子:先按 Key,再按位置

updateChildren 面对一组孩子时,先用 Key 建表,再线性扫:

  1. 新 Widget 有 Key,且旧孩子里有相同 Key + 可 canUpdate:对上,挪到新位置。
  2. 否则尝试当前位置的旧孩子 canUpdate(无 Key 时的默认:按位置复用)。
  3. 对不上:旧的 deactivate,新的 inflate。
  4. 多出来的旧孩子全部 deactivate。

没有 Key,交换两项就是「位置 0 的 Element 吃到位置 0 的新 Widget」。若类型相同,canUpdate 为 true:TextField 的输入、Scrollable 的 offset、AutomaticKeepAlive 的状态,都会留在位置上,而不是跟 id 走。用户看到的是「两项交换后,打的字还在上面那个框里」。

ValueKey(id) 是集合里稳定身份的常规写法。ValueKey(index) 在排序、插入后会骗人,和没 Key 几乎一样。ObjectKey / UniqueKey 每次 new 都是新身份,等于强迫重建——UniqueKey 用在「我就是要你拆掉」的场景,例如强制重启动画。

5. GlobalKey:跨位置挪人,而不是更快的 Key

GlobalKey 把 Element 登记进全局表。下一帧它可以出现在树的另一处:框架先从旧父 deactivate,再挂到新父,同一 Element、同一 State、同一 RenderObject 搬家。Overlay、Navigator 的一些英雄动画、你手写的「这个面板从抽屉拖到桌面」,靠的是它。

代价:

  • 全局注册表,查找和冲突检测更贵。
  • 同一时刻只能出现一次;两处 build 都挂同一个 GlobalKey,断言失败。
  • 搬家发生在 buildScope 过程中,和普通 update 的深度排序交织,难推理。
  • 它让 Element 逃过「按位置就地更新」,也让你更容易把本该死去的 State 钉在全局。

静态子树、列表项,不要用 GlobalKey 当 id。那是杀鸡用牛刀,而且牛会踢人。

6. deactivate 与 unmount:死亡分两步

deactivate 不是立刻销毁。Element 进 inactiveElements,本帧稍后 finalizeTree 时若仍无人认领,才 unmount:State.dispose、RenderObject detach、dropChild、清依赖。

GlobalKey 的搬家发生在 deactivate 之后、unmount 之前:新位置 inflate 发现 GlobalKey 已有 Element,把它 reactive 回来。所以「从树上拿下来再挂上去」可以在同一帧完成,State 不跑 dispose。

你在 dispose 里做的事(停 Ticker、关 controller、取消订阅)不会在 GlobalKey 搬家时跑。反过来,以为「Widget 没了 State 就死了」,会在 Key 复用时看到「死不掉的订阅」。生命周期跟 Element,不跟 Widget 实例。

unmount 之后 Element 不能再 markNeedsBuild。异步回调必须看 mounted。这是生命周期,不是 Diff 算法的附录。

7. 脏列表:Diff 并不走整棵树

setState 只把该 StatefulElement 丢进 BuildOwner._dirtyElements。buildScope 按 depth 排序后逐个 rebuild。没脏的子树,即使父 rebuild 了,仍走 updateChild:能 const / canUpdate 就留下。

所以 Diff 的复杂度接近「脏节点 × 其直接孩子数」,不是整棵应用树。父 setState 很大时,孩子又没有 const、没有拆 Element,才会变成「整页 Diff」。这是脏范围问题,不是算法没哈希。

InheritedWidget 是另一条通知边:依赖它的 Element 登记在 InheritedElement 的依赖表里。数据变了,只通知依赖者 didChangeDependencies / mark dirty,不靠从根 Diff 下去。依赖登记本身是生命周期的一部分:unmount 必须取消登记,否则 InheritedElement 会钉住已死的子孙。

条件插入兄弟是 Key 的经典试验场:if (flag) Banner() 插在 ListView 前面,Banner 无 Key、ListView 无 Key,flag 从 false 变 true 时,位置 0 的 Element 会拿 Banner 的 Widget 去 canUpdate 原来的 ListView——类型不同,ListView 被拆掉重建,滚动位置清零。给 ListView 一个稳定 Key,插入只 inflate Banner,ListView Element 换 slot 留下。Offstage / OverlayPortals 不走这条「插兄弟」的路,它们把孩子留在树上只是不画,State 还在;用 if 卸掉和用 Offstage 藏起来,Diff 结果完全不同。

updateChildren 还有 forgottenChildren:父在这次更新里没提到、但 GlobalKey 可能要认领的孩子,先不 unmount,等帧末。你自己重写 RenderObjectElement 却忘了把没再出现的孩子 deactivateChild,会把旧 RenderObject 留在 child 链表上,paint 出「幽灵孩子」。这是 Diff 协议在自定义 Element 上的镜像,和自定义 layout 协议一样,少一步就静默错。

StatefulElement 在第一次 mount 时 createState,之后直到 unmount 都是同一个 State 实例。didUpdateWidget 的 oldWidget 是上一份配置,不是「上一个身份」。身份没变、配置变了,才走这里:你在这里比较 oldWidget.id != widget.id 再清控制器,等于用字段模拟 Key。能用 Key 表达的身份,不要推迟到 didUpdateWidget 里手写。反过来,同一身份下 URL 变了要换订阅,那才是 didUpdateWidget 的活。

BuildOwner.lockState 在 build 期间禁止再 markNeedsBuild,防止 build 重入。你在 build 里同步调用会 setState 的回调,debug 会断言。正确的逃逸是 schedulePostFrameCallback 或把通知改成「这一帧只记值,下一帧再脏」。这不是风格,是 Diff 正在遍历脏列表时不能修改列表。

8. 三棵树联动:一次身份判断的下游

canUpdate true:

  • Element 留。
  • StatefulElement 的 State 留,didUpdateWidget 可能跑。
  • RenderObjectElement 调 updateRenderObject,RenderObject 留;是否 markNeedsLayout/Paint 看字段。

canUpdate false:

  • 旧 Element deactivate/unmount,State.dispose,RenderObject detach 并从父的 child 链表摘掉,Layer 句柄松开。
  • 新 Element mount,State.initState,新 RenderObject 第一次 layout/paint。

这就是「Key 用错」的爆炸半径:你以为只是文字没对上,其实焦点节点、RestorationBucket、AutomaticKeepAlive、RepaintBoundary 的纹理,全跟错了人或被拆掉重建。

9. 设计取舍(为什么是现在这样)

  • Widget 不可变、每帧可 new:配置像值,好 diff、好 const。代价是新手以为 new Widget 等于 new State。
  • 身份 = runtimeType + key,默认按位置:少写 Key 时静态 UI 仍然对。代价是集合重排必须显式给身份,否则静默跟错人。
  • GlobalKey 作为逃逸口:真需要跨位置保留身份时有路。代价是全局性和唯一性,默认不用。
  • deactivate 延迟到帧末 unmount:同一帧搬家可行。代价是 dispose 时机比「widget 离开 build 方法」更晚,异步必须看 mounted。

这些取舍决定了什么优化有意义。给整页加 GlobalKey 不会更快;给列表项 ValueKey(id) 不是微优化,是正确性。把 StatelessWidget 拆成更小的 StatefulWidget 来缩小脏范围,改变的是谁进 dirty list,不是 canUpdate 公式。

10. 怎么把原理用到排障上

「切换 Tab 状态丢了 / 没丢该丢的」按身份提问:

  1. 同级孩子有没有稳定 Key? 排序、插入、条件 if 插一个兄弟,都是 Key 的试验场。
  2. 是 update 了还是 unmount/initState 了? 在 didUpdateWidget 和 dispose 打点,立刻能看见 Diff 走了哪条路。
  3. 脏的是哪个 Element? 父 setState 过大,Diff 再正确也在做无用 update。
  4. 有没有 GlobalKey 冲突或意外搬家? 两处同时 build 同一 key,或以为 dispose 了其实只是 deactivate。
  5. Inherited 依赖有没有泄漏? 已 unmount 仍被通知,是登记没摘干净,不是 Diff 没跑。

能回答这五问,三棵树就不再是 mermaid 图,而是一次更新里谁该活、谁该死、谁该搬家。

11. 小结

Diff 问的是「你还是不是同一个人」。runtimeType + key 是身份证,位置只是没有身份证时的猜测。Widget 可扔,Element 是人,RenderObject 跟人走。把身份说清楚,后面自定义 RenderObject 才不会在每次父 rebuild 时把你手绘的树拆掉重建。