Flutter : 關於 Key

JDChi發表於2021-07-03

寫在前面

Key是一種對 WidgetElementSemanticsNode的識別符號。Key是個抽象類,分為 LocalKeyGlobalKey兩種。

它們更細的分類大致如下:

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)

內容

建立一個 MyBoxStatefulWidget用於演示:

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的時候,是有一個機制對其進行管理。

WidgetElement被呼叫 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裡。

在從樹上移除的時候,則會呼叫Elementunmount方法,然後呼叫到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);
  }
}
複製程式碼

那麼在哪裡檢查呢? WidgetsBindingdrawFrame方法被呼叫的時候,會呼叫BuildOwnerfinalizeTree()方法,在 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;
    }());
  }
}
複製程式碼

相關文章