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

Key:那个「换了位置颜色不跟着走」的坑

直觉过境 「Key 是列表滚动的性能优化,可加可不加。」 得扔掉

这是全书最容易让人栽的一个坑,也是面试最爱问的一个点。Key 常被讲成「加上能提升列表性能」——这个说法既不完整也误导。Key 首先是正确性问题:在特定情况下,不加 Key 你的界面会显示错误的数据,加了才对。这一章有一台开关,你亲手拨一下就懂了。

ValueKey状态错位GlobalKey该加不该加

先拨开关,别急着听解释

两个方块,甲是红的、乙是蓝的。颜色存在各自的 State 里(初始化时从配置抄进去的),标签存在 Widget 上。现在交换它们的位置——先不加 Key 试,再打开开关加上 Key 试。

看到那个诡异现象了吗?不加 Key,交换后颜色留在原地不动:甲跑到了右边但还是红的,乙跑到了左边但还是蓝的。加上 Key,颜色才跟着标签一起搬了家。

这不是 bug。这是上一章那条规则的精确后果

为什么会这样:把上一章的规则套上去

交换后,Row 的孩子从 [甲, 乙] 变成 [乙, 甲]。框架按位置比对:

  • 第 0 个位置:旧的是「甲」这个 ColorBox,新的是「乙」这个 ColorBoxruntimeType 都是 ColorBox,key 都是 null → canUpdate
  • 框架的判断是:「第 0 个位置还是那个 ColorBox,只是配置变了。」于是复用第 0 位的 Element 和它的 State,只把新 Widget(标签「乙」)塞进去。
  • 但 State 里的颜色是 initState 时定的红色,复用 = 不重新 initState,颜色纹丝不动。

结果:标签(在 Widget 上)换了,颜色(在 State 里)没换。框架完全按规则办事——它无从知道你的意图是「把整个甲搬过去」而不是「把第 0 个位置的内容改成乙」。这两种意图在没有 Key 时长得一模一样。

◆ Key 是你对框架说的一句话

Key 就是你补上的那句话:「这个 Element 的身份,认这个 key,不认它在列表里的位置。」

加上 ValueKey('a') 之后,交换时框架发现:第 0 个位置的 key 从 'a' 变成了 'b'canUpdate。它不再原地复用,而是去整个列表里找 key 匹配的那个旧 Element——找到了「甲」,把它连同 State(红色)一起到新位置。于是颜色跟着走了。

看引擎的统计:加 Key 后那次交换,发生了 2 次「搬迁」,0 次销毁、0 次新建。框架没有重建任何东西,只是把两个带状态的 Element 挪了位置——这正是你要的。

那条完整的多孩子匹配算法

上面这个「先按位置试,试不上就按 key 找」的过程,是 Element.updateChildren 干的。它比单孩子的 updateChild 复杂一点,但值得知道它的四步,因为它解释了 key 的所有行为:

  1. 从头往下扫:位置对位置比,能 canUpdate 就复用,直到对不上为止。
  2. 从尾往上扫:同样地从末尾往回比。(头尾这两趟专门优化「在中间增删」的常见情况。)
  3. 中间剩下的旧节点:有 key 的,进一张 {key → Element} 的表里当候选;没 key 的,直接弃用
  4. 中间的新节点:有 key 就去表里认领对应的旧 Element(复用+搬迁);没有就新建。

第 3 步那句「没 key 的直接弃用」,就是上一章「列表中间插一项,无 key 就是销毁+新建」的出处。而第 4 步的「去表里认领」,就是 key 能让状态跨位置存活的机制。

那到底什么时候需要 Key

好消息是:绝大多数时候你不需要 Key。需要它的条件很具体,三个必须同时满足:

◆ 需要 Key 的三个条件(缺一不可)

1. 一组同类型的 Widget(都是 ColorBox、都是 TodoTile)。

2. 它们各自持有状态StatefulWidget,或者内部有动画、有 TextField、有滚动位置)。

3. 这组 Widget 会重排、增删(排序、筛选、拖拽、在开头插入)。

三条全中,就必须给每个加一个稳定且唯一的 key(通常是业务 id)。少一条,加不加都无所谓——甚至乱加反而有害(下面讲)。

反过来记更省事:如果你的列表项是无状态的(StatelessWidget,纯展示),永远不用操心 Key。状态都不存在,谈何「状态错位」。这也是为什么很多人写了很久 Flutter 从没主动加过 key 却没出过事——他们的列表项恰好都是无状态的。

用哪种 Key

Key 类型什么时候用例子
ValueKey(v)有一个能代表身份的值ValueKey(course.id)
ObjectKey(obj)拿整个对象的身份当 key(用 identical 比)ObjectKey(student)
UniqueKey()要「每次都不一样、强制重建」时强制重播动画
GlobalKey()要跨树位置移动,或从外部拿 State下面单讲
⚠ 三个和 Key 有关的真实事故

1. 别用 index 当 key。ValueKey(index) 等于没加——因为重排后「第 0 项」的 index 还是 0,key 没变,照样错位。key 必须绑在数据的身份上(id),不是它的位置。这条和 Compose 里 items(list) { } 不传 key 的坑一模一样。

2. 别在 buildUniqueKey()UniqueKey() 每次调用都不同 → 每帧 key 都变 → canUpdate 永远为假 → 这个 Widget 每帧都被销毁重建,状态永远清零、动画永远重播。这是「我加了 key 反而更卡/状态丢了」的头号原因。

3. GlobalKey 很贵,别批量用。它要维护一张全局注册表,还能强制 Element 在树间搬家。用它当普通列表 key 是杀鸡用牛刀,会拖慢重建。

GlobalKey:能跨越结构变化的身份

上面那些是 LocalKeyValueKey 等),只在同一个父节点的孩子之间区分身份。还有一种 GlobalKey,它的身份是全树唯一的,能干两件 LocalKey 干不了的事:

一、让 Element 在树里「整个搬家」而不丢状态

回到上一章结尾那个问题:把一个 Widget 从一层挪到另一层(比如响应式布局里,同一个视频播放器从 Column 移到 Row)。普通 diff 会把它当成「旧位置删除 + 新位置新建」,播放器重来。给它一个 GlobalKey,框架会认出「这是同一个东西」,把它连同 State(播放进度)整个搬过去

二、从 Widget 外部拿到 State

final _formKey = GlobalKey<FormState>();

// build 里
Form(key: _formKey, child: …)

// 事件回调里,从外部够到 Form 的 State
if (_formKey.currentState!.validate()) {
  _formKey.currentState!.save();
}

这是 FormScaffoldScaffoldMessenger 出现前)等的经典用法。但要克制:大多数「我想从外面读子组件状态」的需求,正确解法是状态提升(把状态往上放),而不是 GlobalKey。GlobalKey 是逃生舱,不是日常工具。

回到实战:你的学校 App 里哪里要 key

把三个条件套到具体场景上:

场景要 key 吗为什么
课程列表纯展示(名字、时间)不要列表项无状态
可勾选的待办清单,能排序/删除有勾选状态 + 会重排 → ValueKey(todo.id)
带展开动画的 FAQ 列表,能筛选有展开状态 + 会重排
一堆输入框的报名表单(顺序固定)不要有状态但不重排
切换「登录/注册」两个都含输入框的表单同类型、有状态、要切换 → 各给一个不同的 key 强制区分
⇄ Compose 对照 · 你早就见过这个概念

Compose 里你写过这个:

LazyColumn {
    items(todos, key = { it.id }) { todo ->   // ← 就是这个 key
        TodoRow(todo)
    }
}

不传这个 key,重排列表时 Compose 也会状态错位、动画错乱——和 Flutter 不加 ValueKey 完全一样的病,完全一样的药。区别只是:Compose 只在 LazyColumn 这类地方暴露 key 参数,Flutter 把 key 做成了每个 Widget 都有的字段,因为它的适用范围更广(比如上面那个「切换两个表单」的场景,Compose 里对应的是 key(…) { } 包裹块)。

结论:你在 Compose 里养成的「列表项一定给稳定 id 当 key」的肌肉记忆,直接带过来,一次都不会错。

这一章的一句话

Key 首先是正确性、其次才是性能:当一组同类型、有状态的 Widget 需要重排时,它告诉框架「认 key 不认位置」,让状态跟着正确的 Element 搬家;不满足这三个条件时不用管它,而 index 当 key、build 里 UniqueKey 这两种乱加法比不加更糟。

卷 II 只剩最后一块拼图了。我们已经知道 State 挂在 Element 上、能活过重建。但 State 从生到死,中间还有一串回调——initStatedidUpdateWidgetdidChangeDependenciesdispose。下一章把它们排成一条时间线,顺便回答那个每个新手都问过的问题:热重载为什么能保住输入框里的字?