cpp map 获取所有 key_Flutter 中最熟悉的陌生人之 Key 全面解析

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

前言

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

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

1ffcbc89769944626223cf97756798a7.png

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

589e053a3a95f31bf265ad278c9e2b60.png

•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 的继承结构

82c3a39171e805597a93249a1e0bc556.png

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

•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[2]

Reparent 保持状态

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

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

06350d30662123ffc491910feec2a87f.gif

页面源码如下:

a5c7a3767290e549fa620c4a152b563f.png

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

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

916309aee7d860ef4de8d47425c76f1c.png

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

d7a70034037399f7674316e438ca0bda.gif

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

访问 Widget 状态

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

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

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

还是看个例子:

fcac0ec2da68c94f7e6e37259677abcb.gif

源码如下:

34e58256458e679b67fabdbc0813927f.png

很简单,通过 GlobalKey 的 currentState 属性便可以访问到 state 信息。

GlobalKey 实现原理

老规矩,先看 GlobalKey 类图:

de28c57a3ddbd102d3e06eb46b3d385e.png

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

8fe6482ef145f451c64ad3db97440fac.png

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

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

# framework.dart $Elementvoid 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 $Elementvoid 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 的生命周期就此结束。

d482e1e85b540332d438cbfa7155e6c0.png

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

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

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

# framework.dart $ElementElement 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 小控件:

da05c8893dd2d07e8623bf1793864b15.png

主列表页:

0402c7af52d7bb6834414f792efc3de5.png

操作一下,看效果:

55ee5491ecc6650307685f9ba3e4926c.gif

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

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

原理分析

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

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

# MultiChildRenderObjectElement.class@overridevoid 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 部分即可。

举个例子:

dc6fb5275604918b4ffd32660fe3234d.png

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

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

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

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

907bd7516104f4ab8d9039a5a196ed62.png

翻译一下:

1.两个指针,一个正向遍历一个逆向遍历,先把相同的区域排除,正向遍历,相同的 Widget 调用其 updateChild 同步最新 widget 信息,而逆向遍历的先不同步。2.剩余中间的 diff 区域,进行处理。3.先把剩余区域 oldChildren 中没有 key 的 Widget 全部 deactive,有 key 的先不 deactive,并记录在一个 Map 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 全部匹配成功,逆向遍历都不需要了。

0003bc1f1a89cda707ea889fd42d95fe.png

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

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

d3fb5955f9cb7b4ea1223767b5e148b1.gif

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

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

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

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

ValueKey

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

@immutableabstract class Key {  ...  const factory Key(String value) = ValueKey;  @protected  const Key.empty();}

LocalKey

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

ValueKey

b040461862d99321aea81f58c7f18001.png

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

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

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

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

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

ObjectKey

7b668562f692fe210881875337a3a2c7.png

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

UniqueKey

e4cbaf85ae1725a2c19612c48116ccae.png

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

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

示例解决方案

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

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

e8b88d0366cf9a247f0bd8c566eaf314.png

当然这样做前提是 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 就讲完了~。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值