在同一个父 Widget 下,子 Widget 只是位置发生了变动(重排序) ,永远应该优先且几乎总是使用 LocalKey
。
使用 GlobalKey
在这种场景下虽然也能工作,但属于"杀鸡用牛刀",会带来不必要的性能开销和更高的复杂度。
让我们通过一个例子来详细解释。
场景:一个可重排序的列表
假设我们有一个 Column
,它的子节点是一个 StatefulWidget
列表,我们可以通过按钮来改变它们的顺序。
dart
class SortableList extends StatefulWidget {
const SortableList({super.key});
@override
State<SortableList> createState() => _SortableListState();
}
class _SortableListState extends State<SortableList> {
// 数据源
final List<String> _items = ['Item A', 'Item B', 'Item C'];
void _swapItems() {
setState(() {
// 交换 A 和 C
final temp = _items[0];
_items[0] = _items[2];
_items[2] = temp;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('LocalKey vs GlobalKey')),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 根据数据源生成 Widget 列表
for (final item in _items)
// 这里是关键:我们应该用什么 Key?
StatefulItemTile(
key: /* ??? */, // <--- 关键点
label: item,
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _swapItems,
child: const Icon(Icons.swap_vert),
),
);
}
}
// 一个带状态的瓦片,比如它有自己的随机颜色
class StatefulItemTile extends StatefulWidget {
final String label;
const StatefulItemTile({super.key, required this.label});
@override
State<StatefulItemTile> createState() => _StatefulItemTileState();
}
class _StatefulItemTileState extends State<StatefulItemTile> {
// 每个瓦片在创建时都有一个随机颜色,这个颜色就是它的 State
late final Color _color;
@override
void initState() {
super.initState();
_color = Colors.primaries[Random().nextInt(Colors.primaries.length)];
}
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(8),
padding: const EdgeInsets.all(16),
color: _color,
child: Text(widget.label, style: const TextStyle(color: Colors.white, fontSize: 20)),
);
}
}
现在,我们来分析 key: /* ??? */
这里用不同 Key 的情况。
方案一:使用 LocalKey
(正确且高效的方式)
我们应该使用一个能够唯一标识数据项的 LocalKey
,比如 ValueKey
。
dart
key: ValueKey(item), // item 是 'Item A', 'Item B' 等
工作流程:
- 初始状态 :
_items
是['A', 'B', 'C']
。- Widget 树是
[Tile(key: ValueKey('A')), Tile(key: ValueKey('B')), Tile(key: ValueKey('C'))]
。 Column
的 Element 会创建三个子 Element,每个都有自己的随机颜色 State。
- Widget 树是
- 点击按钮后 :
_items
变成['C', 'B', 'A']
。build
方法生成新的 Widget 树:[Tile(key: ValueKey('C')), Tile(key: ValueKey('B')), Tile(key: ValueKey('A'))]
。
Column
Element 的比对过程 :- 它看到新的第一个 Widget 是
Tile(key: ValueKey('C'))
。 - 它会在旧的兄弟 Element 中寻找
ValueKey('C')
。 - 找到了!是原来在索引 2 的那个 Element。
- 结果:它将这个 Element(连同它内部的颜色 State)移动到索引 0 的位置。
- 同理,
ValueKey('B')
对应的 Element 保持在原位,ValueKey('A')
对应的 Element 被移动到索引 2。
- 它看到新的第一个 Widget 是
- 结论 :三个
StatefulItemTile
的状态(颜色)都被完美保留了,并且没有创建新的 Element,只是移动了它们。这是最高效的方式。
方案二:使用 GlobalKey
(能工作但没必要)
如果我们为每个 item 预先创建一个 GlobalKey
并存储起来。
dart
// 在 State 中提前创建
final Map<String, GlobalKey> _itemKeys = {
'Item A': GlobalKey(),
'Item B': GlobalKey(),
'Item C': GlobalKey(),
};
// 在 build 中使用
key: _itemKeys[item]!,
工作流程: 这个流程和 LocalKey
非常相似。当 Widget 重排序时,Column
的 Element 依然会去匹配 Key。因为 GlobalKey
也能在兄弟节点中被找到,所以它也能正确地移动 Element 并保留状态。
那为什么不推荐?
- 性能开销 :
GlobalKey
需要在全局的 Map 中注册和查找,这个开销比LocalKey
在小范围的兄弟节点中查找要大。对于一个长列表,这种开销会累积。 - 管理复杂性 :你需要手动创建并管理这些
GlobalKey
的生命周期,比如用一个Map
存起来。而LocalKey
是在build
方法中即时创建的,用完即弃,非常轻量。 - 误用风险 :
GlobalKey
的设计初衷是解决跨父级 移动和外部访问这两个特定问题。在同级重排序这种简单场景下使用它,违背了它的设计意图,可能会让其他维护者感到困惑。
什么时候会出现新旧 Key 不同的情况?
这是一个很好的问题,它关联到 Element
是否会被销毁并重建。
一个 Element
在更新时,它会比较新的 Widget 配置和它自己当前引用的旧 Widget 配置。
canUpdate(newWidget, oldWidget)
这个内部函数会检查 newWidget.runtimeType == oldWidget.runtimeType && newWidget.key == oldWidget.key
。
新旧 Key 不同的情况主要发生在以下场景,并且会导致 Element
重建:
场景 1:无 Key 或 Key 错误,导致位置与身份混淆
这是最常见的情况。回到上面的例子,如果我们不提供 Key:
dart
key: null, // 或者干脆不写
- 初始状态 :
['A', 'B', 'C']
->[Tile(A), Tile(B), Tile(C)]
- 交换后 :
['C', 'B', 'A']
->[Tile(C), Tile(B), Tile(A)]
- 比对过程 (按索引) :
- 索引 0 :新 Widget 是
Tile(C)
,旧 Element 是ElementA
。runtimeType
相同,key
都是null
,也相同。canUpdate
返回true
! 于是,Flutter 认为你只是想把ElementA
的内容更新成Tile(C)
的样子。ElementA
被保留,但它的label
属性从 'A' 变成了 'C'。它的颜色 State (A 的颜色) 没变! - 索引 1 :新 Widget 是
Tile(B)
,旧 Element 是ElementB
。同理,ElementB
被更新,label
没变,颜色也没变。 - 索引 2 :新 Widget 是
Tile(A)
,旧 Element 是ElementC
。ElementC
被更新,label
变成 'A',但保留了 C 的颜色。
- 索引 0 :新 Widget 是
- 结果 :UI 上显示
C, B, A
,但是第一个瓦片的颜色是原来 A 的颜色,第三个瓦片的颜色是原来 C 的颜色。状态错乱了!
场景 2:使用了每次都会变的 Key (如 UniqueKey
)
UniqueKey
是 LocalKey
的一种,但它的特点是每次创建都是一个全新的、唯一的对象。
dart
key: UniqueKey(),
如果你在 build
方法里这么写,那么每次 setState
之后,build
方法重新执行,会生成一套全新的 UniqueKey
。
Column
的 Element 在比对时,会发现新的 Widget 列表里的所有 Key,在旧的 Element 列表里一个都找不到(因为UniqueKey
的==
比较是基于对象身份的)。- 结果:所有的旧 Element 都会被销毁,并为新的 Widget 创建全新的 Element 和 State。每次重排都会导致所有瓦片的颜色随机变化。
场景 3:数据源的 ID 发生了变化
假设你的数据源是对象,你用对象的 id
作为 ValueKey
。
dart
// class MyItem { final int id; final String name; }
key: ValueKey(item.id),
如果因为某种操作,你替换了数据源中的一个对象,比如把 MyItem(id: 1, ...)
换成了 MyItem(id: 101, ...)
。那么在下一次 build
时:
- Flutter 会看到一个新的
ValueKey(101)
。 - 它在旧的 Element 列表中找不到这个 Key。
- 同时,旧的
ValueKey(1)
在新的 Widget 列表中也消失了。 - 结果 :持有
ValueKey(1)
的旧 Element 会被销毁,并为ValueKey(101)
创建新的 Element。这是符合预期的行为,因为从数据的角度看,这确实是一个全新的项。
总结
- 同父 Widget 下的子 Widget 重排序 :用
LocalKey
(通常是ValueKey
或ObjectKey
),它轻量、高效、符合设计意图。 - 新旧 Key 不同会导致 Element 重建 ,这在以下情况会发生:
- 你没有提供 Key,导致 Flutter 错误地按位置匹配,并用新 Widget 的数据更新了错误的旧 Element(Key 都是
null
,所以 Key 相同)。 - 你提供的 Key 是不稳定的,比如
UniqueKey()
,导致 Flutter 认为所有 Widget 都是全新的。 - 你的数据源的唯一标识符本身发生了变化,导致 Key 自然也发生了变化,这是符合逻辑的重建。
- 你没有提供 Key,导致 Flutter 错误地按位置匹配,并用新 Widget 的数据更新了错误的旧 Element(Key 都是