Flutter 基础体系 · 第 36/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter Key 完整指南:ValueKey、ObjectKey、GlobalKey 和状态保留
在 Flutter 中,Key 是 Widget 的身份标识。它不负责保存状态,也不是数据库主键;它的作用是帮助框架判断:
新构建出的 Widget,是否应该继续使用旧 Widget 对应的 Element 和 State。
这一区分非常重要:
Widget:配置,通常会频繁创建
Element:Widget 配置在树中的挂载实例
State:StatefulWidget 对应的可变状态
RenderObject:布局、绘制和命中测试对象
Key:帮助框架匹配 Widget 与已有 Element
当 StatefulWidget 重新构建时,Flutter 通常不会重新创建对应的 State,而是让旧的 Element 更新为新的 Widget。Key 参与的正是这个“旧 Element 与新 Widget 如何匹配”的过程。
1. Key 解决的根本问题:Widget 是否还是“同一个位置上的对象”
假设有一个列表:
Column(
children: const [
Text('A'),
Text('B'),
],
)
下一次构建时变成:
Column(
children: const [
Text('B'),
Text('A'),
],
)
如果没有 Key,框架通常会优先按位置匹配:
旧位置 0 的 Text('A') -> 新位置 0 的 Text('B')
旧位置 1 的 Text('B') -> 新位置 1 的 Text('A')
对于无状态的 Text,这通常没有明显问题。但如果子项是 StatefulWidget,每个子项内部有输入框、动画控制器或展开状态,状态可能会跟着“位置”走,而不是跟着业务对象走。
加入 Key 后:
Column(
children: const [
Text('A', key: ValueKey('a')),
Text('B', key: ValueKey('b')),
],
)
交换顺序:
Column(
children: const [
Text('B', key: ValueKey('b')),
Text('A', key: ValueKey('a')),
],
)
框架可以根据 Key 找到:
旧的 ValueKey('b') -> 新位置 0
旧的 ValueKey('a') -> 新位置 1
于是原来与 B 对应的 Element 和 State 会移动到新位置 0,状态仍然属于 B。
1.1 一个形式化的匹配条件
对于同一个父 Element 下的旧 Widget oldWidget 和新 Widget newWidget,框架能否复用旧 Element,核心条件可以概括为:
runtimeType(oldWidget) == runtimeType(newWidget)
并且
oldWidget.key == newWidget.key
Flutter 源码通过 Widget.canUpdate 表达这一条件:
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType &&
oldWidget.key == newWidget.key;
}
这里的 == 是 Dart 的相等运算,因此不同 Key 类型会采用各自的相等规则。
需要注意,这个条件是“是否可以更新已有 Element”的核心判断,不等于完整描述所有子列表的 diff 细节。对于多个子节点,框架还会结合位置、Key 和子节点结构进行更新。
1.2 Key 不是全局唯一编号
下面两个 Widget 可以安全地使用相同的局部 Key:
Column(
children: [
Row(key: const ValueKey('row')),
Row(key: const ValueKey('row')),
],
)
但这种写法本身有问题,因为同一个父节点的同一层级出现了重复 Key。另一方面,下面两个不同父节点中的 Key 不会因为值相同就发生冲突:
Column(
children: [
Row(
children: [
const Text('左', key: ValueKey('label')),
],
),
Row(
children: [
const Text('右', key: ValueKey('label')),
],
),
],
)
普通的 LocalKey 只在对应父节点的子列表中参与匹配。它不是整个应用范围内的标识。
2. Key、Element 和 State 的关系
理解状态保留,必须区分三种情况:
新 Widget 能匹配旧 Element
-> 旧 Element 被更新
-> 旧 State 通常继续保留
新 Widget 无法匹配旧 Element
-> 旧 Element 被卸载
-> 对应 State.dispose() 被调用
新 Widget 匹配了另一个旧 Element
-> 状态可能被“交换”到另一个业务对象上
例如:
class CounterItem extends StatefulWidget {
const CounterItem({
required this.title,
super.key,
});
final String title;
@override
State<CounterItem> createState() => _CounterItemState();
}
class _CounterItemState extends State<CounterItem> {
int count = 0;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(widget.title),
trailing: TextButton(
onPressed: () => setState(() => count++),
child: Text('$count'),
),
);
}
}
如果两个 CounterItem 没有 Key,列表顺序发生变化时,count 可能保留在原来的位置,而不是原来的业务项上。
如果使用稳定的业务 ID:
CounterItem(
key: ValueKey(item.id),
title: item.title,
)
那么 count 会跟随 item.id 对应的 Element 移动。
2.1 重建不等于状态丢失
父组件调用 setState,会重新执行 build,但这并不意味着所有子组件的 State 都被销毁:
父 State.setState()
-> 父 build()
-> 生成新的子 Widget 对象
-> 子 Widget 与旧 Element 匹配
-> 子 State 保留
只有当匹配失败,或者节点被移除并完成卸载时,子 State 才会结束生命周期。
因此,下面这种担忧通常是不正确的:
“每次 build 都创建了新的 Widget,所以 State 一定重置。”
Widget 可以是新的,Element 和 State 仍然可以是旧的。
3. LocalKey 与 GlobalKey
Flutter 的 Key 大致分为两类:
LocalKey
├── ValueKey<T>
├── ObjectKey
└── UniqueKey
GlobalKey<T>
├── GlobalKey<T>
└── GlobalObjectKey<T>
3.1 LocalKey
LocalKey 只在一个父节点的子节点范围内参与匹配。它适合:
- 列表项身份识别;
- 同一父节点下的子项重排;
- 保留列表项内部的 State;
- 避免状态跟随位置错误移动。
ValueKey、ObjectKey 和 UniqueKey 都属于 LocalKey。
3.2 GlobalKey
GlobalKey 可以在整个 Widget 树范围内定位对应的 Element。它还支持在树中的位置变化时保留整个子树的 State。
它适合:
- 访问
FormState; - 在少量场景下获取某个组件的
State或BuildContext; - 需要把一个带状态的子树从一个父节点移动到另一个父节点。
但它的能力更强,代价也更高。多数列表和普通组件不应该使用 GlobalKey。
4. ValueKey:用值相等性标识 Widget
ValueKey<T> 使用所包装值的 == 判断相等性:
const ValueKey('user-42')
ValueKey<int>(42)
ValueKey<String>('42')
概念上可以表示为:
ValueKey(a) == ValueKey(b)
当且仅当
a == b
更准确地说,ValueKey 还会检查对应的 Key 类型参数约束;工程上最重要的规则是:Key 内部的值应当具有稳定且正确的 == 语义。
4.1 最常见用法:业务 ID
class UserRow extends StatelessWidget {
const UserRow({
required this.userId,
required this.name,
super.key,
});
final int userId;
final String name;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(name),
subtitle: Text('ID: $userId'),
);
}
}
ListView(
children: users.map((user) {
return UserRow(
key: ValueKey(user.id),
userId: user.id,
name: user.name,
);
}).toList(),
)
这里的 user.id 必须满足:
- 同一业务对象在列表生命周期内保持不变;
- 不同业务对象不会拥有同一个 ID;
- ID 的相等语义与业务身份一致。
如果用户 ID 是字符串、整数或不可变值,ValueKey 通常是最直接的选择。
4.2 ValueKey 的关键边界:相等性必须稳定
下面的类型不适合作为长期挂载节点的 Key 值:
class MutableIdentity {
MutableIdentity(this.id);
int id;
@override
bool operator ==(Object other) =>
other is MutableIdentity && other.id == id;
@override
int get hashCode => id.hashCode;
}
如果对象已经作为 Key 使用,之后修改 id:
final identity = MutableIdentity(1);
final key = ValueKey(identity);
identity.id = 2;
那么这个 Key 的相等性和哈希值可能发生变化。框架在匹配旧子节点和新子节点时可能无法按预期找到它,结果包括 State 被重建、节点被错误匹配或出现难以诊断的更新行为。
更安全的做法是使用不可变标识:
ValueKey<int>(user.id)
或者确保作为 Key 的值在挂载期间不会改变。
4.3 自定义 ValueKey 子类避免跨组件冲突
如果多个组件都可能使用相同的字符串值,可以定义私有 Key 类型:
class _ProductKey extends ValueKey<String> {
const _ProductKey(super.value);
}
class _CategoryKey extends ValueKey<String> {
const _CategoryKey(super.value);
}
这样:
const _ProductKey('42')
const _CategoryKey('42')
不会被视为同一种 Key。这个技巧在组件库中尤其有用,可以避免不同组件内部使用相同值时产生意外匹配。
5. ObjectKey:用对象身份而不是对象内容标识
ObjectKey 使用对象的身份,也就是 identical 语义:
ObjectKey(a) == ObjectKey(b)
当且仅当
identical(a, b)
示例:
final user = User(id: 1, name: 'Alice');
UserRow(key: ObjectKey(user))
如果之后仍然使用同一个对象实例:
final sameUser = user;
identical(user, sameUser); // true
则 ObjectKey(user) 和 ObjectKey(sameUser) 相等。
但如果创建了内容相同的新对象:
final anotherUser = User(id: 1, name: 'Alice');
identical(user, anotherUser); // false
ObjectKey(user) == ObjectKey(anotherUser); // false
即使 user.id 和 anotherUser.id 相同,ObjectKey 也会认为它们是不同对象。
5.1 ObjectKey 和 ValueKey 的选择
假设:
class Product {
Product(this.id, this.name);
final int id;
final String name;
}
使用业务身份:
ProductTile(key: ValueKey(product.id))
表达的是:
只要产品 ID 相同,就认为它是同一个产品项。
使用对象身份:
ProductTile(key: ObjectKey(product))
表达的是:
必须是同一个 Product 实例,才认为它是同一个产品项。
因此,下面两种状态更新的语义不同:
products[index] = Product(
old.id,
'新名称',
);
如果 Key 是 ValueKey(old.id),新对象仍可匹配旧 Element,状态继续保留。
如果 Key 是 ObjectKey(old),新对象不是旧实例,匹配失败,旧 State 会被替换。
5.2 ObjectKey 的典型适用边界
ObjectKey 适用于对象实例本身就是身份,并且对象实例在树更新期间稳定的场景,例如:
- 内存中的节点对象;
- 编辑器模型对象;
- 不希望仅因对象内容替换就复用 State 的场景。
如果数据来自网络、数据库或状态管理层,并且每次刷新都会重新创建对象实例,通常应使用稳定业务 ID 的 ValueKey,而不是 ObjectKey。
6. UniqueKey:每次实例都代表新节点
UniqueKey 的每个实例都只与自己相等:
UniqueKey()
以下两个 Key 一定不同:
UniqueKey() == UniqueKey(); // false
因此:
SomeStatefulWidget(key: UniqueKey())
在每次 build 中创建新 Key,会让框架无法匹配旧 Element:
build 第一次:UniqueKey A
build 第二次:UniqueKey B
A != B
-> 旧 Element 无法复用
-> State 被销毁并创建新的 State
这是一种强制重置 State 的方式,但不应把它当作普通列表 Key。
适合的场景包括:
AnimatedSwitcher(
child: SomePanel(key: UniqueKey()),
)
当确实需要告诉框架“这不是之前的那个子组件”时,可以使用它。否则应使用稳定 Key 或不设置 Key。
7. 一个完整的状态保留示例:可重排编辑列表
下面的示例展示了为什么列表项应使用业务 ID,而不是索引。
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: EditorListPage()));
}
class TodoItem {
const TodoItem({
required this.id,
required this.title,
});
final int id;
final String title;
}
class EditorListPage extends StatefulWidget {
const EditorListPage({super.key});
@override
State<EditorListPage> createState() => _EditorListPageState();
}
class _EditorListPageState extends State<EditorListPage> {
final List<TodoItem> items = [
const TodoItem(id: 10, title: '设计页面'),
const TodoItem(id: 20, title: '实现接口'),
const TodoItem(id: 30, title: '编写测试'),
];
void _moveItem(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('待办事项')),
body: ReorderableListView(
onReorder: _moveItem,
children: [
for (final item in items)
TodoEditor(
key: ValueKey(item.id),
initialTitle: item.title,
),
],
),
);
}
}
class TodoEditor extends StatefulWidget {
const TodoEditor({
required this.initialTitle,
super.key,
});
final String initialTitle;
@override
State<TodoEditor> createState() => _TodoEditorState();
}
class _TodoEditorState extends State<TodoEditor> {
late final TextEditingController controller;
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.initialTitle);
}
@override
void didUpdateWidget(covariant TodoEditor oldWidget) {
super.didUpdateWidget(oldWidget);
// Key 相同意味着通常仍是同一个业务项。
// 如果外部初始值确实发生变化,再同步控制器。
if (oldWidget.initialTitle != widget.initialTitle &&
controller.text != widget.initialTitle) {
controller.text = widget.initialTitle;
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
key: const ValueKey('tile-container'),
padding: const EdgeInsets.all(8),
child: TextField(
controller: controller,
decoration: InputDecoration(
labelText: widget.initialTitle,
border: const OutlineInputBorder(),
),
),
);
}
}
上面的代码中,真正用于识别待办事项的是:
key: ValueKey(item.id)
ReorderableListView 要求子项具有 Key,这是因为它需要识别重排前后的子项身份。
但是代码中的这段:
Padding(
key: const ValueKey('tile-container'),
...
)
在实际项目中会导致同一个父节点下重复使用相同 Key,因为每个 TodoEditor 的 Padding 都属于各自不同的 TodoEditor 子树,通常不会互相冲突;它只是在示例中说明 Key 的作用范围。更清晰且不容易误用的写法是直接让 TodoEditor 作为重排项根节点,并删除这层固定 Key:
class TodoEditor extends StatefulWidget {
// ...
}
class _TodoEditorState extends State<TodoEditor> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: TextField(
controller: controller,
decoration: InputDecoration(
labelText: widget.initialTitle,
border: const OutlineInputBorder(),
),
),
);
}
}
此时外部传给 TodoEditor 的 ValueKey(item.id) 就是整个 StatefulWidget 的身份。
7.1 错误写法:使用列表索引作为 Key
for (var index = 0; index < items.length; index++)
TodoEditor(
key: ValueKey(index),
initialTitle: items[index].title,
)
假设初始状态如下:
位置 0:A,输入框状态为 “用户修改了 A”
位置 1:B,输入框状态为 “用户修改了 B”
交换 A 和 B 后:
位置 0:B,但 Key 仍然是 ValueKey(0)
位置 1:A,但 Key 仍然是 ValueKey(1)
框架会认为位置 0 仍对应原来的 Element,于是 A 的输入状态可能显示在 B 上。
索引 Key 只有在列表项身份确实等于位置、并且列表不会插入、删除、排序时才安全。只要列表支持增删、筛选、排序、分页合并或异步刷新,就不应把索引当作业务身份。
8. GlobalKey:跨位置定位和保留子树
GlobalKey 的作用范围是整个 Widget 树,而不是某个父节点的子列表。
最常见的使用方式是访问 FormState:
import 'package:flutter/material.dart';
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final formKey = GlobalKey<FormState>();
final nameController = TextEditingController();
@override
void dispose() {
nameController.dispose();
super.dispose();
}
void _submit() {
final formState = formKey.currentState;
if (formState == null) {
// Form 尚未挂载,或已经从树中移除。
return;
}
if (!formState.validate()) {
return;
}
final name = nameController.text.trim();
debugPrint('提交:$name');
}
@override
Widget build(BuildContext context) {
return Form(
key: formKey,
child: Column(
children: [
TextFormField(
controller: nameController,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入姓名';
}
return null;
},
),
ElevatedButton(
onPressed: _submit,
child: const Text('提交'),
),
],
),
);
}
}
这里有几个生命周期条件:
formKey.currentState
可能为 null,因为对应的 Form 可能尚未挂载,或者已经被移除。不能无条件使用:
formKey.currentState!.validate()
除非调用时机已经由页面生命周期保证。
8.1 GlobalKey 必须稳定保存
正确做法是把 Key 放在 State 的字段中:
class _PageState extends State<Page> {
final formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(key: formKey, child: ...);
}
}
错误做法是在 build 中创建:
@override
Widget build(BuildContext context) {
final formKey = GlobalKey<FormState>();
return Form(key: formKey, child: ...);
}
每次 build 都会产生新的 GlobalKey,结果包括:
旧 Form:使用 GlobalKey A
重新 build:使用 GlobalKey B
A != B
-> 不能继续使用旧 FormState
-> currentState 可能重新指向新实例或暂时为空
如果需要在页面外部持有 GlobalKey,也应确保它的生命周期覆盖目标组件,并且同一时间只挂载一次。
8.2 GlobalKey 的重复使用
同一个 GlobalKey 不能同时出现在 Widget 树的两个位置:
final key = GlobalKey();
Column(
children: [
Form(key: key, child: const Text('左')),
Form(key: key, child: const Text('右')),
],
)
这会触发 Flutter 的重复 GlobalKey 断言,常见错误信息包含:
A GlobalKey was used multiple times inside one widget's child list.
原因不是两个 Form “内容相同”,而是同一个全局身份同时绑定了两个挂载位置。
在调试模式下,Flutter 会对这类错误进行断言检查。即使某些发布模式下不会立即抛出同样的断言,也不能依赖未定义或异常的树匹配结果。
9. GlobalKey 如何保留被移动子树的 State
LocalKey 主要帮助同一父节点的子列表匹配。GlobalKey 还可以让一个带状态的子树从一个位置移动到另一个位置。
概念上的更新过程如下:
旧位置中的 GlobalKey 子树
-> 从旧父节点脱离
-> 进入 inactive 状态
-> 在同一更新周期中于新位置找到相同 GlobalKey
-> 重新挂载到新父节点
-> 子树中的 State 保留
因此,如果把一个包含输入框、滚动位置或动画控制器的子树移动到另一个父节点,并且使用同一个 GlobalKey,框架可以保留其状态。
但这不是普通布局重排的首选机制。GlobalKey 重新挂载子树可能触发:
- 子树及其后代的
deactivate; - 依赖的 InheritedWidget 重新检查;
- 更大的更新范围;
- 复杂的生命周期顺序;
- 因 Key 生命周期错误导致的状态丢失。
它更适合少量、明确的跨位置移动,而不是给列表每一项都分配 GlobalKey。
10. Key 不会自动持久化任何状态
Key 只影响 Widget 树内的匹配。它不会把状态写入磁盘,也不会跨越所有生命周期边界保存状态。
下面这些边界需要区分:
10.1 普通 rebuild
父组件 setState
-> 子 Widget Key 相同
-> Element 复用
-> State 保留
这是 Key 最常见的作用。
10.2 子节点从树中删除
if (visible) {
return const StatefulPanel(key: ValueKey('panel'));
}
return const SizedBox.shrink();
当 visible 从 true 变成 false 时,StatefulPanel 被移除。对应 State 最终会执行 dispose。Key 不会让已经删除的 State 自动永久保存。
10.3 路由切换
切换到另一个页面时,原页面是否保留,取决于 Navigator、路由栈以及页面本身的结构。一个页面中的 ValueKey 不能让它跨路由自动恢复。
10.4 热重载和热重启
热重载通常尝试保留现有 Element 和 State,但代码结构变化可能导致匹配失败;热重启会重新启动 Dart 运行时,内存中的 State 通常全部丢失。Key 不能替代持久化方案。
10.5 进程被系统杀死
Android、iOS 或桌面系统终止进程后,Key 不会保存任何数据。需要恢复的数据必须写入本地数据库、文件、偏好设置或其他持久化存储。
11. PageStorageKey 与滚动位置保存
PageStorageKey 是一个用于配合 PageStorage 保存页面局部状态的 Key。滚动组件可以利用它保存滚动位置,例如:
ListView.builder(
key: const PageStorageKey<String>('article-list'),
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(title: Text('文章 $index'));
},
)
这里涉及两种不同机制:
普通 ValueKey:
帮助 Widget 与 Element 匹配,保留 State
PageStorageKey:
在 PageStorage 中使用 Key 作为地址,保存特定的页面状态
因此,PageStorageKey 不是“更强的 ValueKey”。它仍然是局部 Key,同时具有与 PageStorage 协作的语义。
它不能保证以下情况中的滚动位置始终恢复:
- PageStorage 不在预期的祖先范围内;
- Key 改变;
- 页面被完全销毁且没有保存到持久化存储;
- 使用了不同的滚动控制器或改变了滚动组件结构。
12. Sliver 中的 KeepAlive 与 Key 的区别
在长列表、ListView、GridView 和 Sliver 中,离开可视区域的子项通常可能被回收,以降低内存占用。此时需要区分:
Key:
解决节点身份匹配问题
KeepAlive:
请求框架在子项离开可视区域后仍保留其 Element/State
PageStorage:
保存某些可恢复的页面状态,例如滚动偏移
例如:
class EditableItem extends StatefulWidget {
const EditableItem({
required this.id,
super.key,
});
final int id;
@override
State<EditableItem> createState() => _EditableItemState();
}
class _EditableItemState extends State<EditableItem>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return TextField(
decoration: InputDecoration(labelText: '项目 ${widget.id}'),
);
}
}
super.build(context) 在使用 AutomaticKeepAliveClientMixin 时是必要的,因为该 Mixin 需要在构建过程中向祖先报告 KeepAlive 状态。
如果列表项只是离开屏幕后又被重新构建,稳定 Key 可以帮助它与合适的旧节点匹配;但如果框架已经因为 Sliver 回收而销毁了节点,Key 本身不会凭空恢复其中的控制器或 State。需要 KeepAlive 或把数据显式提升到列表外部。
13. 状态保留的完整判断路径
遇到“列表项状态错位”或“页面返回后输入内容丢失”时,可以按照以下顺序分析:
第一步:组件是否仍在同一个 Widget 树中
如果节点已经被移除,先确认它是否真的应该保留。Key 不能阻止 dispose。
第二步:Widget 类型是否改变
以下两个 Widget 不能直接通过同一个 Key 互相更新:
Text(key: const ValueKey('x'), data: 'A')
Container(key: const ValueKey('x'), child: ...)
因为它们的 runtimeType 不同。
第三步:Key 是否相等
比较的是:
oldWidget.key == newWidget.key
而不是:
oldWidget.key.toString()
也不是“看起来用了同一个数字”。
第四步:Key 的值是否稳定
检查:
- 是否使用了索引;
- 是否使用了每次 build 新建的
UniqueKey; - 是否使用了会改变
==或hashCode的对象; ObjectKey包装的对象实例是否被替换;- GlobalKey 是否在 build 中重新创建。
第五步:父节点是否允许这种匹配
LocalKey 只在对应父节点的子列表中有效。把 Key 从一个父节点移动到另一个父节点,不应简单假设它一定能保留 State;需要明确的跨位置迁移时才考虑 GlobalKey。
第六步:是否是列表回收,而不是匹配失败
如果是 Sliver 中的离屏子项,状态丢失可能来自回收策略,而不是 Key 错误。此时要检查 KeepAlive、数据模型和控制器的归属。
14. 常见失败表现与原因
14.1 输入框内容跑到了另一行
常见原因是:
key: ValueKey(index)
或者完全没有 Key,而列表发生了插入、删除、排序。
修复方式是:
key: ValueKey(item.id)
并确认 item.id 真正代表业务身份。
14. 每次刷新后 State 都重置
可能原因:
key: UniqueKey()
每次构建都会产生不同 Key。
也可能是:
key: GlobalKey()
在 build 中重复创建 GlobalKey。
14. GlobalKey 报重复使用错误
检查是否把同一个字段传给了两个同时挂载的组件:
final sharedKey = GlobalKey();
全局 Key 必须唯一对应一个已挂载位置。
14. 使用 ObjectKey 后刷新数据状态丢失
如果数据刷新过程创建了新对象:
final newUser = User(id: oldUser.id);
则:
ObjectKey(oldUser) != ObjectKey(newUser)
如果业务上要求相同 ID 继续保留状态,应改用:
ValueKey(oldUser.id)
14. 使用相同 ValueKey 但状态仍不对
可能是 Key 值在同一个父节点下重复,也可能是组件类型改变,或者节点已经被不同父节点拆分。需要同时检查父节点边界、Widget 类型和列表结构,而不能只看 Key 文本。
15. 不同 Key 的选择规则
可以用下面的语义来做判断:
业务 ID 是身份
-> ValueKey(id)
对象实例本身是身份
-> ObjectKey(object)
明确要求每次都是新组件
-> UniqueKey()
需要从 Widget 树全局定位 State 或跨父节点移动子树
-> GlobalKey
需要配合 PageStorage 保存页面局部状态
-> PageStorageKey
典型示例:
// 推荐:稳定业务身份
ProductCard(key: ValueKey(product.id))
// 对象实例身份稳定时使用
EditorNodeWidget(key: ObjectKey(node))
// 强制替换子组件
AnimatedSwitcher(
child: Panel(key: UniqueKey()),
)
// 访问 FormState
final formKey = GlobalKey<FormState>();
Form(key: formKey, child: ...)
// 配合 PageStorage 保存列表滚动状态
ListView(key: const PageStorageKey('feed'))
不要为了“保险”给每个 Widget 都加 Key。Key 的价值不在于数量多,而在于它是否准确表达了组件身份。
16. 性能与架构取舍
ValueKey 和 ObjectKey 通常是轻量的局部匹配工具,特别适合列表子项。它们不会让 Widget 变成全局可访问对象。
GlobalKey 需要维护全局注册关系,并允许框架通过它定位 Element。对于少量表单、导航容器或确实需要跨位置迁移的子树,它很有用;对大型列表使用 GlobalKey,可能增加维护复杂度和更新成本,也更容易触发重复 Key 和生命周期问题。
如果只是想让按钮触发子组件动作,优先考虑:
父 State 持有数据和动作
-> 通过构造参数传给子组件
-> 子组件通过回调通知父组件
而不是立即创建 GlobalKey 访问子 State。GlobalKey 是一种树级命令式访问机制,不应替代清晰的数据流设计。
17. 平台差异
Key 属于 Flutter Framework 的 Widget、Element 和 State 机制,因此在 Android、iOS、Windows、macOS、Linux 和 Web 上,ValueKey、ObjectKey、GlobalKey 的匹配规则没有平台专属版本。
但平台生命周期会影响“状态保留”这个更大的问题:
- 移动端可能在后台后被系统杀死;
- 桌面端窗口关闭通常意味着进程或页面销毁;
- Web 刷新浏览器会重新启动 Dart 应用;
- 浏览器前进后退可能涉及路由和页面重建;
- 不同平台的可见区域和列表回收时机可能不同。
因此,Key 只能保证 Flutter 进程内、Widget 树结构允许时的节点匹配,不能代替跨进程、跨刷新和跨设备的状态恢复。
18. 最小验证方式
可以通过日志观察 State 是否被复用:
class Probe extends StatefulWidget {
const Probe({super.key});
@override
State<Probe> createState() => _ProbeState();
}
class _ProbeState extends State<Probe> {
@override
void initState() {
super.initState();
debugPrint('initState: $hashCode');
}
@override
void dispose() {
debugPrint('dispose: $hashCode');
super.dispose();
}
@override
Widget build(BuildContext context) {
return const SizedBox();
}
}
在父组件多次 setState 时:
如果 Key 和 Widget 类型匹配:
通常只看到一次 initState
不会因为普通 rebuild 立即 dispose
如果每次使用新的 UniqueKey:
可能反复看到 dispose 和 initState
这个实验只能观察生命周期,不能证明所有复杂列表 diff 行为。对于列表,还应同时打印业务 ID、Key 和 State 的 hashCode,确认状态究竟跟随了业务项、位置还是被重新创建。
结语:Key 表达的是身份,状态保留依赖匹配
Flutter Key 的核心不是“给 Widget 加一个编号”,而是明确表达:
新旧 Widget 是否代表同一个挂载节点。
ValueKey 用值相等性表达业务身份,适合稳定 ID;ObjectKey 用对象实例身份表达节点归属;UniqueKey 故意阻止复用;GlobalKey 提供全局定位和跨位置保留子树的能力。状态之所以保留,是因为 Key 和 Widget 类型让新 Widget 匹配到了旧 Element,而不是因为 Key 自己存储了状态。
在列表中,应优先使用稳定业务 ID;在表单和少量跨位置场景中谨慎使用 GlobalKey;在需要离屏状态或滚动恢复时,分别考虑 KeepAlive 和 PageStorage。只要先确定“身份是什么”,再选择对应的 Key 类型,绝大多数状态错位和无故重置问题都可以沿着 Widget、Element、State 的匹配链路准确定位。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 渲染流水线:Widget、Element、RenderObject、Layer 和帧
- 下一篇:Flutter BuildContext:树位置、Inherited 依赖、异步间隙和查找
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论