写在前面
Key
是一种对 Widget
、Element
和 SemanticsNode
的标识符。Key
是个抽象类,分为 LocalKey
和 GlobalKey
两种。
它们更细的分类大致如下:
graph LR
A[Key] --> B(LocalKey)
A --> C(GlobalKey)
B-->D(ValueKey)
D-->E(PageStorageKey)
B-->F(ObjectKey)
B-->G(UniqueKey)
C-->H(LabeledGlobalKey)
C-->I(GlobalObjectKey)
内容
创建一个 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
用于做进一步的标识。
graph LR
A[MyBox] --> B(MyBoxElement)
C[MyBox] --> D(MyBoxElement)
E[MyBox] --> F(MyBoxElement)
也就是说,你调换第一个和第二个的位置,跟你不改变位置,然后分别改变它们的 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;
}());
}
}