Key
在Flutter的源码中可以说是无处不在,但是我们日常中确不怎么使用它。有点像是“最熟悉的陌生人”,那么今天就来说说这个“陌生人”,揭开它神秘的面纱。
概念
Key
是Widget
、Element
和SemanticsNode
的标识符。 只有当新的Widget
的Key
与当前Element
中Widget
的Key
相同时,它才会被用来更新现有的Element
。Key
在具有相同父级的Element
之间必须是唯一的。
以上定义是源码中关于Key
的解释。通俗的说就是Widget
的标识,帮助实现Element
的复用。关于它的说明源码中也提供了YouTube的视频链接:When to Use Keys。如果你无法访问,可以看Google 官方在优酷上传的。
例子
视频中的例子很简单且具有代表性,所以本文将采用它来介绍今天的内容。
首先上代码:
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
List<Widget> widgets;
@override
void initState() {
super.initState();
widgets = [
StatelessColorfulTile(),
StatelessColorfulTile()
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Row(
children: widgets,
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.refresh),
onPressed: _swapTile,
),
);
}
_swapTile() {
setState(() {
widgets.insert(1, widgets.removeAt(0));
});
}
}
class StatelessColorfulTile extends StatelessWidget {
final Color _color = Utils.randomColor();
@override
Widget build(BuildContext context) {
return Container(
height: 150,
width: 150,
color: _color,
);
}
}
class Utils {
static Color randomColor() {
var red = Random.secure().nextInt(255);
var greed = Random.secure().nextInt(255);
var blue = Random.secure().nextInt(255);
return Color.fromARGB(255, red, greed, blue);
}
}
代码可以直接复制到DartPad中运行查看效果。 或者点击这里直接运行。
效果很简单,就是两个彩色方块,点击右下角的按钮后交换两个方块的位置。这里我就不放具体的效果图了。实际效果也和我们预期的一样,两个方块成功交换位置。
发现问题
上面的方块是StatelessWidget
,那我们把它换成StatefulWidget
呢?。
class StatefulColorfulTile extends StatefulWidget {
StatefulColorfulTile({Key key}) : super(key: key);
@override
StatefulColorfulTileState createState() => StatefulColorfulTileState();
}
class StatefulColorfulTileState extends State<StatefulColorfulTile> {
final Color _color = Utils.randomColor();
@override
Widget build(BuildContext context) {
return Container(
height: 150,
width: 150,
color: _color,
);
}
}
再次执行代码,发现方块没有“交换”。这是为什么?
???分析问题
首先要知道Flutter中有三棵树,分别是==Widget Tree==、==Element Tree== 和 ==RenderObject Tree==。
- Widget:
Element
的配置信息。与Element
的关系可以是一对多,一份配置可以创造多个Element
实例。 - Element:
Widget
的实例化,内部持有Widget
和RenderObject
。 - RenderObject:负责渲染绘制。
简单的比拟一下,Widget
有点像是产品经理,规划产品整理需求。Element
则是UI小姐姐,根据原型整理出最终设计图。RenderObject
就是我们程序员,负责具体的落地实现。
代码中可以确定一点,两个方块的Widget肯定是交换了。既然Widget
没有问题,那就看看Element
。
但是为什么StatelessWidget
可以成功,换成StatefulWidget
就失效了?
点击按钮调用setState
方法,依次执行:
graph TB
A["_element.markNeedsBuild()"] -- 标记自身元素dirty为true --> B["owner.scheduleBuildFor()"]
B --添加至_dirtyElements--> D["drawFrame()"]
D --> E["buildScope()"]
E --> F["_dirtyElements[index].rebuild()"]
F --> G["performRebuild()"]
G --> H["updateChild()"]
我们重点看一下Element
的updateChild
方法:
@protected
Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
// 如果'newWidget'为null,而'child'不为null,那么我们删除'child',返回null。
if (newWidget == null) {
if (child != null)
deactivateChild(child);
return null;
}
if (child != null) {
// 两个widget相同,位置不同更新位置,返回child。这里比较的是hashCode
if (child.widget == newWidget) {
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
return child;
}
// 我们的交换例子处理在这里
if (Widget.canUpdate(child.widget, newWidget)) {
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
child.update(newWidget);
return child;
}
deactivateChild(child);
}
// 如果无法更新复用,那么创建一个新的Element并返回。
return inflateWidget(newWidget, newSlot);
}
Widget
的canUpdate
方法:
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
这里出现了我们今天的主角Key
,不过我们先放在一边。canUpdate
方法的作用是判断newWidget是否可以替代oldWidget作为Element
的配置。 一开始也提到了,Element
会持有Widget。
该方法判断的依据就是runtimeType
和key
是否相等。在我们上面的例子中,不管是StatelessWidget
还是StatefulWidget
的方块,显然canUpdate
都会返回true。因此执行child.update(newWidget)
方法,就是将持有的Widget更新了。
不知道这里大家有没有注意到,这里并没有更新state
。我们看一下StatefulWidget
源码:
abstract class StatefulWidget extends Widget {
const StatefulWidget({ Key key }) : super(key: key);
@override
StatefulElement createElement() => StatefulElement(this);
@protected
State createState();
}
StatefulWidget
中创建的是StatefulElement
,它是Element
的子类。
class StatefulElement extends ComponentElement {
StatefulElement(StatefulWidget widget)
: _state = widget.createState(),
super(widget) {
_state._element = this;
_state._widget = widget;
}
@override
Widget build() => state.build(this);
State<StatefulWidget> get state => _state;
State<StatefulWidget> _state;
...
}
通过调用StatefulWidget
的createElement
方法,最终执行createState
创建出state并持有。也就是说StatefulElement
才持有state。
所以我们上面两个StatefulWidget
的方块的交换,实际只是交换了“身体”,而“灵魂”没有交换。所以不管你怎么点击按钮都是没有变化的。
解决问题
找到了原因,那么怎么解决它?那就是设置一个不同的Key
:
@override
void initState() {
super.initState();
widgets = [
StatefulColorfulTile(key: const Key("1")),
StatefulColorfulTile(key: const Key("2"))
];
}
但是这里要注意的是,这里不是说添加key以后,在canUpdate
方法返回false,最后执行inflateWidget(newWidget, newSlot)
方法创建新的Element
。(很多相关文章对于此处的说明都有误区。。。好吧我承认我一开始也被误导了。。。)
@protected
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);
assert(newChild == updatedChild);
return updatedChild;
}
}
// 这里就调用到了createElement,重新创建了Element
final Element newChild = newWidget.createElement();
newChild.mount(this, newSlot);
return newChild;
}
如果如此,那么执行createElement
方法势必会重新创建state,那么方块的颜色也就随机变了。当然此种情况并不是不存在,比如我们给现有的方块外包一层Padding
(SingleChildRenderObjectElement
):
@override
void initState() {
super.initState();
widgets = [
Padding(
padding: const EdgeInsets.all(8.0),
child: StatefulColorfulTile(key: Key("1"),)
),
Padding(
padding: const EdgeInsets.all(8.0),
child: StatefulColorfulTile(key: Key("2"),)
),
];
}
这种情况下,交换后比较外层Padding
不变,接着比较内层StatefulColorfulTile
,因为key不相同导致颜色随机改变。因为两个方块位于不同子树,两者在逐层对比中用到的就是canUpdate
方法返回false来更改。
而本例是方块的外层是Row
(MultiChildRenderObjectElement
),是对比两个List,存在不同。关键在于update
时调用的RenderObjectElement.updateChildren
方法。
@protected
List<Element> updateChildren(List<Element> oldChildren, List<Widget> newWidgets, { Set<Element> forgottenChildren }) {
...
int newChildrenTop = 0;
int oldChildrenTop = 0;
int newChildrenBottom = newWidgets.length - 1;
int oldChildrenBottom = oldChildren.length - 1;
final List<Element> newChildren = oldChildren.length == newWidgets.length ?
oldChildren : List<Element>(newWidgets.length);
Element previousChild;
// 从前往后依次对比,相同的更新Element,记录位置,直到不相等时跳出循环。
while ((oldChildrenTop <= oldChildrenBottom) &&
(newChildrenTop <= newChildrenBottom)) {
final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
final Widget newWidget = newWidgets[newChildrenTop];
// 注意这里的canUpdate,本例中在没有添加key时返回true。
// 因此直接执行updateChild,本循环结束返回newChildren。后面因条件不满足都在不执行。
// 一旦添加key,这里返回false,不同之处就此开始。
if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
break;
final Element newChild = updateChild(oldChild, newWidget, previousChild);
newChildren[newChildrenTop] = newChild;
previousChild = newChild;
newChildrenTop += 1;
oldChildrenTop += 1;
}
// 从后往前依次对比,记录位置,直到不相等时跳出循环。
while ((oldChildrenTop <= oldChildrenBottom) &&
(newChildrenTop <= newChildrenBottom)) {
final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenBottom]);
final Widget newWidget = newWidgets[newChildrenBottom];
if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
break;
oldChildrenBottom -= 1;
newChildrenBottom -= 1;
}
// 至此,就可以得到新旧List中不同Weiget的范围。
final bool haveOldChildren = oldChildrenTop <= oldChildrenBottom;
Map<Key, Element> oldKeyedChildren;
// 如果存在中间范围,扫描旧children,获取所有的key与Element保存至oldKeyedChildren。
if (haveOldChildren) {
oldKeyedChildren = <Key, Element>{};
while (oldChildrenTop <= oldChildrenBottom) {
final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
if (oldChild != null) {
if (oldChild.widget.key != null)
oldKeyedChildren[oldChild.widget.key] = oldChild;
else
// 没有key就移除对应的Element
deactivateChild(oldChild);
}
oldChildrenTop += 1;
}
}
// 更新中间不同的部分
while (newChildrenTop <= newChildrenBottom) {
Element oldChild;
final Widget newWidget = newWidgets[newChildrenTop];
if (haveOldChildren) {
final Key key = newWidget.key;
if (key != null) {
// key不为null,通过key获取对应的旧Element
oldChild = oldKeyedChildren[key];
if (oldChild != null) {
if (Widget.canUpdate(oldChild.widget, newWidget)) {
oldKeyedChildren.remove(key);
} else {
oldChild = null;
}
}
}
}
// 本例中这里的oldChild.widget与newWidget hashCode相同,在updateChild中成功被复用。
final Element newChild = updateChild(oldChild, newWidget, previousChild);
newChildren[newChildrenTop] = newChild;
previousChild = newChild;
newChildrenTop += 1;
}
// 重置
newChildrenBottom = newWidgets.length - 1;
oldChildrenBottom = oldChildren.length - 1;
// 将后面相同的Element更新后添加到newChildren,至此形成新的完整的children。
while ((oldChildrenTop <= oldChildrenBottom) &&
(newChildrenTop <= newChildrenBottom)) {
final Element oldChild = oldChildren[oldChildrenTop];
final Widget newWidget = newWidgets[newChildrenTop];
final Element newChild = updateChild(oldChild, newWidget, previousChild);
newChildren[newChildrenTop] = newChild;
previousChild = newChild;
newChildrenTop += 1;
oldChildrenTop += 1;
}
// 清除旧列表中多余的Element
if (haveOldChildren && oldKeyedChildren.isNotEmpty) {
for (Element oldChild in oldKeyedChildren.values) {
if (forgottenChildren == null || !forgottenChildren.contains(oldChild))
deactivateChild(oldChild);
}
}
return newChildren;
}
这个方法有点复杂,详细的执行流程我在代码中添加了注释。看完这个diff算法,只能说一句:妙啊!!
到此也就解释了我们一开始提出的问题。不知道你对这不起眼的key
是不是有了更深的认识。通过上面的例子可以总结以下三点:
-
一般情况下不设置key也会默认复用
Element
。 -
对于更改同一父级下Widget(尤其是
runtimeType
不同的Widget)的顺序或是增删,使用key
可以更好的复用Element
,提升性能。 -
StatefulWidget
使用key,可以在发生变化时保持state。不至于发生本例中“身体交换”的bug。
Key的种类
上面例子中我们用到了Key
,其实它还有许多种类。
1.LocalKey
LocalKey
继承自 Key
,在同一父级的Element
之间必须是唯一的。(当然了,你要是写成不唯一也行,不过后果自负哈。。。)
我们基本不直接使用LocalKey
,而是使用的它的子类:
ValueKey
我们上面使用到的Key
,其实就是ValueKey<String>
。它主要是使用特定类型的值来做标识的,像是“值引用”,比如int、String等类型。我们看它源码中的 ==
操作符方法:
class ValueKey<T> extends LocalKey {
const ValueKey(this.value);
final T value;
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType)
return false;
final ValueKey<T> typedOther = other;
return value == typedOther.value; // <---
}
...
}
ObjectKey
有“值引用”,就有“对象引用”。主要还是==
操作符方法:
class ObjectKey extends LocalKey {
const ObjectKey(this.value);
final Object value;
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType)
return false;
final ObjectKey typedOther = other;
return identical(value, typedOther.value); // <---
}
...
}
UniqueKey
会生成一个独一无二的key值。
class UniqueKey extends LocalKey {
UniqueKey();
@override
String toString() => '[#${shortHash(this)}]';
}
String shortHash(Object object) {
return object.hashCode.toUnsigned(20).toRadixString(16).padLeft(5, '0');
}
PageStorageKey
用于保存和还原比Widget生命周期更长的值。比如用于保存滚动的偏移量。每次滚动完成时,PageStorage
会保存其滚动偏移量。 这样在重新创建Widget时可以恢复之前的滚动位置。类似的,在ExpansionTile
中用于保存展开与闭合的状态。
具体的实现原理也很简单,看看PageStorage
的源码就清楚了,这里就不展开了。
2.GlobalKey
介绍
GlobalKey
也继承自 Key
,在整个应用程序中必须是唯一的。GlobalKey
源码有点长,我就不全部贴过来了。
@optionalTypeArgs
abstract class GlobalKey<T extends State<StatefulWidget>> extends Key {
factory GlobalKey({ String debugLabel }) => LabeledGlobalKey<T>(debugLabel);
const GlobalKey.constructor() : super.empty();
static final Map<GlobalKey, Element> _registry = <GlobalKey, Element>{};
// 在`Element的 `mount`中注册GlobalKey。
void _register(Element element) {
_registry[this] = element;
}
// 在`Element的 `unmount`中注销GlobalKey。
void _unregister(Element element) {
if (_registry[this] == element)
_registry.remove(this);
}
Element get _currentElement => _registry[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;
}
...
}
它的内部存在一个Map<GlobalKey, Element>
的静态Map,通过调用_register
、_unregister
方法来添加和删除Element
。同时它的内部还持有当前的Element
、Widget
甚至State
。可以看到 GlobalKey
是非常昂贵的,没有特别的复用需求,不建议使用它。
怎么复用呢?GlobalKey
在上面inflateWidget
的源码中出现过一次。当发现key是GlobalKey
时,使用_retakeInactiveElement
方法复用Element
。
Element _retakeInactiveElement(GlobalKey key, Widget newWidget) {
final Element element = key._currentElement;
if (element == null)
return null;
if (!Widget.canUpdate(element.widget, newWidget))
return null;
final Element parent = element._parent;
if (parent != null) {
parent.forgetChild(element);
parent.deactivateChild(element);
}
owner._inactiveElements.remove(element);
return element;
}
如果获取到了Element
,那么就从旧的节点上移除并返回。否则将在inflateWidget
重新创建新的Element
。
使用
-
首先就是上面提到的使用相同的
GlobalKey
来实现复用。 -
利用
GlobalKey
持有的BuildContext
。比如常见的使用就是获取Widget的宽高信息,通过BuildContext
可以在其中获取RenderObject
或Size
,从而拿到宽高信息。这里就不贴代码了,有需要可以看此处示例。 -
利用
GlobalKey
持有的State
,实现在外部调用StatefulWidget
内部方法。比如常用GlobalKey<NavigatorState>
来实现无Context跳转页面,在点击推送信息跳转指定页面就需要用到。
先创建一个GlobalKey<NavigatorState>
:
static GlobalKey<NavigatorState> navigatorKey = new GlobalKey();
添加至MaterialApp:
MaterialApp(
navigatorKey: navigatorKey,
...
);
然后就是调用push方法:
navigatorKey.currentState.push(MaterialPageRoute(
builder: (BuildContext context) => MyPage(),
));
通过GlobalKey
持有的State
,就可以调用其中的方法、获取数据。
LabeledGlobalKey
它是一个带有标签的GlobalKey
。 该标签仅用于调试,不用于比较。
GlobalObjectKey
同上ObjectKey
。区别在于它是GlobalKey
。
思考题
最后来个思考题:对于可选参数key,我搜索了一下Flutter的源码。发现只有Dismissible
这个滑动删除组件要求必须传入key。结合今天的内容,想想是为什么?如果传入相同的key,会发生什么?
本篇是“说说”系列第三篇,前两篇链接奉上:
PS:此系列都是自己的学习记录与总结,尽力做到“通俗易懂”和“看着一篇就够了”。不过也不现实,学习之路没有捷径。
写着写着,就写的有点多了。本想着拆成两篇,想想算了。毕竟我是一名月更选手,哈哈~~
如果本文对你有所帮助或启发的话,还请不吝点赞收藏支持一波。同时也多多支持我的Flutter开源项目flutter_deer。
我们下个月见~~
网友评论