Flutter 最熟悉的陌生人之 Key 全面解析

2,315 阅读6分钟

Key 是作为一个 Flutter 初学者最让人迷惑的东西,它无处不在,但又难以理解,可谓是最熟悉的陌生人,今天我们一起通过几个例子来系统的学习 Key 的使用和底层原理,阅读本文大概需要 2 分钟 [认真脸]。未经授权,禁止转载!

前言

回顾一下,上篇我们讲完 Flutter Widget 体系架构,其中在 widget 比较过程中涉及到新旧 wiget 的 key 的比较,比较结果直接决定了是重新 inflateWidget 还是直接将 widget 更新。

我们需要再次把祖传流程图拿出来:

rebuild流程

标红部分为 Widget.canUpadte 比较,具体比较代码:

canUpdate

  • canUpdate 返回 true, 则表示 widget 可以复用,如果是 Stateful 的,那么同时状态可以保留。
  • canUpdate 返回 false,则认为是不同 widget,需要先从原 widget 树上卸载,然后将新的 widget 挂载到 widget 树上。

可见,对于相同类型的 widget ,决定 canUpdate 返回值的就是这个比较:

old.widget == newWidget.key

也就是比较 引用,但这个说法并不严谨,因为存在操作符重载的情况,这个我们后面讲。

所以 Key 的设计核心理念就是给业务层留一个钩子,决定对 Widget 对应的 Element 子树 进行重建还是复用,同时默认情况下 widget 的 key 都为空,在相同 Widget 类型的情况下,也就满足条件 canUpdate 条件,提高了渲染效率。

下面,我们正式开始介绍 Flutter 中 Key 的分类。

Key 的继承结构

key类图

类图让我们在脑海中构建一个宏观的概念。

  • Key 是抽象类,它有两个子类,LocalKey 和 GlobalKey。
  • GlobalKey: Widget Tree 全局唯一的 key,同时可以保存状态。
  • LocalKey: 该类注释就是非 GlobalKey 的 key (这不是废话)。进一步解释,在同一个 parent Widget 下的所有子 Widget 之间 key 必须是唯一的,LocalKey 提供了一种区分兄弟节点的方式。

具体的子类我们下面详细介绍:

GlobalKey

由于 GlobalKey 具备全局唯一性, 通常有两个用途:

  1. 以 GlobalKey 声明的 Widget,可以跨 parent 卸载和挂载,同时保持状态,或者说 reparent 更易理解。
  2. 提供一个访问 widget 状态的接口。

我们分别举例来看,文中全部示例源码移步 GitHub flutter_demo_key

Reparent 保持状态

为了更直观的感受 Reparent 的好处,我们看下面的例子:

当点击 “切换 slide 状态” 按钮时,我们希望 Slide 控件在上下切换过程中保持滑动的状态,注意观察滑块在切换过程中滑动的位置是保留的。

globalkey_gif

页面源码如下:

globalkey_demo

关键部分为两处 MySlideWidget 的使用,都是用了同一个 GlobalKey,只不过由于 isShowSlide 变量控制,并不会同时显示,当点击 “切换slide状态” 按钮时择一显示。

MySlideWidget 为自定义的 slide 控件,内部保存了当前滑动的位置。

my_slide_widget

作为对比我们看看使用 LocalKey 的效果如下:

localkey_gif

显然就不能在两个 widget 之间保持状态,每次切换 slide 状态都会导致 slide 进度重置。

访问 Widget 状态

我们知道,在 Flutter 这种响应式的编程环境下,是不方便拿到其他 widget 的信息的。

为了实现 widget 间的状态通信,各种状态管理框架应运而生,GlobalKey 提供了一种可能。

由于 GlobalKey 全局唯一,因此可以在其他地方通过此 key 取出对应的Widget、Element 和 State 信息。

还是看个例子:

globalkey_access_state

源码如下:

access_state

很简单,通过 GlobalKey 的 currentState 属性就能访问到 state 信息。

GlobalKey 实现原理

老规矩,先看 GlobalKey 类图:

globalkey

最关键的是 GlobalKey 内部有个私有的静态变量 _registry,它记录了 GlobalKey 和 Element 的对应关系,GlobalKey 作为 map 的 key 也可以说明为什么需要全局唯一。如果尝试使用相同的 GlobalKey 将会抛出异常:

same_globalkey_error

GlobalKey 内部持有 Widget 和对应 Element 的引用,如果该 Widget 是 Stateful 的,那么 currentState 将也不为空,也就是说通过 Globalkey 还能拿到 State 状态。所以,通过 GlobalKey 可以访问到 Widget 的状态。

接下来我们看一下这个 _registry 是什么添加和删除元素的,这决定了这个 GlobalKey 对应 Element 的生命周期。

# framework.dart $Element

void mount(Element parent, dynamic newSlot) {
    ...
    _parent = parent;
    _slot = newSlot;
    _depth = _parent != null ? _parent.depth + 1 : 1;
    _active = true;
    ...
    if (widget.key is GlobalKey) {
      final GlobalKey key = widget.key;
      key._register(this);
    }
    _updateInheritance();
    ...
  }
  
void unmount() {
    ...
    if (widget.key is GlobalKey) {
      final GlobalKey key = widget.key;
      key._unregister(this);
    }
    ...
  }

显然,GlobalKey 的注册发生在 Element 的 mount 阶段,mount 常见的上游是 inflateWidget,也就是新的 Widget 挂载进来时,比较好理解。

GlobalKey 的取消注册发生在 Element 的 unmount 阶段。 那这个unmount 是在什么时候调用的呢?在上一篇文章中我们没有详细讲这里。

当一个 Widget 经新老比较之后发现不能直接 update(还是开篇那个流程图),此时需要先将原来的 Widget deactivate,随后将这个 Widget 对应的 element 添加到 BuildOwner 的 _inactiveElements 成员变量中,如果该 Widget 是 Stateful 的,那还会回调我们熟悉的 State 的 deactivate 方法。

# framework.dart $Element
void deactivateChild(Element child) {
    ...
    child._parent = null;
    child.detachRenderObject();
    // this eventually calls child.deactivate()
    owner._inactiveElements.add(child); 
    ...
}

处于 deactivate 状态的 Element 在本次 build 流程结束前可以 reparent,否则将会在 build 之后统一调用各个 inactiveElement 的 unmount 进行回收处理,GlobalKey 的生命周期就此结束。

umount_seq

如果上述这段原理解释你没看懂,强烈建议反复研读上篇 Flutter Widget 体系架构与 UI 渲染流程

可见, GlobalKey 可以在一次 drawFrame 的 finalizeTree 方法调用前 进行 reparent,否则还是会被销毁,而不是想象中的全局常驻。

我们重新回到注册后的流程,既然将所有 GlobalKey 和其对应的 Element 保存到了 _register 这个静态的 map 变量中,那什么时候取出复用呢?答案是 inflateWidget。

# framework.dart $Element

Element inflateWidget(Widget newWidget, dynamic newSlot) {
    ...
    final Key key = newWidget.key;
    if (key is GlobalKey) {
      final Element newChild = _retakeInactiveElement(key, newWidget);
      if (newChild != null) {
        ...
        newChild._activateWithParent(this, newSlot);
        final Element updatedChild = updateChild(newChild, newWidget, newSlot);
        ...
        return updatedChild;
      }
    }
    final Element newChild = newWidget.createElement();

    newChild.mount(this, newSlot);
    return newChild;
  }

可见,当一个 Widget 的 Key 为 GlobalKey 时,会先调用 _retakeInactiveElement 尝试从 InactiveElement 列表中取出 GlobalKey 对应的 Element,如果结果不为 null, 那么就是执行 reparent 流程,如果恰好这个 Element 是 Stateful 的,显然 State 也就保存了下来;反之,还是去创建新的 Element,一切重头开始。

源码逻辑非常清晰,不再赘述。

LocalKey

在这之前,笔者也看了一些相关的文章,大致都在讲各种 LocalKey(ValueKey、ObjectKey 等等) 的区别和使用场景是什么,但是还没有发现一个能讲清楚 “why” 的问题[摊手],笔者尝试用一些小例子并结合原理拆解一下,帮助你理解其中所以然。

前面讲到 GlobalKey 用于全局范围内 Widget 的唯一标识,那与之对应 LocalKey 则是局部范围的唯一标识。同时需要明确的是,这里的局部指的是具有相同父节点的子 Widget,换句话说就是说用来区分兄弟节点。

那么问题来了为什么要进行区分?我们看一个例子:

需求示例

假设有这样一个列表,点击列表的某条 Item,则将这条 Item 删除,每条 Item 有一个 switch 开关用来标识这条 Item 的状态。

现在的需求是点击哪条 item,就在列表中删除它,来看一个简单的实现:

每个item 小控件:

my_switch

主列表页:

test_delete_1

操作一下,看效果:

test_delete_1_gif

WTF?一个明显的问题是:当删除一条 item 时,它 switch 的状态怎么给了下一条?正常应该是当我删除 “C” 时其状态也被一起删掉,为什么会出现这种情况呢??

问题出现在兄弟 Widget 之间的比较过程!

原理分析

其实这与多子 Widget 的更新过程有关,我们在 Flutter Widget 体系架构 文章中讲过 Widget 的更新过程,但其中并未涉及到多子 Widget 的情况。

多子 Widget 对应的 Element 为 MultiChildRenderObjectElement,我们只需关注其 update 方法:

# MultiChildRenderObjectElement.class
@override
void update(MultiChildRenderObjectWidget newWidget) {
    //1..
    super.update(newWidget);
    assert(widget == newWidget);
    //2..
    _children = updateChildren(_children, widget.children, forgottenChildren: _forgottenChildren);
    ...
}

第一步调用 super.update 方法,与普通的 Element 差不多,核心就是更新 Element 内部持有的 widget 变量,由于属于 RenderObjectElement ,另外会调用 widget 的 updateRenderObject 方法更新对应的 RenderObject。

第二步,也是 widget 比较过程的核心,调用 updateChildren 方法,并传入新老 children,这里的 children 为 List,即需要对两个 List 进行比较。

那么问题来了,如何对两个 widget 列表进行高效的比较?这里思考两分钟...








如果不考虑效率,那么我们完全可以让每个 Widget(准确说是对应的 Element)重建,但在 todo 列表这种单个删除的场景,这样做显然没有必要。

更高效的做法是:

  1. 一个指针从前向后遍历一遍,直到比较到两个不同 Widget。
  2. 另一个指针从后向前遍历一遍,直到比较到两个不同 Widget。
  3. 经1、2步之后,我们就把两个列表之间的 diff 缩小到了中间的范围。
  4. 最后单独处理中间 diff 部分即可。

举个例子:

删除示例

按照上述流程,我们只需将 C 元素移除,其他元素直接执行 update 即可,这样做是不是效率提升很多?updateChildren 方法的原理就是这样。

图画的很明了,但接下来的问题是,你怎么知道第一个列表的 “A” 就是第二个列表的 “A” 呢?

上述例子中的问题就出现在这个比较过程,两个 Widget 的比较是通过什么呢?————还是 Widget.canUpdate。

我们来看 updateChildren 具体的做了什么?由于这块的源码比较长,就不贴 framework.dart #RenderObjectElement.updateChildren line:5591 源码了,注释讲的比较清楚:

updateChildren

翻译一下:

  1. 两个指针,一个正向遍历一个逆向遍历,先把相同的区域排除,正向遍历,相同的 Widget 调用其 updateChild 同步最新 widget 信息,而逆向遍历的先不同步。
  2. 剩余中间的 diff 区域,进行处理。
  3. 先把剩余区域 oldChildren 中没有 key 的 Widget 全部 deactive,有 key 的先不 deactive,并记录在一个 Map<Key, Element> oldKeyedChildren 中。
  4. 继续遍历 newChildren 中的 diff 区域,进行 inflate 工作,如果中途碰到上述 map 中的 key,则移除相应 key,表示这个 Widget 可以复用,oldKeyedChildren 列表中的元素最终都会被 deactive。这里也是一个优化点,即如果列表中存在元素进行了位置的调换,则只需改变元素的 slot 即可,而不需要重新 inflate。比如 A、B、C、D --> A、C、B、D。
  5. 继续同步第一步中剩余尾部的相同 widget 的信息(updateChild),这样做是保证同步过程是正向进行的。
  6. 如果 oldKeyedChildren 不为空,则明显是已经不存在的 element,最后调用 其 deactive 移除。

原理讲清楚了,回到我们的例子,由于两个 Widget 的比较过程还是通过 Widget.canUpdate 方法,即同时比较 runtimeType 和 key。在我们的例子中,runtimeType 都是 GestureDetector,而 key 也未赋值,所以任意两个 Widget 比较的结果都为 true。这导致在正向遍历的过程中,newChildren 全部匹配成功,逆向遍历都不需要了。

比较过程

所以依次进行 newChildren 的 updateChild 过程,原本 C 中 switch 的状态保留并更新 widget 标题信息,变成了 D,所以直观上看确实是 C 被删除了,只不过把状态错位给到了 D,但真实情况是 E 被删除了,同时把原本 C 和 D 的标题信息分别更新成了 D 和 E,诚不我欺!

为了验证这套逻辑,我们在删除 C 之前,将最后一条 Item 的 switch 开关打开,看看现象:删的是 C,但 F 的状态却没了。

test_delete_2_gif

问题的原因已经分析出来了,那应该怎么解决呢?我们终于可以正式开始讲 LocalKey 了。

在上面的 updateChildren 过程中,我们可以发现,当多子 widget 容器的兄弟节点不声明 key 时有两个问题:

  1. 对于 diff 区域 中没有 Key 的 widget 是直接 deactive 的,如果存在状态那么状态也会随之丢失。
  2. 对于正向和逆向比较过程中,没有 key 的 Widget 大概率会发生错乱匹配(通常 runtimeType 相同)。

LocalKey 的存在就是让开发者告诉系统如何区分兄弟节点。

ValueKey

先看 Key 的源码,可见使用 Key 构造函数默认创建的就是 ValueKey。

@immutable
abstract class Key {
  ...
  const factory Key(String value) = ValueKey<String>;

  @protected
  const Key.empty();
}

LocalKey

abstract class LocalKey extends Key {
  /// Default constructor, used by subclasses.
  const LocalKey() : super.empty();
}

ValueKey

ValueKey

注意到 ValueKey 重写了 == 操作符,实现是用 == 比较 value。

这里需要注意 == 与 identical 方法的不同。 两个对象比较时,当第一个对象没有重写 == 操作符时 ,比较结果与 identical 相同。identical 方法才是 Dart 中真正意义上的引用比较,具体操作就是比较对象的 hash 值。

常见的 String 类型的 ValueKey,那么会有:

String a = 'aaa';
String b = 'aaa';
print(a == b); //true
var valueKey1 = ValueKey(a);
var valueKey2 = ValueKey(b);
print('key比较 ${valueKey1 == valueKey2}'); //true

所以 ValueKey 可以更灵活的比较两个值引用类型的 Key。

ObjectKey

ObjectKey

可以看到 ObjectKey 是真正意义的引用比较,而不受 == 操作符的影响。

UniqueKey

UniqueKey

由于构造函数没有 const factory 的修饰,每次都会创建一个新的key,且没有 重载 == 操作符,所以两个 UniqueKey 使用引用进行比较。

如果你希望 Widget 的状态在每次 setState 之后重置,可以使用 UniqueKey;否则,请慎重使用它。

示例解决方案

所以最简单的解决方案就是,为每个子 Widget 声明一个相互不同的 key,并且保证在 widget 重建前后 保持一致。

所以最简单的做法就是给 GestureDetector 声明一个 ValueKey,值为 list[index]。

test_delete_3

当然这样做前提是 List 中的元素不会重复,如果可能存在重复的元素,应当将字符信息包装到一个新的对象中并使用 ObjectKey。

UniqueKey 不可取,因为在下次 build 时会原 Widget 再次产生新的 UniqueKey,导致所有 Widget 都匹配不成功。

结尾

你有没有注意到,为什么示例项目不使用 ListView,而是使用 Column?ListView 它不香吗?其实,这与滚动布局的特殊性和 ListView 回收逻辑有关,示例使用 ListView 会有新的问题出现~。后续会在 List 或 sliver 模块单独讲。

ValueKey 还有一个子类为 PageStorageKey,它由于记录可滚动布局的滚动位置,当列表被重建时保持滚动位置,同样这个我们在后续讲 List 或 sliver 时单独讲。

在整个 Widget 体系中绝大部分 Widget 创建时,Key 都是可选参数,仅有一个 Dismissible Widget,它要求 Key 是必传的。Dismissible 是 Flutter Framework 封装的用于支持滑动删除的 Widget。使用起来非常简单,只需要将你的 Widget 嵌套一层 Dismissible 即可。

那么在讲完整个 Key 的工作原理以后,你能解释一下 为什么创建 Dismissible 需要必传 Key 吗?

至此,这个熟悉又陌生的 Key 就讲完了~。

欢迎关注公众号 wanderingTech ,获取更多深度好文,溜了~~~

wx公众号