Key:那个「换了位置颜色不跟着走」的坑
这是全书最容易让人栽的一个坑,也是面试最爱问的一个点。Key 常被讲成「加上能提升列表性能」——这个说法既不完整也误导。Key 首先是正确性问题:在特定情况下,不加 Key 你的界面会显示错误的数据,加了才对。这一章有一台开关,你亲手拨一下就懂了。
先拨开关,别急着听解释
两个方块,甲是红的、乙是蓝的。颜色存在各自的 State 里(初始化时从配置抄进去的),标签存在 Widget 上。现在交换它们的位置——先不加 Key 试,再打开开关加上 Key 试。
看到那个诡异现象了吗?不加 Key,交换后颜色留在原地不动:甲跑到了右边但还是红的,乙跑到了左边但还是蓝的。加上 Key,颜色才跟着标签一起搬了家。
这不是 bug。这是上一章那条规则的精确后果。
为什么会这样:把上一章的规则套上去
交换后,Row 的孩子从 [甲, 乙] 变成 [乙, 甲]。框架按位置比对:
- 第 0 个位置:旧的是「甲」这个
ColorBox,新的是「乙」这个ColorBox。runtimeType都是ColorBox,key 都是 null →canUpdate为真! - 框架的判断是:「第 0 个位置还是那个
ColorBox,只是配置变了。」于是复用第 0 位的 Element 和它的 State,只把新 Widget(标签「乙」)塞进去。 - 但 State 里的颜色是
initState时定的红色,复用 = 不重新 initState,颜色纹丝不动。
结果:标签(在 Widget 上)换了,颜色(在 State 里)没换。框架完全按规则办事——它无从知道你的意图是「把整个甲搬过去」而不是「把第 0 个位置的内容改成乙」。这两种意图在没有 Key 时长得一模一样。
Key 就是你补上的那句话:「这个 Element 的身份,认这个 key,不认它在列表里的位置。」
加上 ValueKey('a') 之后,交换时框架发现:第 0 个位置的 key 从 'a' 变成了 'b',canUpdate 为假。它不再原地复用,而是去整个列表里找 key 匹配的那个旧 Element——找到了「甲」,把它连同 State(红色)一起搬到新位置。于是颜色跟着走了。
看引擎的统计:加 Key 后那次交换,发生了 2 次「搬迁」,0 次销毁、0 次新建。框架没有重建任何东西,只是把两个带状态的 Element 挪了位置——这正是你要的。
那条完整的多孩子匹配算法
上面这个「先按位置试,试不上就按 key 找」的过程,是 Element.updateChildren 干的。它比单孩子的 updateChild 复杂一点,但值得知道它的四步,因为它解释了 key 的所有行为:
- 从头往下扫:位置对位置比,能
canUpdate就复用,直到对不上为止。 - 从尾往上扫:同样地从末尾往回比。(头尾这两趟专门优化「在中间增删」的常见情况。)
- 中间剩下的旧节点:有 key 的,进一张
{key → Element}的表里当候选;没 key 的,直接弃用。 - 中间的新节点:有 key 就去表里认领对应的旧 Element(复用+搬迁);没有就新建。
第 3 步那句「没 key 的直接弃用」,就是上一章「列表中间插一项,无 key 就是销毁+新建」的出处。而第 4 步的「去表里认领」,就是 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 | 下面单讲 |
1. 别用 index 当 key。ValueKey(index) 等于没加——因为重排后「第 0 项」的 index 还是 0,key 没变,照样错位。key 必须绑在数据的身份上(id),不是它的位置。这条和 Compose 里 items(list) { } 不传 key 的坑一模一样。
2. 别在 build 里 UniqueKey()。UniqueKey() 每次调用都不同 → 每帧 key 都变 → canUpdate 永远为假 → 这个 Widget 每帧都被销毁重建,状态永远清零、动画永远重播。这是「我加了 key 反而更卡/状态丢了」的头号原因。
3. GlobalKey 很贵,别批量用。它要维护一张全局注册表,还能强制 Element 在树间搬家。用它当普通列表 key 是杀鸡用牛刀,会拖慢重建。
GlobalKey:能跨越结构变化的身份
上面那些是 LocalKey(ValueKey 等),只在同一个父节点的孩子之间区分身份。还有一种 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();
}
这是 Form、Scaffold(ScaffoldMessenger 出现前)等的经典用法。但要克制:大多数「我想从外面读子组件状态」的需求,正确解法是状态提升(把状态往上放),而不是 GlobalKey。GlobalKey 是逃生舱,不是日常工具。
回到实战:你的学校 App 里哪里要 key
把三个条件套到具体场景上:
| 场景 | 要 key 吗 | 为什么 |
|---|---|---|
| 课程列表纯展示(名字、时间) | 不要 | 列表项无状态 |
| 可勾选的待办清单,能排序/删除 | 要 | 有勾选状态 + 会重排 → ValueKey(todo.id) |
| 带展开动画的 FAQ 列表,能筛选 | 要 | 有展开状态 + 会重排 |
| 一堆输入框的报名表单(顺序固定) | 不要 | 有状态但不重排 |
| 切换「登录/注册」两个都含输入框的表单 | 要 | 同类型、有状态、要切换 → 各给一个不同的 key 强制区分 |
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 从生到死,中间还有一串回调——initState、didUpdateWidget、didChangeDependencies、dispose。下一章把它们排成一条时间线,顺便回答那个每个新手都问过的问题:热重载为什么能保住输入框里的字?