写在前面
Key
是一种对 Widget
、Element
和 SemanticsNode
的标识符。Key
是个抽象类,分为 LocalKey
和 GlobalKey
两种。
它们更细的分类大致如下:
内容
创建一个 MyBox
的StatefulWidget
用于演示:
class MyBox extends StatefulWidget {
final Color color;
final Key key;
MyBox({this.color, this.key}) : super(key: key);
@override
_MyBoxState createState() => _MyBoxState();
}
class _MyBoxState extends State<MyBox> {
num number = 0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
number++;
});
},
child: Container(
alignment: Alignment.center,
width: 60,
height: 60,
color: widget.color,
child: Text(
number.toString(),
style: TextStyle(fontSize: 20),
),
),
);
}
}
然后创建三个出来,并点击改变一些数据:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
children: [
MyBox(color: Colors.yellow),
MyBox(color: Colors.blue),
MyBox(color: Colors.green)
],
),
),
),
);
}
}
然后现在调换第一个和第二个的位置,并点击 Hot Reload,就会出现以下的效果:
可以发现颜色对调了,但里面的数字却没有发生改变。
在 Widget 里有个 canUpdate()
方法,用于判断是否更新 Widget
@immutable
abstract class Widget extends DiagnosticableTree {
...
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
...
}
在我们的这个场景里,由于前后没有 Key
,所以Key
这个条件可以忽略,接着由于几个 MyBox
不管你怎么换位置,Flutter 都只能看到在 Element Tree 的那个位置上,它们前后的 runtimeType
是一致的。所以对它来说,其实就还是原来的那个 Widget
,因为我们没有给它个Key
用于做进一步的标识。
也就是说,你调换第一个和第二个的位置,跟你不改变位置,然后分别改变它们的 color 值,其实是一样的。
在HotReload
下,StatefulWidget
下的 State
由于已经创建过了,就不会再重新创建,然后直接走 build()
方法,而 number 又是在 build()
方法外初始化,因此 number 还是原来的数据,而 color 由于从外部拿到是变了的,所以就导致这里颜色变了,但数字却没变。
当然,如果MyBox
用的是 StatelessWidget
,那就符合我们预期的效果了,因为它没有状态这东西。
所以,我们给这几个MyBox
分别加上Key
就可以实现我们想要的效果了。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
key: ValueKey(1),
children: [
// 加上 Key
// MyBox(color: Colors.yellow, key: ValueKey(1)),
// MyBox(color: Colors.blue, key: ValueKey(2)),
// MyBox(color: Colors.green, key: ValueKey(3))
// 调换位置
MyBox(color: Colors.blue, key: ValueKey(2)),
MyBox(color: Colors.yellow, key: ValueKey(1)),
MyBox(color: Colors.green, key: ValueKey(3))
],
),
),
),
);
}
}
一个例子了解 Key 的标识作用后,就来进一步了解下每种 Key 的作用。
LocalKey
LocalKey
是相对于GlobalKey
而言的,GlobalKey
需要在整个 app 里是唯一,而LocalKey
只要在同一个parent
下的Element
里是唯一的就行。
因为LocalKey
是个抽象类,我们用它的一个实现类来做示例就行,其它都一样。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(),
body: Column(
key: ValueKey(1),
children: [
Text("hello", key: ValueKey(1)),
Text("hello", key: ValueKey(2)),
Text("hello", key: ValueKey(3)),
],
),
),
);
}
}
在 Column
下的 children
里有三个 ValueKey
,其中有一个是ValueKey(1)
,而它们的 parent
也有一个ValueKey(1)
,这个是没有影响的,因为LocalKey
的唯一性只在它的同一级里。
这也是为什么说GlobalKey
比较耗性能的一个原因,因为要比较的话它需要跟整个 app 里的去比,而LocalKey
只在同一级里。
ValueKey
对于ValueKey
,它比较的是我们传进去的 value 值是否一致。
class ValueKey<T> extends LocalKey {
const ValueKey(this.value);
final T value;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType)
return false;
return other is ValueKey<T>
&& other.value == value;
}
...
PageStorageKey
PageStorageKey
是一种比较特别的 Key,是用于储存状态的场景下使用,但并不是说它可以这么做,而是要搭配PageStorage
这个Widget
使用,例如在ListView
使用了PageStorageKey
,那么其内部的实现会通过PageStorage
去获取到它,然后把它作为 Key,ListView
的滚动数据作为 value,把它们绑定起来后,就可以方便后续恢复数据。
相关内容可以看之前写过的一篇 Flutter: 当使用了PageStorageKey后发生了什么?
ObjectKey
ObjectKey
的话,则是比较我们传进去的对象是否一样,即传进去的对象是指向同一个内存地址的话,则认为是一致的。
class ObjectKey extends LocalKey {
const ObjectKey(this.value);
final Object? value;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType)
return false;
return other is ObjectKey
&& identical(other.value, value);
}
...
在 Dart 里比较对象是否一致是用identical()
方法
/// Check whether two references are to the same object.
external bool identical(Object? a, Object? b);
UniqueKey
UniqueKey
就没的比较了,它本身就是顾名思义唯一的。只能跟自身相等。
class UniqueKey extends LocalKey {
UniqueKey();
@override
String toString() => '[#${shortHash(this)}]';
}
GlobalKey
GlobalKey
之前说过,是用于在整个 app 里标识唯一的。所以就不能在树里面有两个 Widget
都拥有同一个 GlobalKey
了。
@optionalTypeArgs
abstract class GlobalKey<T extends State<StatefulWidget>> extends Key {
factory GlobalKey({ String? debugLabel }) => LabeledGlobalKey<T>(debugLabel);
const GlobalKey.constructor() : super.empty();
Element? get _currentElement => WidgetsBinding.instance!.buildOwner!._globalKeyRegistry[this];
BuildContext? get currentContext => _currentElement;
Widget? get currentWidget => _currentElement?.widget;
T? get currentState {
final Element? element = _currentElement;
if (element is StatefulElement) {
final StatefulElement statefulElement = element;
final State state = statefulElement.state;
if (state is T)
return state;
}
return null;
}
}
GlobalKey
好用但也要慎用。
好用
通过它的实现,我们可以看到如果我们用于标识StatefulWidget
,那么就可以访问到它的State
,进而操作State
里的属性或是方法等。
同样的可以获取到这个 Widget 的Context
还有Element
所持有的Widget
,进而获取更多的信息。就像我们常用的:
final GlobalKey box1Key = GlobalKey();
RenderBox box = box1Key.currentContext.findRenderObject();
// 尺寸
Size size = box.size;
// 屏幕上的位置
Offset offset = box.localToGlobal(Offset.zero);
假如说有两个页面重叠,我们想上面的页面调用到下面页面的某个GestureDetector
的方法,那就给下面的那个GestureDetector
一个 GlobalKey
,上面的页面就可以这么操作,就像隔空操作了它一样:
GestureDetector gestureDetector = gestureKey.currentWidget;
gestureDetector.onTap();
无 Context 页面跳转
我们一般使用Navigator
做页面跳转的时候,都会需要 Context
,那么借助GlobalKey
可以获取 State 这个,就可以实现无 Context 的页面跳转。
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey,
home: Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
children: [
TextButton(
onPressed: () {
navigatorKey.currentState.pushNamed("routeName");
},
child: Text("press")),
],
),
),
),
);
}
}
慎用
GlobalKey
在每次 build 的时候,如果都去重新创建它,由于它的全局唯一性,意味着它会扔掉旧的 Key 所持有的子树状态然后创建一个新的子树给这个新的 Key。
性能损耗是一方面,有时也会有一些意想不到的效果。比方说使用GestureDetector
,如果每次 build 都给它个新的 GlobalKey,那么它就可能无法跟踪正在执行的手势了。
所以最好是让State
持有它,并在build()
方法外面初始化它,例如State.initState()
里。
关于 app 里唯一
我们说 GlobalKey
是用于在 app 范围里唯一的标识,那是不是给了一个Widget
就不能给另一个Widget
呢?
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final GlobalKey boxGlobalKey = GlobalKey();
bool isChanged = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
children: [
TextButton(
onPressed: () {
setState(() {
isChanged = !isChanged;
});
},
child: Text("press")),
isChanged
? MyBox(color: Colors.red, key: boxGlobalKey)
: MyBox(color: Colors.blue, key: boxGlobalKey)
],
),
),
),
);
}
}
当我们点击按钮的时候,是可以正常切换,没有报错,并且 boxGlobalKey
是可以给另外一个 Widget
的。
也就是说,并不是在整个 app 的生命周期里唯一,而是在同一帧的树里是唯一。
当我们使用GlobalKey
的时候,是有一个机制对其进行管理。
当 Widget
的 Element
被调用 mount
的方法用于挂载在树上的时候,会调用 BuildOwner
的_registerGlobalKey()
方法:
abstract class Element extends DiagnosticableTree implements BuildContext {
...
void mount(Element? parent, Object? newSlot) {
...
final Key? key = widget.key;
if (key is GlobalKey) {
owner!._registerGlobalKey(key, this);
}
...
}
...
}
class BuildOwner {
final Map<GlobalKey, Element> _globalKeyRegistry = <GlobalKey, Element>{};
void _registerGlobalKey(GlobalKey key, Element element) {
...
_globalKeyRegistry[key] = element;
}
}
会把这个GlobalKey
做为 Key,当前Element
作为 value,加入到_globalKeyRegistry
里。
在从树上移除的时候,则会调用Element
的unmount
方法,然后调用到BuildOwner
的_unregisterGlobalKey()
方法用于移除。
abstract class Element extends DiagnosticableTree implements BuildContext {
...
@mustCallSuper
void unmount() {
...
final Key? key = _widget.key;
if (key is GlobalKey) {
owner!._unregisterGlobalKey(key, this);
}
}
...
}
class BuildOwner {
final Map<GlobalKey, Element> _globalKeyRegistry = <GlobalKey, Element>{};
void _unregisterGlobalKey(GlobalKey key, Element element) {
....
if (_globalKeyRegistry[key] == element)
_globalKeyRegistry.remove(key);
}
}
那么在哪里检查呢?
WidgetsBinding
的drawFrame
方法被调用的时候,会调用BuildOwner
的finalizeTree()
方法,在 Debug 模式下,这个方法会对重复的 GlobalKey
进行检查。
mixin WidgetsBinding{
@override
void drawFrame() {
try {
...
buildOwner!.finalizeTree();
}
...
}
}
class BuildOwner {
void finalizeTree() {
Timeline.startSync('Finalize tree', arguments: timelineArgumentsIndicatingLandmarkEvent);
try {
lockState(() {
_inactiveElements._unmountAll(); // this unregisters the GlobalKeys
});
assert(() {
try {
_debugVerifyGlobalKeyReservation();
_debugVerifyIllFatedPopulation();
if (_debugElementsThatWillNeedToBeRebuiltDueToGlobalKeyShenanigans != null &&
_debugElementsThatWillNeedToBeRebuiltDueToGlobalKeyShenanigans!.isNotEmpty) {
final Set<GlobalKey> keys = HashSet<GlobalKey>();
for (final Element element in _debugElementsThatWillNeedToBeRebuiltDueToGlobalKeyShenanigans!.keys) {
if (element._lifecycleState != _ElementLifecycle.defunct)
keys.addAll(_debugElementsThatWillNeedToBeRebuiltDueToGlobalKeyShenanigans![element]!);
}
if (keys.isNotEmpty) {
final Map<String, int> keyStringCount = HashMap<String, int>();
for (final String key in keys.map<String>((GlobalKey key) => key.toString())) {
if (keyStringCount.containsKey(key)) {
keyStringCount.update(key, (int value) => value + 1);
} else {
keyStringCount[key] = 1;
}
}
final List<String> keyLabels = <String>[];
keyStringCount.forEach((String key, int count) {
if (count == 1) {
keyLabels.add(key);
} else {
keyLabels.add('$key ($count different affected keys had this toString representation)');
}
});
final Iterable<Element> elements = _debugElementsThatWillNeedToBeRebuiltDueToGlobalKeyShenanigans!.keys;
final Map<String, int> elementStringCount = HashMap<String, int>();
for (final String element in elements.map<String>((Element element) => element.toString())) {
if (elementStringCount.containsKey(element)) {
elementStringCount.update(element, (int value) => value + 1);
} else {
elementStringCount[element] = 1;
}
}
final List<String> elementLabels = <String>[];
elementStringCount.forEach((String element, int count) {
if (count == 1) {
elementLabels.add(element);
} else {
elementLabels.add('$element ($count different affected elements had this toString representation)');
}
});
assert(keyLabels.isNotEmpty);
final String the = keys.length == 1 ? ' the' : '';
final String s = keys.length == 1 ? '' : 's';
final String were = keys.length == 1 ? 'was' : 'were';
final String their = keys.length == 1 ? 'its' : 'their';
final String respective = elementLabels.length == 1 ? '' : ' respective';
final String those = keys.length == 1 ? 'that' : 'those';
final String s2 = elementLabels.length == 1 ? '' : 's';
final String those2 = elementLabels.length == 1 ? 'that' : 'those';
final String they = elementLabels.length == 1 ? 'it' : 'they';
final String think = elementLabels.length == 1 ? 'thinks' : 'think';
final String are = elementLabels.length == 1 ? 'is' : 'are';
// TODO(jacobr): make this error more structured to better expose which widgets had problems.
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Duplicate GlobalKey$s detected in widget tree.'),
// TODO(jacobr): refactor this code so the elements are clickable
// in GUI debug tools.
ErrorDescription(
'The following GlobalKey$s $were specified multiple times in the widget tree. This will lead to '
'parts of the widget tree being truncated unexpectedly, because the second time a key is seen, '
'the previous instance is moved to the new location. The key$s $were:\n'
'- ${keyLabels.join("\n ")}\n'
'This was determined by noticing that after$the widget$s with the above global key$s $were moved '
'out of $their$respective previous parent$s2, $those2 previous parent$s2 never updated during this frame, meaning '
'that $they either did not update at all or updated before the widget$s $were moved, in either case '
'implying that $they still $think that $they should have a child with $those global key$s.\n'
'The specific parent$s2 that did not update after having one or more children forcibly removed '
'due to GlobalKey reparenting $are:\n'
'- ${elementLabels.join("\n ")}'
'\nA GlobalKey can only be specified on one widget at a time in the widget tree.',
),
]);
}
}
} finally {
_debugElementsThatWillNeedToBeRebuiltDueToGlobalKeyShenanigans?.clear();
}
return true;
}());
} catch (e, stack) {
// Catching the exception directly to avoid activating the ErrorWidget.
// Since the tree is in a broken state, adding the ErrorWidget would
// cause more exceptions.
_debugReportException(ErrorSummary('while finalizing the widget tree'), e, stack);
} finally {
Timeline.finishSync();
}
}
void _debugVerifyGlobalKeyReservation() {
assert(() {
final Map<GlobalKey, Element> keyToParent = <GlobalKey, Element>{};
_debugGlobalKeyReservations.forEach((Element parent, Map<Element, GlobalKey> childToKey) {
// We ignore parent that are unmounted or detached.
if (parent._lifecycleState == _ElementLifecycle.defunct || parent.renderObject?.attached == false)
return;
childToKey.forEach((Element child, GlobalKey key) {
// If parent = null, the node is deactivated by its parent and is
// not re-attached to other part of the tree. We should ignore this
// node.
if (child._parent == null)
return;
// It is possible the same key registers to the same parent twice
// with different children. That is illegal, but it is not in the
// scope of this check. Such error will be detected in
// _debugVerifyIllFatedPopulation or
// _debugElementsThatWillNeedToBeRebuiltDueToGlobalKeyShenanigans.
if (keyToParent.containsKey(key) && keyToParent[key] != parent) {
// We have duplication reservations for the same global key.
final Element older = keyToParent[key]!;
final Element newer = parent;
final FlutterError error;
if (older.toString() != newer.toString()) {
error = FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Multiple widgets used the same GlobalKey.'),
ErrorDescription(
'The key $key was used by multiple widgets. The parents of those widgets were:\n'
'- ${older.toString()}\n'
'- ${newer.toString()}\n'
'A GlobalKey can only be specified on one widget at a time in the widget tree.',
),
]);
} else {
error = FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Multiple widgets used the same GlobalKey.'),
ErrorDescription(
'The key $key was used by multiple widgets. The parents of those widgets were '
'different widgets that both had the following description:\n'
' ${parent.toString()}\n'
'A GlobalKey can only be specified on one widget at a time in the widget tree.',
),
]);
}
// Fix the tree by removing the duplicated child from one of its
// parents to resolve the duplicated key issue. This allows us to
// tear down the tree during testing without producing additional
// misleading exceptions.
if (child._parent != older) {
older.visitChildren((Element currentChild) {
if (currentChild == child)
older.forgetChild(child);
});
}
if (child._parent != newer) {
newer.visitChildren((Element currentChild) {
if (currentChild == child)
newer.forgetChild(child);
});
}
throw error;
} else {
keyToParent[key] = parent;
}
});
});
_debugGlobalKeyReservations.clear();
return true;
}());
}
void _debugVerifyIllFatedPopulation() {
assert(() {
Map<GlobalKey, Set<Element>>? duplicates;
for (final Element element in _debugIllFatedElements) {
if (element._lifecycleState != _ElementLifecycle.defunct) {
assert(element != null);
assert(element.widget != null);
assert(element.widget.key != null);
final GlobalKey key = element.widget.key! as GlobalKey;
assert(_globalKeyRegistry.containsKey(key));
duplicates ??= <GlobalKey, Set<Element>>{};
// Uses ordered set to produce consistent error message.
final Set<Element> elements = duplicates.putIfAbsent(key, () => LinkedHashSet<Element>());
elements.add(element);
elements.add(_globalKeyRegistry[key]!);
}
}
_debugIllFatedElements.clear();
if (duplicates != null) {
final List<DiagnosticsNode> information = <DiagnosticsNode>[];
information.add(ErrorSummary('Multiple widgets used the same GlobalKey.'));
for (final GlobalKey key in duplicates.keys) {
final Set<Element> elements = duplicates[key]!;
// TODO(jacobr): this will omit the '- ' before each widget name and
// use the more standard whitespace style instead. Please let me know
// if the '- ' style is a feature we want to maintain and we can add
// another tree style that supports it. I also see '* ' in some places
// so it would be nice to unify and normalize.
information.add(Element.describeElements('The key $key was used by ${elements.length} widgets', elements));
}
information.add(ErrorDescription('A GlobalKey can only be specified on one widget at a time in the widget tree.'));
throw FlutterError.fromParts(information);
}
return true;
}());
}
}