面试备战 Flutter 07:Key、Element 复用与状态保持

面试备战 Flutter 07:Key、Element 复用与状态保持

Key 的本质不是“唯一标识 Widget”,而是参与 Element 匹配,决定状态是否复用。

1. Element 复用规则

Flutter 更新子节点时比较:

oldWidget.runtimeType == newWidget.runtimeType
oldWidget.key == newWidget.key

满足则复用 Element,不满足则卸载旧节点创建新节点。

2. 为什么列表需要 Key?

无 Key 时列表按位置复用。

插入新 item 后:

旧位置 0 的 State 可能给了新位置 0

导致 checkbox、输入框、动画状态串位。

正确:

TodoItem(
  key: ValueKey(todo.id),
  todo: todo,
)

3. LocalKey

只在同一父节点下比较。

常见:

  • ValueKey。
  • ObjectKey。
  • UniqueKey。

4. ValueKey

根据值判断身份。最常用,适合业务 id。

ValueKey(user.id)

5. ObjectKey

根据对象身份判断。对象实例稳定时可用。

如果每次 build 都创建新对象,ObjectKey 也会失去稳定性。

6. UniqueKey

每次唯一,会强制不复用。

适合明确要重建的场景,不适合普通列表。

7. GlobalKey

能力:

  • 全局唯一。
  • 跨父节点移动保留 State。
  • 获取 State/Context。
  • Form 校验。

代价:

  • 全局注册、维护全局表。
  • reparent 时 element 停用/重激活的开销。
  • 容易破坏封装。
  • 同一个 GlobalKey 不能同时挂在树上两个位置,否则抛 Multiple widgets used the same GlobalKey

高频追问

Q1:Key 为什么能保持状态?

因为 State 由 Element 持有,Key 决定新 Widget 能否复用旧 Element。

Q2:为什么不用 index 做 Key?

插入、删除、排序后 index 会变,状态仍然可能串。

Q3:GlobalKey 为什么贵?

成本不在“查找”(走全局注册表哈希,O(1)),而在维护全局表,以及 GlobalKey 对应 Element 在树中移动(reparent)时会触发 deactivate/reactivate,可能引发其子树 RenderObject 重新挂载。

8. PageStorageKey 解决什么?

PageStorageKey 常用于保存滚动位置。

例如 TabBarView 里多个列表切换:

ListView.builder(
  key: const PageStorageKey('order-list'),
  itemBuilder: ...
)

它不是普通身份匹配那么简单,而是配合 PageStorage 保存页面状态,例如 scroll offset。

9. Key 的作用范围

LocalKey 只在同一个父节点的 children 中比较。

这意味着:

Column(
  children: [
    Container(child: Item(key: ValueKey(1))),
    Container(child: Item(key: ValueKey(1))),
  ],
)

这两个 ValueKey(1) 各自的父是不同的 Container,不是 siblings,所以不冲突。

但去掉 Container、让它们成为直接 siblings 就会报错(Duplicate keys found):

Column(children: [
  Item(key: ValueKey(1)),
  Item(key: ValueKey(1)), // ❌ 同一组 children 下 key 重复
])

10. GlobalKey 的典型正确用法

Form 校验

final formKey = GlobalKey<FormState>();

formKey.currentState?.validate();

ScaffoldMessenger / Navigator

某些全局导航或消息场景可以使用,但现在更推荐 Router、context 扩展或状态管理封装,避免到处 currentState。

11. Key 使用坏味道

  • 为所有 Widget 都加 Key。
  • 列表使用 index key。
  • 每次 build 生成 UniqueKey。
  • 用 GlobalKey 做普通数据传递。
  • 用 Key 掩盖状态设计混乱。

Key 应该解决身份问题,不应该替代状态管理。


🔬 深度扩展:Key的canUpdate匹配规则

扩展1:Widget.canUpdate的精确规则

源码:

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

匹配规则:

  • runtimeType相同 + key相同 → 复用Element
  • 否则 → 创建新Element

扩展2:列表交换的Key必要性

无Key(错误):

List<Widget> items = [
  StatefulItem(data: 'A'),
  StatefulItem(data: 'B'),
];

// 交换后
items = [items[1], items[0]];

// Element复用导致状态错乱

有Key(正确):

List<Widget> items = [
  StatefulItem(key: ValueKey('A'), data: 'A'),
  StatefulItem(key: ValueKey('B'), data: 'B'),
];

// 交换后Key不匹配,Element正确移动

扩展3:GlobalKey的查找开销

查找机制:

// GlobalKey维护全局Map
static final Map<GlobalKey, Element> _registry = {};

// 查找Element
Element? element = _registry[globalKey];

性能影响:

  • 全局查找有开销
  • 不适合大量使用
  • 推荐:局部状态用StatefulWidget、全局状态用Provider

补充总结

Key的深度记忆点:

  1. canUpdate规则:runtimeType + key都匹配才复用
  2. 列表场景:交换/删除/插入需要Key
  3. Key类型:ValueKey(值)、ObjectKey(引用)、UniqueKey(唯一)
  4. GlobalKey:跨树访问State,有性能开销

面试追问时要能讲出:

  • canUpdate的匹配规则(类型+key)
  • 为什么列表交换需要Key(Element复用导致状态错乱)
  • GlobalKey的性能问题(全局Map查找)

深挖追问:Key 参与的是兄弟节点匹配

Key 的作用范围要说准:

Key 只在同一父 Element 下的同类型兄弟节点匹配中发挥作用。它帮助 Framework 判断旧 Element 能不能复用给新 Widget。

列表插入问题:

无 key:
[A state] [B state] [C state]
插入 X 后按位置复用,状态可能错位

有 ValueKey(id):
Framework 按 key 找到对应旧 Element
状态跟着业务 id 走

为什么不用 index 做 key?

  • index 不是稳定身份。
  • 插入、删除、排序后 index 会变化。
  • 状态会跟位置走,不跟数据走。

GlobalKey 深挖:

  • 可以跨父节点移动并保留 State。
  • Framework 需要全局注册和查找。
  • reparent 会触发 deactivate/activate,影响依赖和布局。
  • 滥用会增加全局管理成本,也可能隐藏架构问题。

PageStorageKey:

它常用于保存滚动位置等页面局部状态,本质是借助 PageStorage bucket 存储状态,不是让 Element 永远不销毁。

AutomaticKeepAliveClientMixin 追问:

  • 它解决懒加载列表/tab 中 child 是否保活。
  • 保活会增加内存。
  • 适合表单、视频、复杂页面状态,不适合所有 item 都 keepAlive。

项目表达:

我用 Key 的原则是:业务列表用稳定业务 id;临时强制重建才用 UniqueKey;跨树访问和保状态慎用 GlobalKey,优先通过状态管理或路由传递解决。

一句话总结

Key 定义的是 Element 身份;列表用稳定业务 id,GlobalKey 只在确实需要跨层级访问或移动 State 时使用。