寫在前面
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;
}());
}
}
複製程式碼