Flutter布局指南之谁动了我的Key

2,065 阅读10分钟

Key用来干嘛

Flutter中的Key,一直都是作为一个可选参数在很多Widget中出现,那么它到底有什么用,它到底怎么用,本篇文章将带你从头到尾,好好理解下,Flutter中的Key。

我们首先来看下面这个Demo:

Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Container(
      width: 100,
      height: 100,
      color: Colors.red,
    ),
    Container(
      width: 100,
      height: 100,
      color: Colors.blue,
    ),
  ],
)

image-20220227203558343

展示为两个不同颜色的方块。

问题1

这时候,如果我们在代码中交换两个Container的位置,Hot reload之后,它们的位置会发生改变吗?

下面我们把Demo修改一下,将Container抽取出来,并在中间放一个Text用来做计时器,并改为StatefulWidget,代码如下。

class KeyBox extends StatefulWidget {
  final Color color;

  KeyBox(this.color);

  @override
  _KeyBoxState createState() => _KeyBoxState();
}

class _KeyBoxState extends State<KeyBox> {
  var counter = 0;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 100,
      height: 100,
      color: widget.color,
      child: Center(
        child: TextButton(
          onPressed: () {
            setState(() => counter++);
          },
          child: Text(
            counter.toString(),
            style: const TextStyle(fontSize: 60),
          ),
        ),
      ),
    );
  }
}
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    KeyBox(Colors.yellow),
    KeyBox(Colors.green),
  ],
)

这样当我们点击计时器工作之后,展示如下。

image-20220227203642652

问题2

这时候,如果我们在代码中交换两个Container的位置,Hot reload之后,它们的数字会发生改变吗?

问题3

如果我们删掉第一个Widget,Hot reload之后,显示的是数字几?

问题4

如果我们再重新把删掉的Widget加回来,Hot reload之后,又会如何显示?

问题5

如果在问题2的基础上,给第一个Widget外新增一个Center,那么又会如何显示呢?

如果你能完全回答上面的这几个问题并知道为什么,那么恭喜你,看完这篇文章,你会浪费十几分钟,当然,如果你不清楚,那么这十几分钟的时间,将给你带来不小的收益。

Key是什么

Flutter通过Widget来渲染UI,那么它是如何区分上面的两个不同颜色的Container的呢?通过颜色吗?当然不是,如果Container的颜色相同,那岂不是无法区分了?

所以,Key就成了Flutter区分不同Widget的依据,这就好比是Android中布局的ViewID。

知道Key是什么还不够,我们还得知道,我们为什么需要Key,首先,我们来看下上面的三个问题。

对于问题1,这个应该很简单了,Container是StatelessWidget,所以每次Hot reload都会重新build,因此颜色肯定会发生互换,这个很好理解。

那么对于问题2呢?StatelessWidget改成了StatefulWidget,这次再交换两个Widget的位置,你可以发现,虽然颜色互换了,但是数字没变。

要怎么解决这个问题呢?这就需要用到Key了,我们给KeyBox增加一个Key的参数。

新的Flutter Lint已经会提示你构造函数需要增加key的可选参数了。

const KeyBox(this.color, {Key? key}) : super(key: key);

在使用的地方,传入ValueKey即可。

KeyBox(Colors.yellow, key: ValueKey(2)),
SizedBox(height: 20),
KeyBox(Colors.cyan, key: ValueKey(1)),

这时候你再切换两个Container的位置,数字就会跟着变换了。

Key的原理

Key实际上是Flutter用来标记Widget的唯一标识,但是为什么需要Key,就要从Flutter的渲染流程上说起了。

Widget作为Flutter中的不可变数据,是作为渲染的数据类而存在的,它实际上就是内容的配置表,根据View的树形结构,自然而然模拟出了一个Widget Tree的概念。

Widget在运行时会创建Element实例,这些Element和Widget也组成了一一对应的关系,对于StatefulWidget来说,Widget中包含了组件的外观、位置等信息,而Element中,包含了State信息,这也是Flutter的核心原理。所以,在上面的Demo中,Counter作为State,被保存在Element中,而颜色,被保存在Widget中。

Widget和Element分离之后,如果修改颜色等Widget属性,那么可以直接创建新的Widget替换旧的Widget,同时还可以保留Element中的数据,因为创建Widget的成本是很低的,而Element则会高很多,所以Element会持续尽可能长的时间。

那么在Widget被改变之后,Element是如何和Widget进行关联的呢?这就需要两个东西了:

  • runtimeType
  • Key

所以Element会先对比当前新的Widget Tree中的新元素,是否跟当前Element的类型一致,如果不一致,那么说明Element已经无效了,只能重新创建,如果类型一致,那么就需要进一步判断Key了。

问题2的原因

所以,在问题2中,由于两个Widget的类型并没有发生变化,而又没有Key,所以,Widget被重新创建后,与原来的Element又关联起来了,看上去就是只修改了颜色。

那么在问题2的解法中,我们给Widget增加了Key,当我们调换两个Widget的位置时,虽然类型没有改变,但是Key发生了改变,Element在原来的位置找不到对应的Widget,那么这时候,它会选择在当前层级下,继续搜索这个Key。

这里要注意,Element只会在当前层级下搜索,如果这个Key的Widget被移入了其它层级,那么也是无法找到的,在问题2的场景下,由于只是交换了两个Widget的顺序,所以Element会在后面找到之前Key的Widget,同理,下一个Element也会找到,所以,两个Widget都被关联起来了,所以State也显示正确了。

问题3的原因

那么在问题3中,我们删除了第一个Widget,当没有Key时,Element会在Widget Tree中搜索,当它发现第二个Key类型是一样的时,它就以为它找到了,而第二个Element,因为找不到Widget,就销毁了。最终的效果就是剩下第二个Box的颜色和第一个Box的数字。

那么如果有Key呢?有Key的话,就不会找错了啊,所以自然能够对应上,与我们预想的也就是一样的了。

问题4的原因

理解了问题3,那么问题4就好理解了。当我们在开头创建同一个类型的Widget时,Element会把这个新增的Widget当作是以前的Widget,因为它们类型相同,所以Element被关联到了这个新的Widget,而另一个Widget发现已经没有Element了,所以会选择新建一个Element,这时候,数字就是默认值0了。

问题5的原因

对于问题5来说,实际上就是Element的搜寻机制,前面解释了,Element只会在当前层级进行搜索,所以Center的加入,改变了Widget的层级,Element无法对应了,所以它也选择了消耗重建,所以第一个Widget会显示默认值0。

但是要注意的是,如果类型不一致,那么Flutter会直接判断不相同,从而直接消耗重建,所以,在这些问题里,如果在KeyBox之间插上一些不同类型的Widget,那么就瞬间破防了,演示的效果就完全不同了。

Key有哪些Key

Key从整体上来说,分为两种,即:

  • Local Key:分为Value Key、Object Key和Unique Key
  • Global Key

Local Key顾名思义,指的是在当前Widget层级下,有唯一的Key属性,而Global Key,则是在全局APP中,具有唯一性。Global Key的性能会比Local Key差很多。

Value Key

在前面的Demo中,我们给KeyBox增加了Key之后,Widget在修改、移动之后,Element就可以正确的找到对应的Widget了,这里我们使用的是Value Key。

Value Key,顾名思义,就是使用Value来对Key做标识的Key,例如我们在Demo中使用的,ValueKey(1),value可以是任意类型,这里是1,其实更符合的场景,应该是用Color,或者是更加具有语义性的value来作为Key的value。

Value Key在同一层级下需要具有唯一性,所以当两个KeyBox都设置成ValueKey(1)时,程序就会报错,告诉你Key重复了。

Object Key

Object Key与Value Key类似,但是又不完全一样,Value Key对比的是Value,Value相等,就是相等,而Object Key,对比的是实例,实例相同,才是相等,就好比一个Java中的equals,一个是「==」。我们看下Object Key的源码就一目了然了。

@override
bool operator ==(Object other) {
  if (other.runtimeType != runtimeType)
    return false;
  return other is ObjectKey
      && identical(other.value, value);
}

假如我们有一个自定义的Class,重写了它的==函数,那么用Value Key,new两个同样的对象,它们就是相等的,而Object Key,则不相等,原因就是一个比较的是值,一个比较的是引用。

Unique Key

Unique Key自己都说了,它是独一无二的,也就是说,Unique Key只和自己相等,任意创建多个Unique Key,都是不相等的,相当于唯一标识了。

如果在Build函数中创建Unique Key,那么这个Key在大部分场景下就没有意义,因为Hot reload时,Build函数会重建,所以Unique Key被重建,而且和之前也不相等。

这就很奇怪了,这玩意有什么用呢?

用处确实不多,但一旦用到,就必须得用,例如下面这个例子。

假如我们要用AnimatedSwitcher来实现切换时的动画效果,这时候,我们需要让每次改变都要执行动画,那么这里就可以使用Unique Key,强制每一次都是新的Widget,这样才能有动画效果。

那么另一种使用场景,就是在无法使用Value Key和Object Key的时候使用,但是这时候,需要将Unique Key定义在Build函数之外,这样Unique Key只会创建一次,从而保证唯一性的同时,不用去创建value和Object。

Global Key

Global Key全局唯一且只和自己相等,还记得之前Element在关联新变化的Widget时是怎么比较Key的吗——Element为了效率问题,只会在当前层级下进行寻找,所以,在问题5中,一旦我们修改了某个Widget的层级,那么Element就会消耗重建,那么如果使用了Global Key呢?当Key的类型是Global Key时,Element会不惜代价在全局寻找这个Key,这也是为什么Global Key的效率会比较低的原因。

那么有了Global Key,即使Widget Tree发生了改变,也依然可以找到这个Widget进行关联,但是要注意的是,Global Key需要定义在Build函数之外,否则每次都会重新创建Global Key,那就没有意义了。

除此之外,Global Key还有一个作用,那就是给一个Widget增加一个全局标识,这样有点像命令式编程的意思,类似Android中的FindViewByID,通过Global Key就可以找到当前标记的这个Widget,从而获取它的一些相关信息。

final count = (globalKey.currentState as _KeyBoxState).counter;
print('count: $count');
final color = (globalKey.currentWidget as KeyBox).color;
print('color: $color');
final size = (globalKey.currentContext?.findRenderObject() as RenderBox).size;
print('size: $size');
final position = (globalKey.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero);
print('position: $position');

// output
flutter: count: 0
flutter: color: MaterialColor(primary value: Color(0xff4caf50))
flutter: size: Size(100.0, 100.0)
flutter: position: Offset(145.0, 473.5)

由此可见,通过Global Key,我们可以拿到State、Widget、Element(Context)以及通过Element关联的RenderObject,这样就可以获取Widget中的一些配置参数,State中的数据变量,以及RenderObject中的绘制信息,例如尺寸、位置、约束等等。

向大家推荐下我的网站 xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问