Flutter : 关于 Key

写在前面

Key是一种对 WidgetElementSemanticsNode的标识符。Key是个抽象类,分为 LocalKeyGlobalKey两种。

它们更细的分类大致如下:

Key
LocalKey
GlobalKey
ValueKey
PageStorageKey
ObjectKey
UniqueKey
LabeledGlobalKey
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用于做进一步的标识。

MyBox
MyBoxElement
MyBox
MyBoxElement
MyBox
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;
    }());
  }
}
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值