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

canUpdate:Flutter 的 diff 只看两个字段

直觉过境 「框架的 diff 会聪明地对比新旧属性,挑出变化。」 得扔掉

React 会做属性对比,Compose 会做参数相等判断。Flutter 呢?它的复用判据短到你会怀疑自己看漏了什么runtimeType 相同、key 相同,就复用;否则就推倒重来。没有属性对比,没有内容比较。这个粗暴的规则,恰恰是它快的原因,也是它坑的来源。

canUpdateruntimeTypeO(N) diff同层比较

整个算法的核心,就这四行

上一章的同步器背后是一个叫 Widget.canUpdate 的静态方法。它决定「旧 Element 能不能接住这个新 Widget」。翻开源码,它真的只有这么长:

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

读一遍确认你没看错:只比较类型和 key。它不看文字变没变、颜色变没变、回调换没换——那些「属性」的更新是之后的事,canUpdate 只回答一个更前置的问题:「这还是不是原来那个位置上的那个东西?」

回答是「是」(true),就复用那个 Element:State 保留、RenderObject 保留,只把新 Widget 塞进去、把属性更新一下。回答是「否」(false),就销毁整棵旧子树、新建整棵新子树——State 没了,RenderObject 重造,动画从头开始。

◆ 一句话记住整个 diff

类型和 key 都没变 → 复用(省钱,状态活着)。
任意一个变了 → 销毁重建(费钱,状态清零)。

Flutter 的所有「diff 智能」就到此为止。它不聪明,它——而快正是靠「不聪明」换来的。

为什么敢这么粗暴:一个 O(N) 的赌注

通用的树 diff 算法(比对任意两棵树的最小差异)是 O(N³) 的,没法每帧跑。React 当年靠两条经验假设把它压到了 O(N),Flutter 用的是同一套假设:

  1. 类型变了,整棵子树就当全变了。一个 Text 变成 Column,不去猜「它俩有没有共同点」,直接重建。现实中「把一种控件换成完全不同的另一种、还想保留内部状态」几乎不存在,这个赌注赢面极大。
  2. 只在同一层、同一位置比较。第 i 个旧孩子只和第 i 个新孩子比,不跨层、不全局搜索。除非——你用 key 明确告诉它「这个东西挪位置了」(下一章)。

这两条假设让每帧的比对成本和节点数成正比,稳定 60fps 才成为可能。代价是:当你的意图不符合这两条假设时,你必须用 key 来纠正它——这就是 key 存在的全部理由。

亲手跑:diff 步进器

下面这台把同一个引擎的决策一条条打印出来。选四个场景,看每一次 canUpdate 判成了什么,以及后果。

逐个说说这四个场景,它们对应你在真实项目里会遇到的四种情况:

① 只改文字:零成本

类型都是 Text、key 都是 null → canUpdate 为真 → Element 原地复用,只更新那个字符串。这是你 App 里绝大多数帧的样子:零新建、零销毁。这就是为什么「每帧重建」在 Flutter 里不心疼。

② 换了类型:连状态一起丢

TextIconcanUpdate 为假 → 旧 Element 连同它的 State、RenderObject 一起被弃用,全新造一个。这正是下面这种写法丢状态的原理:

// ✗ 加载完成后,整个内部状态清零、动画重来
child: isLoading
    ? const LoadingSpinner()      // 一种类型
    : CourseList(courses: data),   // 另一种类型
// 类型一变,canUpdate 就是假,两边互相切换时状态都保不住

如果两个分支是同一种有状态 Widget、只是参数不同,问题会更隐蔽——那时候要靠 key 强制区分,下一章讲。

③ 中间插一项:无 key 就是一次销毁 + 新建

[A, C] 变成 [A, B, C]。你以为框架会「认出」C 只是往后挪了一格?不会——它按位置比:第 2 个位置原来是 C(现在是 B),类型可能不同就重建;第 3 个位置原来没有(现在是 C)就新建。看引擎打印的日志:中间那个没有 key 的旧节点直接被弃用

如果 A/B/C 是有状态的(比如带勾选框的列表项),这个「按位置比」会造成经典 bug:在列表开头插入一项,后面所有项的状态会集体错位一格。解法还是 key。

④ 外面包一层:整棵子树推倒

这个最反直觉,也最值得记。你只是给 Item 外面包了一层 Center——但在那个位置上,孩子的类型从 Item 变成了 CentercanUpdate 为假,整棵子树重建

⚠「我只是包了一层 Padding,动画怎么重来了?」

这是一个每个 Flutter 开发者都踩过的坑。你根据某个条件,给一个 Widget 有时候包一层、有时候不包:

// ✗ selected 一变,这个位置的类型就在 Padding 和 (裸) Item 之间跳
Widget build(BuildContext context) {
  final tile = MyAnimatedTile(...);
  return selected
      ? Padding(padding: const EdgeInsets.all(8), child: tile)
      : tile;
}
// 后果:每次 selected 翻转,MyAnimatedTile 的 State 清零、动画从头播

原因:这个位置的直接孩子,一会儿是 Padding,一会儿是 MyAnimatedTile,类型在跳,canUpdate 一直为假。

解法:让类型稳定。永远包着,用 padding 的值来控制有没有间距:

// ✓ 类型永远是 Padding,只是 padding 的数值在变 → 复用
return Padding(
  padding: selected ? const EdgeInsets.all(8) : EdgeInsets.zero,
  child: MyAnimatedTile(...),
);
// 或者用 AnimatedPadding,顺便把间距变化做成动画

一个立刻能用的推论:三元表达式怎么写才安全

把上面这些串起来,你会得到一条实用规则:在需要保留状态的地方,让 Widget 的类型在各种条件下保持稳定。

想做的事会丢状态的写法保得住状态的写法
条件加边距cond ? Padding(child: x) : xPadding(padding: cond ? a : zero, child: x)
条件对齐cond ? Center(child: x) : xAlign(alignment: cond ? … : …, child: x)
两种状态切换cond ? WidgetA() : WidgetB()各自加上不同的 Key(下一章)
可选地隐藏用条件从 children 里删掉Visibility / Offstage 保留在树上

注意最后一条:「从 children 列表里删掉一个 Widget」和「把它藏起来」是完全不同的。删掉 → Element 销毁 → 状态没了;藏起来(Offstage)→ Element 还在 → 状态活着,只是不画、不占位。要保留滚动位置、播放进度这类状态,用后者。

为什么 diff 只在「同一层」比

再强调一下第二条假设,因为它常被忽略。Flutter 从不跨层匹配。看这个例子:

// 第一帧
Column(children: [A(), B()])

// 第二帧
Column(children: [Padding(child: A()), B()])

你可能觉得「A 还是 A,只是被包起来了,框架应该能认出来」。不能。第 0 个位置:旧的是 A,新的是 Padding,类型不同 → A 的 Element 被销毁,Padding(连同它里面一个全新的 A)被新建。框架的视野只有当前这一层的位置对位置,它看不到「A 下沉了一层」这种全局重排。

要表达「这个东西还是那个东西,只是挪了地方」,你需要一个能跨越这种结构变化的身份标记——那就是下一章的 GlobalKey

再看一眼那两个字段:runtimeType 的两个隐藏后果

canUpdate 比的第一个字段是 runtimeType——「运行时的确切类型」。这里有两个不太直观、但会实际影响你的后果。

后果一:泛型参数不同 = 不同类型 = 会重建

// 这两个的 runtimeType 是不同的!
FutureBuilder<int>(...)      // runtimeType = FutureBuilder<int>
FutureBuilder<String>(...)   // runtimeType = FutureBuilder<String>

如果某个位置的 Widget 泛型参数在两帧之间变了,canUpdate 判假、整棵子树重建。日常很少撞上,但当你写通用组件、泛型在条件里变化时,要知道这会导致重建。

后果二:用函数返回 Widget,类型全一样,反而「太容易」复用

回到第 5 章那个「拆方法 vs 拆 Widget」。假设你用一个方法根据条件返回不同内容:

Widget _buildBody() {
  if (loading) return const CircularProgressIndicator();
  return CourseList(...);
}

这里两个分支类型不同CircularProgressIndicator vs CourseList),所以切换时 canUpdate 判假、正确地重建——没问题。但如果两个分支类型相同、只是参数不同canUpdate 会判真、复用同一个 Element——这时候若它们各自该有独立状态,就会串味(第 8 章那个 key 的坑)。「类型相同就复用」是把双刃剑:多数时候帮你省钱,偶尔会帮倒忙让状态错位。记住判据在哪,出问题时你就知道往哪看。

这条规则怎么帮你读懂动画

canUpdate 还悄悄决定了动画的连续性。Flutter 的隐式动画(AnimatedContainerAnimatedOpacity 这类)能「从旧值平滑过渡到新值」,前提是那个 Animated Widget 的 Element 被复用了——它内部的 State 记着「上一次的值」,才能算出过渡。

所以:如果一个隐式动画「不动了、直接跳变」,八成是它的 Element 被重建了canUpdate 判假)——可能是你条件性地包了一层(第 7 章开头那个 Padding 坑)、可能是 key 变了、可能是父节点类型跳变。反过来,如果你希望动画「重头播」而不是「接着上次」,故意给它一个变化的 UniqueKey 强制重建即可。理解 canUpdate,连动画的行为都变得可预测了。

⇄ Compose 对照 · 位置 vs 身份

Compose调用位置(源码里的位置 + 循环里的顺序)来匹配记忆槽,所以它同样有「列表里位置变了状态就乱」的问题——解法是给 items()key = { it.id }是不是很眼熟?和 Flutter 的 key 是同一个东西、同一个理由。

不同点:Compose 的匹配基于「代码里的调用位置」,是编译期埋的;Flutter 的匹配基于「运行时树里的位置」,是每帧算的。但两者面对「重排」时的对策完全一致——用一个稳定的业务 id 当 key。你在 Compose 里养成的「列表项一定给 key」的习惯,在 Flutter 里同样正确,而且更重要(下一章告诉你为什么)。

这一章的一句话

Flutter 的 diff 不做属性对比,只看 runtimeTypekey 两个字段:都不变就复用、保住状态,任一变化就销毁重建、清空状态——「随手包一层就丢状态」「中间插一项就错位」全是这条规则的直接后果,而不是 bug。

到这里,那两个字段里的 runtimeType 我们讲透了。还剩一个 key——它平时是 null,你几乎注意不到它。但下一章你会看到,就是这个不起眼的字段,决定了交换两个卡片时颜色跟不跟着走。这是全书最容易让人栽跟头的一个坑,我们把它彻底挖开。