前言

之前入门一些Flutter应用的时候,总是会遇到GlobalKey这个类,当时我只从代码的语法上感知到这个东西肯定是用来绑定某些东西的,但至于key这东西是啥?为什么要绑定?不绑定的话会怎么样?为什么有的Widget需要绑定有有的不需要?这些统统都不知道。

于是趁着端午有时间,就认真翻了下官方文档,发现官方文档说得非常详细(前提是你对Flutter的控件树有一定理解),上面的问题基本都回答到,可惜的是官方是用视频(YouToBe)讲解的,这不便于忘记的时候速读翻阅,于是我就整理成这篇博客顺便加固下印象。


key是什么

key的作用是:控制weidget树上的widget是否被替换(刷新)

如果两个weidget的runtimeTypekey属性相等(用==比较),那么原本指向旧weidge的element,它的指针会指向新的widget上(通过Element.update方法)。如果不相等,那么旧element会从树上移除,根据当前新的widget重新构建新element,并加到树上指向新widget。

我们可以看下代码是不是这么回事:
Element.update

java
  • 01
  • 02
  • 03
  • 04
  • 05
  • 06
  • 07
  • 08
  • 09
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
@mustCallSuper void update(covariant Widget newWidget) { // This code is hot when hot reloading, so we try to // only call _AssertionError._evaluateAssertion once. assert(_lifecycleState == _ElementLifecycle.active && widget != null && newWidget != null && newWidget != widget && depth != null && Widget.canUpdate(widget, newWidget)); // This Element was told to update and we can now release all the global key // reservations of forgotten children. We cannot do this earlier because the // forgotten children still represent global key duplications if the element // never updates (the forgotten children are not removed from the tree // until the call to update happens) assert(() { _debugForgottenChildrenWithGlobalKey.forEach(_debugRemoveGlobalKeyReservation); _debugForgottenChildrenWithGlobalKey.clear(); return true; }()); _widget = newWidget; }

进入上面的Widget.canUpdate

java
  • 01
  • 02
  • 03
  • 04
static bool canUpdate(Widget oldWidget, Widget newWidget) { return oldWidget.runtimeType == newWidget.runtimeType && oldWidget.key == newWidget.key; }

可以看到判断逻辑基本与文档一致,这里有个值得注意的是:

  • Widget本身不会调用Widget.canUpdate,这个方法是由Element负责调用的,也就是Widget能不能更新,最终还是Element说了算

相信看到这里你已经明白key是啥以及它的作用了,but talk is cheap show me the code,那么我们怎么证明这理论是对的呢?下面就给出了代码demo。


什么时候会用到key

建一个demo先

下面先举一个不需要用key的例子,代码逻辑是,集合的元素顺序变更后,控件要跟着变化,代码如下:

java
  • 01
  • 02
  • 03
  • 04
  • 05
  • 06
  • 07
  • 08
  • 09
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
import 'dart:math'; import 'package:flutter/material.dart'; void main() { runApp(new MaterialApp(home: PositionedTiles())); } class PositionedTiles extends StatefulWidget { @override State<StatefulWidget> createState() => PositionedTilesState(); } class PositionedTilesState extends State<PositionedTiles> { List<Widget> tiles; @override void initState() { super.initState(); tiles = [ // StatefulColorfulTile(), // StatefulColorfulTile(), // StatefulColorfulTile(key: UniqueKey()), // StatefulColorfulTile(key: UniqueKey()), StatelessColorfulTile(), StatelessColorfulTile(), ]; } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Row( children: tiles, ), ), floatingActionButton: FloatingActionButton( child: Icon(Icons.sentiment_very_satisfied), // child: Icon(Icons.sentiment_very_dissatisfied), onPressed: swapTiles, ), ); } void swapTiles() { setState(() { tiles.insert(1, tiles.removeAt(0)); }); } } // ignore: must_be_immutable class StatelessColorfulTile extends StatelessWidget { Color color = ColorUtil.randomColor(); @override Widget build(BuildContext context) { return Container( color: color, child: Padding(padding: EdgeInsets.all(70.0)) ); } } class StatefulColorfulTile extends StatefulWidget { StatefulColorfulTile({Key key}) : super(key: key); @override State<StatefulWidget> createState() => StatefulColorfulTileState(); } class StatefulColorfulTileState extends State<StatefulColorfulTile> { Color color; @override void initState() { super.initState(); color = ColorUtil.randomColor(); } @override Widget build(BuildContext context) { return Container( color: color, child: Padding(padding: EdgeInsets.all(70.0)) ); } } class ColorUtil { 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); } }

上面的代码效果如下,可以看到使用StatelessColorfulTile时,点击按钮后两个色块能成功交换:


接下来我们把代码改成下面这样煮,重启:

java
  • 01
  • 02
  • 03
  • 04
  • 05
  • 06
  • 07
  • 08
  • 09
  • 10
  • 11
  • 12
@override void initState() { super.initState(); tiles = [ StatefulColorfulTile(), StatefulColorfulTile(), // StatefulColorfulTile(key: UniqueKey()), // StatefulColorfulTile(key: UniqueKey()), // StatelessColorfulTile(), // StatelessColorfulTile(), ]; }

神奇的事情发生了,点击按钮后,色块不再发生交换:

那在使用StatefulColorfulTile的前提下,如何让色块再次点击按钮后能发生交换呢?我猜聪明的你已经想到了,就是设置key属性,即把代码改成下面这个样子,重启:

java
  • 01
  • 02
  • 03
  • 04
  • 05
  • 06
  • 07
  • 08
  • 09
  • 10
  • 11
  • 12
@override void initState() { super.initState(); tiles = [ // StatefulColorfulTile(), // StatefulColorfulTile(), StatefulColorfulTile(key: UniqueKey()), StatefulColorfulTile(key: UniqueKey()), // StatelessColorfulTile(), // StatelessColorfulTile(), ]; }

效果如下:

接下来就是图解造成这些效果的原因了。


为啥StatelessColorfulTile能交换

我们先来看看StatelessColorfulTile交换的时候都发生了什么,先来看看交换前的:

交换后的:

当代码调用PositionedTiles.setState交换两个Widget后,flutter会从上到下逐一对比Widget树和Element树中的每个节点,如果发现节点的runtimeType和key一致的话(这里没有key,因此只对比runtimeType),那么就认为该Element仍然是有效的,可用复用,于是只需要更改Element的指针,就可以直接复用。

而由于StatefulColorfulTile的颜色信息是存储在widget中的:

java
  • 01
  • 02
  • 03
  • 04
  • 05
class StatelessColorfulTile extends StatelessWidget { Color color = ColorUtil.randomColor(); ...(略) }

所以即便色块Widget因为Widget.canUpdate返回不需要更新,内部没有回调到setState逻辑,也会成功交换。

Element保存了Widget和RenderObject,Widget是负责描述控件样式,RenderObject则是布局渲染控制,当Element只更新了Widget,下一次渲染时就会变成新Widget的效果了。


为啥StatefulColorfulTile要加key才能交换

先从代码的最表面说说StatefulColorfulTileStatelessColorfulTile一个重大的区别,即Color的属性放的位置不一样。

StatelessColorfulTile的Color属性是直接放置在Widget下的:

java
  • 01
  • 02
  • 03
  • 04
  • 05
class StatelessColorfulTile extends StatelessWidget { Color color = ColorUtil.randomColor(); ...(略) }

StatefulColorfulTile的Color属性是放在State下的:

这里补充一个基础知识,即State属性,最终都会被Element管理,下面可以简单追几段源码看看。

首先看看StateFulWidget的抽象方法:

有了Flutter三棵树概念以后,我们应该明白每个Widget最终都会被创建出对应的Element,而创建的方法正是上面的createElement,它会调用StatefulElement构造函数来构造。

接着跟进StatefulElement()函数,我们就能清晰地看到StatefulElement管理了State,并且拿它来做各种各样的事了:

明确了State属性,最终都会被Element管理这个大前提后,接下来就好办了。


我们先来看看StatefulColorfulTile不带key的时候,调用交换函数究竟发生了什么,依旧是先看交换前的:

交换后的:

相信原因不用我多说了,首先还是Widget更新后,flutter会根据runtimeTypekey比较Widget从而判断是否需要重新构建Element,这里key为空,只比较runtimeType,比较结果必然相等,所以Element直接复用。

StatefulColorfulTile在重新渲染时,Color属性不再是从Widget对象(即自身)里获取,而是从ElementState里面获取,而Element根本没发生变化,所以取到的Color也没有变化,最终就算怎么渲染,颜色都是不变的,视觉效果上也就是两个色块没有交换了。


接着看有了key之后,交换前:

交换后,发现两边key不相等,于是尝试匹配Element是否还有相同的id,发现有,于是重新排列Element让相同key的配对:

如果Element这边没有key能与新Widget匹配得上,那么旧的Element会失效,后续根据新Widget重新构建一个Element。

rebuild后,Element已改变,重新渲染后视觉上就看到两个色块交换位置了:

熟悉三棵树原理的我们知道,Element就相当于设备上的真实控件,既然Element的位置变化了,那么最终屏幕上的控件也就跟着变化了,最终交换后重新渲染给视觉上就是两个色块交换了。

好了,本篇博客先到这里结束了,这里只是简单介绍了下Widget中key的作用,但实际上Key还有很多种实现,他们用处各有不同,这个因为和本篇目标没啥太大关系,所以不介绍了,有空自己翻翻官方文档其实很快也能搞懂了。


题外话

老实说,我一开始是计划这篇博客一个下午就应该搞定了,但是实际花费的时间多了几个小时,这是有原因的:

  • ①:这次博客里的图我都是用ppt画的,以前我都直接用draw.io,但这次实际上我想搞点动画效果,最后发现我对ppt一窍不通,放弃。然后为了短暂的方便还是继续用ppt画图,下次说什么也不用了。
  • ②:有些东西我是边写边调试的,有时调试出的结果跟预期的不一样,花了不少时间去排查。