Key 是作为一个 Flutter 初学者最让人迷惑的东西,它无处不在,但又难以理解,可谓是最熟悉的陌生人,今天我们一起通过几个例子来系统的学习 Key 的使用和底层原理,阅读本文大概需要 2 分钟 [认真脸]。未经授权,禁止转载!
前言
回顾一下,上篇我们讲完 Flutter Widget 体系架构[1],其中在 widget 比较过程中涉及到新旧 wiget 的 key 的比较,比较结果直接决定了是重新 inflateWidget 还是直接将 widget 更新。
我们需要再次把祖传流程图拿出来:
标红部分为 Widget.canUpadte 比较,具体比较代码:
•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 是抽象类,它有两个子类,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 控件在上下切换过程中保持滑动的状态,注意观察滑块在切换过程中滑动的位置是保留的。
页面源码如下:
关键部分为两处 MySlideWidget 的使用,都是用了同一个 GlobalKey,只不过由于 isShowSlide 变量控制,并不会同时显示,当点击 “切换slide状态” 按钮时择一显示。
MySlideWidget 为自定义的 slide 控件,内部保存了当前滑动的位置。
作为对比我们看看使用 LocalKey 的效果如下:
显然就不能在两个 widget 之间保持状态,每次切换 slide 状态都会导致 slide 进度重置。
访问 Widget 状态
我们知道,在 Flutter 这种响应式的编程环境下,是不方便拿到其他 widget 的信息的。
为了实现 widget 间的状态通信,各种状态管理框架应运而生,GlobalKey 提供了一种可能。
由于 GlobalKey 全局唯一,因此可以在其他地方通过此 key 取出对应的Widget、Element 和 State 信息。
还是看个例子:
源码如下:
很简单,通过 GlobalKey 的 currentState 属性便可以访问到 state 信息。
GlobalKey 实现原理
老规矩,先看 GlobalKey 类图:
最关键的是 GlobalKey 内部有个私有的静态变量 _registry,它记录了 GlobalKey 和 Element 的对应关系,GlobalKey 作为 map 的 key 也可以说明为什么需要全局唯一。如果尝试使用相同的 GlobalKey 将会抛出异常:
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 的生命周期就此结束。
如果上述这段原理解释你没看懂,强烈建议反复研读上篇 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 小控件:
主列表页:
操作一下,看效果:
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 部分即可。
举个例子:
按照上述流程,我们只需将 C 元素移除,其他元素直接执行 update 即可,这样做是不是效率提升很多?updateChildren 方法的原理就是这样。
图画的很明了,但接下来的问题是,你怎么知道第一个列表的 “A” 就是第二个列表的 “A” 呢?
上述例子中的问题就出现在这个比较过程,两个 Widget 的比较是通过什么呢?————还是 Widget.canUpdate。
我们来看 updateChildren 具体的做了什么?由于这块的源码比较长,就不贴 framework.dart #RenderObjectElement.updateChildren line:5591 源码[5]了,注释讲的比较清楚:
翻译一下:
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 全部匹配成功,逆向遍历都不需要了。
所以依次进行 newChildren 的 updateChild 过程,原本 C 中 switch 的状态保留并更新 widget 标题信息,变成了 D,所以直观上看确实是 C 被删除了,只不过把状态错位给到了 D,但真实情况是 E 被删除了,同时把原本 C 和 D 的标题信息分别更新成了 D 和 E,诚不我欺!
为了验证这套逻辑,我们在删除 C 之前,将最后一条 Item 的 switch 开关打开,看看现象:删的是 C,但 F 的状态却没了。
问题的原因已经分析出来了,那应该怎么解决呢?我们终于可以正式开始讲 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
注意到 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
可以看到 ObjectKey 是真正意义的引用比较,而不受 == 操作符的影响。
UniqueKey
由于构造函数没有 const factory 的修饰,每次都会创建一个新的key,且没有 重载 == 操作符,所以两个 UniqueKey 使用引用进行比较。
如果你希望 Widget 的状态在每次 setState 之后重置,可以使用 UniqueKey;否则,请慎重使用它。
示例解决方案
所以最简单的解决方案就是,为每个子 Widget 声明一个相互不同的 key,并且保证在 widget 重建前后 保持一致。
所以最简单的做法就是给 GestureDetector 声明一个 ValueKey,值为 list[index]。
当然这样做前提是 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 就讲完了~。