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;
  • 避免状态跟随位置错误移动。

ValueKeyObjectKeyUniqueKey 都属于 LocalKey。

3.2 GlobalKey

GlobalKey 可以在整个 Widget 树范围内定位对应的 Element。它还支持在树中的位置变化时保留整个子树的 State。

它适合:

  • 访问 FormState
  • 在少量场景下获取某个组件的 StateBuildContext
  • 需要把一个带状态的子树从一个父节点移动到另一个父节点。

但它的能力更强,代价也更高。多数列表和普通组件不应该使用 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 必须满足:

  1. 同一业务对象在列表生命周期内保持不变;
  2. 不同业务对象不会拥有同一个 ID;
  3. 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.idanotherUser.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,因为每个 TodoEditorPadding 都属于各自不同的 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(),
        ),
      ),
    );
  }
}

此时外部传给 TodoEditorValueKey(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();

visibletrue 变成 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 的区别

在长列表、ListViewGridView 和 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. 性能与架构取舍

ValueKeyObjectKey 通常是轻量的局部匹配工具,特别适合列表子项。它们不会让 Widget 变成全局可访问对象。

GlobalKey 需要维护全局注册关系,并允许框架通过它定位 Element。对于少量表单、导航容器或确实需要跨位置迁移的子树,它很有用;对大型列表使用 GlobalKey,可能增加维护复杂度和更新成本,也更容易触发重复 Key 和生命周期问题。

如果只是想让按钮触发子组件动作,优先考虑:

父 State 持有数据和动作
    -> 通过构造参数传给子组件
    -> 子组件通过回调通知父组件

而不是立即创建 GlobalKey 访问子 State。GlobalKey 是一种树级命令式访问机制,不应替代清晰的数据流设计。


17. 平台差异

Key 属于 Flutter Framework 的 Widget、Element 和 State 机制,因此在 Android、iOS、Windows、macOS、Linux 和 Web 上,ValueKeyObjectKeyGlobalKey 的匹配规则没有平台专属版本。

但平台生命周期会影响“状态保留”这个更大的问题:

  • 移动端可能在后台后被系统杀死;
  • 桌面端窗口关闭通常意味着进程或页面销毁;
  • 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 官方文档重新梳理;正文与示例由 WR BLOG 编写。