每个示例都是以 main_0x.dart 格式命名的,想看哪个示例,把它重命名为 main.dart ,运行即可。
Flutter 3.3.6 环境所有示例测试通过。
所有示例代码地址 gitee.com/iam17/flutt…
key 是用来做什么的
key 是 Widget ,Element 和 SemanticsNode 的身份标识。
key 总的来说分两种,一种是 Locakey ,一种是 GlobalKey。区别就在于 Locakey 的作用范围在兄弟节点之间,GlobalKey 的作用范围是全局。Locakey 在兄弟节点中唯一,GlobalKey在整个 App 唯一。
Localkey 的作用
我没有用key,也没什么问题啊?一般情况下,不用 指定 Key参数是没有什么问题的。但是如果要交换两个 widget 的位置呢?或是有 widget 被删除呢?这个时候如果没有 key,就无法确定这个 widget 去更新哪个 element 了。在key 为 null的情况下,如果类型相同,会更新同一位置的 element。
Localkey 的作用是用来决定同一 parent 下是否可以重用 element 的。
举个例子,有两个 Box Widget,点击 floatingActionButton 后,文本会交换,但是颜色没有交换。
完整代码点这里 gitee.com/iam17/flutt…
为什么会有这样的效果呢?这得从头说起。
为了提高效率,flutter 有三棵树,分别由 Widget,Element,RenderObject 组成。
- Widget:Widget 相当于是配置文件,是不可变的。想改变,只能用一个新 widget 替换原来的 widget
- Element:Element 是实例化的 Widget 对象,通过 Widget 的 createElement() 方法,根据 Widget数据生成。如果 widget 有相同的 runtimeType 并且有两同的 key, Element 可以根据新的 widget 进行 update.
- RenderObject:用于应用界面的布局和绘制,保存了元素的大小,布局等信息.
两个关键逻辑
一、 判断 newWidget 是否可以更新持有 oldWidget 的 element。
static bool canUpdate(Widget oldWidget, Widget newWidget) { return oldWidget.runtimeType == newWidget.runtimeType && oldWidget.key == newWidget.key; } 复制代码
在本例中,box1,box2 的 runtimeType 都是 Box
,key 都为 null,所以 box1,box2是可以互相更新的。
二、 element 更新逻辑
element 的更新逻辑在 Element 类的 updateChild 方法中。更新逻辑如下
用 newWidget 更新 child。
- chid==null && newWidget!=null , 用 newWidget create new child。
- child.widget == newWidget,如果位置不同,更新child的位置。
- newWidget==null && child!=null ,删除 child
- newWidget!=null && child!=null,根据逻辑一中的判断逻辑判断 child是否可以更新,如果可以更新,用 newWidget 更新 child,否则 dispose 原 child,重建新的 child。
- child==null && newWidget==null 什么都不做
本例中,会命中第 4 条逻辑,更新文本,颜色不变,因为 element 的位置没变。
如果想让 element 更新位置,那就需要加上 LocalKey 了
在代码文件 main_01.dart 中找到这句
var children = [const Box(text: 'one'), const Box(text: 'two')]; 复制代码
加上 LocalKey
var children = [ const Box(text: 'one',key: ValueKey('one'), ), const Box(text: 'two',key: ValueKey('two'),), ]; 复制代码
加上 key 之后,当 box widget 交换的时候,命中第二条逻辑,box widget 在 parent 中的位置发生了改变 ,更新 child,也就是 对应 element 的位置。
你可能会疑惑,为什么没有命中第四条?因为 box1,box2 的父级,也就是 column 在调用 updateChild(Element? child, Widget? newWidget, Object? newSlot)
方法的时候,已经根据 widget 的 key 找到了对应的 child,调整了 child 的出场顺序,所以会命中第 2 条逻辑。
column 执行 updateChilren 方法调用 updateChild 准备 oldChild 的逻辑如下
//准备要更新的 child Element? oldChild; //用来更新 oldChild 的 widget final Widget newWidget = newWidgets[newChildrenTop]; if (haveOldChildren) { final Key? key = newWidget.key; if (key != null) { //通过key 找到 newWidget 对应的child oldChild = oldKeyedChildren![key]; if (oldChild != null) { if (Widget.canUpdate(oldChild.widget, newWidget)) { // we found a match! // remove it from oldKeyedChildren so we don't unsync it later oldKeyedChildren.remove(key); } else { // Not a match, let's pretend we didn't see it for now. oldChild = null; } } } } final Element newChild = updateChild(oldChild, newWidget, slotFor(newChildrenTop, previousChild))!; 复制代码
通过源码可以知道 key 的重要性,如果没有 key 是无法根据 widget 找到对应的 child 的。
如果没有 key ,可能无法更新对应的 element。如果有了 key,比如本例中 column 就可以把 widget 和 child 配对,更新child的位置。
思考一下,如果交换 Box 的同时,同时修改 text,会走element 更新逻辑的第几条?
答案在本文最后。
Local key 是抽象类,无法直接用,可以用它的子类,ValueKey,ObjectKey,UniqueKey,PageStorageKey。
ValueKey
构造函数
const ValueKey(this.value); final T value; 复制代码
可以这样生成 key ValueKey<String>('1')
,但一般我们都会省略范型,直接写 ValueKey('1')
,dart 会自己推断出类型。
ValueKey(1),ValueKey('1')
,是两个不同的 valueKey,因为它们的类型不同。ValueKey<int>(1),ValueKey<double>(1)
,是两个不同的 valueKey,虽然它们的值相同,但类型不同。
因为有值相同但类型不同的这种情况存在,所以在判断valueKey是否相等的时候,同时判断 类型和值
bool operator ==(Object other) { if (other.runtimeType != runtimeType) { return false; } return other is ValueKey<T> && other.value == value; } 复制代码
ValueKey 是最常用的 key,一般来说对于一个列表,如果有 id,用每个条目 的 id 作为 key 最合适不过。不要用索引作为 key,因为如果有删除或修改索引会发生改变,不能作为标识条目的作用。
ObjectKey
构造函数
const ObjectKey(this.value); final Object? value; 复制代码
看起来像是 ValueKey的具像化。T 具体为 Object。其实不然,ObjectKey 判断相等 和 ValueKey 不同。ValueKey 的相等条件是 类型相同且值相同,ObjectKey的相等条件是 类型相同且引用相同。
判断引用举例:
var o = new Object();
ObjectKey(o),ObjectKey(o)
相同,o 是同一个ObjectObjectKey(const Object()),ObjectKey(const Object())
相同ObjectKey([1]),ObjectKey([1])
不同。ObjectKey(const [1]),ObjectKey(const [1])
相同。ObjectKey(const [1]),ObjectKey(const [2])
不同。ObjectKey(2,1+1),ObjectKey(1)
,相同。
我们尽量加上 const 关键字,有相同的 key,有利于优化。
StatelessWidget 子类和 StatefulWidget 子类加上 const , ObjectKey 也不同。
class Test extends StatelessWidget { const Test({super.key}); @override Widget build(BuildContext context) { return Container(); } } print(ObjectKey(const Test()) == ObjectKey(const Test())); //false 复制代码
UniqueKey
class UniqueKey extends LocalKey { UniqueKey(); @override String toString() => '[#${shortHash(this)}]'; } 复制代码
没错,整个 UniqueKey 的代码就这么多,再往上看 LocalKey,key也什么都没有。每个 UniqueKey 的实例对象都是不相等的,它的应用场景是什么呢?
场景一 必须用不同key的场景 AnimatedSwitcher 的 child 不同才能进行动画 ,所以用 UniqueKey 比较合适。
只有 Widget 的 canUpdate 判定为假的时候,才会执行 Switcher 动画,因为类型没有变,所以只能改变 key,这里用 UniqueKey 保证每个 key 都不同,所以每次都会执行动画。
Text( '$count', key: UniqueKey(), style: const TextStyle( fontSize: 100, fontWeight: FontWeight.bold, color: Colors.blue), ), 复制代码
场景二 为了省事,不需要为 value 值而烦恼。先把key 生成好,每次都复用这个 key
class _MyWidgetState extends State<MyWidget> { var key1 = UniqueKey(); var key2 = UniqueKey(); @override Widget build(BuildContext context) { return Row(children: const [Text('text1',key:key1),Text('text2',key:key2)],) } } 复制代码
key生成好了后,在 state 没 dispose之前是不变的。 每次调用 build 方法都会复用 这个 key。
PageStorageKey
PageStorageKey 是 ValueKey的子类,它的用处是保持滚动位置。
有两个tab ,第一个 tabview 有 PageStorageKey,第二个tabview 没有 key。结果就是,第一个tab 保持了 滚动位置 ,第二个 tab 失去了滚动位置。
关键代码就是加 PageStorageKey 的这句
ListView.builder( key:const PageStorageKey(1) 复制代码
要想操持滚动位置 必须用 PageStorageKey ,用其它类型的 key 是不行的。
使用 GlobalKey
在整个 app 范围内 Globalkey 是唯一的。GlobalKey不应该在build
方法中初始化,否则会每次build
都重建 GlobalKey。为了不让GlobalKeyt每次都重新生成,可以让State
对象拥有GlobalKey对象,然后在 State.initState
的方法中初始化 GlobalKey。
应用场景有两个
一、获取相关的 BuildContext 或 State
示例很简单,就是一个按钮,点击后换颜色,完整代码 gitee.com/iam17/flutt…
主要代码如下:
var key = GlobalKey<_BoxState>(); MaterialApp( home: Scaffold( body: Box(key: key), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, floatingActionButton: ElevatedButton( onPressed: () { RenderBox box = key.currentContext?.findRenderObject() as RenderBox; //获取全局坐标 print(box.localToGlobal(Offset.zero)); //获取 Widget var w = key.currentWidget!; print(w.runtimeType); //获取 context var context = key.currentContext!; print('${context.size} size'); //换颜色 key.currentState?.changeColor(); }, child: const Text('换颜色')), )) 复制代码
通过 GlobalKey 找到 element 的逻辑是这样的。
在 Element mount 的时候先注册 key
void mount(Element? parent, Object? newSlot) { ... final Key? key = widget.key; if (key is GlobalKey) { owner!._registerGlobalKey(key, this); } } 复制代码
其实注册就是加到 owner的 Map里
final Map<GlobalKey, Element> _globalKeyRegistry = <GlobalKey, Element>{}; 复制代码
用的时候根据注册的 key 找到 element
Element? get _currentElement => WidgetsBinding.instance.buildOwner!._globalKeyRegistry[this]; 复制代码
找到了 element 就找到了 currentContext ,因为 contenxt 就是 element ,通过 element 还能找到 widget 和 state。
GlobalKey currentState 属性返回的是范型, GlobalKey 的范型声明就是给它准备的。
T? get currentState; 复制代码
用完之后,在 Element 的 unmount 方法里会删除注册
void unmount() { ... final Key? key = _widget?.key; if (key is GlobalKey) { owner!._unregisterGlobalKey(key, this); } ... } 复制代码
有人不敢用 GlobalKey,理由是 GlobalKey 的成本很高。我们了解了 GlobalKey 找到 element 整个过程,并没有成本高的地方。所以在需要的地方,放心用!
二、在层级改变时保持状态
完整代码地址gitee.com/iam17/flutt…
在有globalkey 的情况下,Box 状态保持,点重新build按钮,Box的颜色不变。现在我们去掉 globalkey
Builder(builder: (BuildContext context) { _count++; if (_count % 2 == 0) { return Box( // 去掉 glokeyKey 看看效果 // key: _globalKey, ); } else { return Center( child: Box( // 去掉 glokeyKey 看看效果 //key: _globalKey, ), ); } }) 复制代码
去掉 globalkey 之后,我们发现,每次 build,颜色都会改变,说明状态丢失,丢失的原因是,每次 Box 都会重建。
所谓的状态保持其实就是 Box 对应的 element 用 update 代替了 create,所以 state 才能保持。
所以在同父级下更新 widget,可以用 localKey,不同父级下更新 widget,可以用 globalkey。
相比较而言,通过 GlobalKey 移动子树的成本会高一些,但是相比于重建子树,GlobalKey 的方案开销还是要小。
最后回答一下前面提到的问题:如果交换 Box 的同时,同时修改 text,会走element 更新逻辑的第几条?
答案是走 第 4 条逻辑。