Flutter : 关于 Key

1,364 阅读5分钟

写在前面

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;
    }());
  }
}