canUpdate:Flutter 的 diff 只看两个字段
React 会做属性对比,Compose 会做参数相等判断。Flutter 呢?它的复用判据短到你会怀疑自己看漏了什么:runtimeType 相同、key 相同,就复用;否则就推倒重来。没有属性对比,没有内容比较。这个粗暴的规则,恰恰是它快的原因,也是它坑的来源。
整个算法的核心,就这四行
上一章的同步器背后是一个叫 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 重造,动画从头开始。
类型和 key 都没变 → 复用(省钱,状态活着)。
任意一个变了 → 销毁重建(费钱,状态清零)。
Flutter 的所有「diff 智能」就到此为止。它不聪明,它快——而快正是靠「不聪明」换来的。
为什么敢这么粗暴:一个 O(N) 的赌注
通用的树 diff 算法(比对任意两棵树的最小差异)是 O(N³) 的,没法每帧跑。React 当年靠两条经验假设把它压到了 O(N),Flutter 用的是同一套假设:
- 类型变了,整棵子树就当全变了。一个
Text变成Column,不去猜「它俩有没有共同点」,直接重建。现实中「把一种控件换成完全不同的另一种、还想保留内部状态」几乎不存在,这个赌注赢面极大。 - 只在同一层、同一位置比较。第 i 个旧孩子只和第 i 个新孩子比,不跨层、不全局搜索。除非——你用 key 明确告诉它「这个东西挪位置了」(下一章)。
这两条假设让每帧的比对成本和节点数成正比,稳定 60fps 才成为可能。代价是:当你的意图不符合这两条假设时,你必须用 key 来纠正它——这就是 key 存在的全部理由。
亲手跑:diff 步进器
下面这台把同一个引擎的决策一条条打印出来。选四个场景,看每一次 canUpdate 判成了什么,以及后果。
逐个说说这四个场景,它们对应你在真实项目里会遇到的四种情况:
① 只改文字:零成本
类型都是 Text、key 都是 null → canUpdate 为真 → Element 原地复用,只更新那个字符串。这是你 App 里绝大多数帧的样子:零新建、零销毁。这就是为什么「每帧重建」在 Flutter 里不心疼。
② 换了类型:连状态一起丢
Text 变 Icon → canUpdate 为假 → 旧 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 变成了 Center,canUpdate 为假,整棵子树重建。
这是一个每个 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) : x | Padding(padding: cond ? a : zero, child: x) |
| 条件对齐 | cond ? Center(child: x) : x | Align(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 的隐式动画(AnimatedContainer、AnimatedOpacity 这类)能「从旧值平滑过渡到新值」,前提是那个 Animated Widget 的 Element 被复用了——它内部的 State 记着「上一次的值」,才能算出过渡。
所以:如果一个隐式动画「不动了、直接跳变」,八成是它的 Element 被重建了(canUpdate 判假)——可能是你条件性地包了一层(第 7 章开头那个 Padding 坑)、可能是 key 变了、可能是父节点类型跳变。反过来,如果你希望动画「重头播」而不是「接着上次」,故意给它一个变化的 UniqueKey 强制重建即可。理解 canUpdate,连动画的行为都变得可预测了。
Compose 靠调用位置(源码里的位置 + 循环里的顺序)来匹配记忆槽,所以它同样有「列表里位置变了状态就乱」的问题——解法是给 items() 传 key = { it.id }。是不是很眼熟?和 Flutter 的 key 是同一个东西、同一个理由。
不同点:Compose 的匹配基于「代码里的调用位置」,是编译期埋的;Flutter 的匹配基于「运行时树里的位置」,是每帧算的。但两者面对「重排」时的对策完全一致——用一个稳定的业务 id 当 key。你在 Compose 里养成的「列表项一定给 key」的习惯,在 Flutter 里同样正确,而且更重要(下一章告诉你为什么)。
这一章的一句话
Flutter 的 diff 不做属性对比,只看 runtimeType 和 key 两个字段:都不变就复用、保住状态,任一变化就销毁重建、清空状态——「随手包一层就丢状态」「中间插一项就错位」全是这条规则的直接后果,而不是 bug。
到这里,那两个字段里的 runtimeType 我们讲透了。还剩一个 key——它平时是 null,你几乎注意不到它。但下一章你会看到,就是这个不起眼的字段,决定了交换两个卡片时颜色跟不跟着走。这是全书最容易让人栽跟头的一个坑,我们把它彻底挖开。