题外话
本文这个标题其实让我想起了电影《蝙蝠侠大战超人:正义黎明》中的一句话,在该片中,从未来穿越回来的闪电侠对蝙蝠侠发出警告: It’s Lois! She’s the key!
【布鲁斯,是露易丝!露易丝是关键!】
【你是对的,你对他的看法一直都是对的……】
【我来得太早了吗?我来得太早了……】
【敬畏他!敬畏他!】
【你一定要找到我们!你必须要找到我们!……】
这是闪电侠借助神速力从未来的某个时间点穿越回来,他是来警告蝙蝠侠的。从前几句话可以推断出,在未来的时间线里,因为露易丝出事了,超人可能成为了某种可怕的存在,正义联盟根本没人能拦得住他,所以闪电侠只能回到过去告诉战友们提前做好准备,不能让超人堕落成这种样子。不料他穿越回来的时候发现蝙蝠侠还不认识他,所以他就知道自己来得太早了(因为这个时间点正义联盟还没有建立)。他知道只有正义联盟有能力保护好超人和他的家人,所以他最后恳求蝙蝠侠一定要找到他们并组建正义联盟。这段剧情应该是改编自漫画和游戏《不义联盟:人间之神》。
剧情回忆: 在电影《蝙蝠侠大战超人:正义黎明》中,正当蝙蝠侠暴揍大超之时,超人提到了玛莎,他让蝙蝠侠去救玛莎,愤怒的蝙蝠侠质问超人为什么要提起这个名字,这时一旁的超人女友及时赶到,她告诉蝙蝠侠那是他母亲的名字。因为蝙蝠侠的妈妈也叫玛莎,由于蝙蝠侠当年自己还是年幼的时候因为没能救下自己妈妈,而亲眼目睹了自己的母亲玛莎·韦恩在自己面前遭遇劫匪杀害,所以当超人叫蝙蝠侠救玛莎时,就想到了自己妈妈。而这时,蝙蝠侠也想起了之前闪电侠对他的警告,顿时恍然大悟,这才明白过来,自己差点犯下大错,假如他没能救出超人的母亲玛莎,那么之后超人将会黑化,世界就会变成蝙蝠侠之前梦境中的那般由黑化的超人所统治,世上再也无人能够阻止超人。
好了,下面开始进入本文正题。
为什么 key 非常重要
我们先看一个例子,现在有一个自定义的 FancyButton
组件,代码如下:
import 'dart:math';
import 'package:flutter/material.dart';
class FancyButton extends StatefulWidget {
const FancyButton({
Key? key, required this.onPressed, required this.child})
: super(key: key);
final VoidCallback onPressed;
final Widget child;
@override
State<FancyButton> createState() => _FancyButtonState();
}
class _FancyButtonState extends State<FancyButton> {
@override
Widget build(BuildContext context) {
return ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(_getColors()),
),
onPressed: widget.onPressed,
child: Padding(
padding: const EdgeInsets.all(30),
child: widget.child,
),
);
}
// 以下代码用于生成随机背景色,以确保每个Button的背景色不同
Color _getColors() {
return _buttonColors.putIfAbsent(this, () => _colors[next(0, 5)]); // map中不存在就放入map, 否则直接返回
}
final Map<_FancyButtonState, Color> _buttonColors =
{
}; // 注意,这里使用了一个Map保存当前State对应的Color
final Random _random = Random();
int next(int min, int max) => min + _random.nextInt(max - min);
final List<Color> _colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.purple,
Colors.amber
];
}
FancyButton
很简单,只是包装了一下ElevatedButton
组件,它在内部会自己管理背景色,为了使每个 FancyButton
的背景色不同,这里使用了随机数来获取一个随机的颜色,并且使用Map
来缓存颜色,这样下次可直接从Map
中获取而不用每次计算。
下面基于 Flutter 默认的计数器示例应用使用上面的 FancyButton
进行改造,我们在页面上添加两个 FancyButton
按钮,点击的时候分别用来加减counter
值,另外我们添加一个“Swap”按钮,点击的时候可以交换页面上的两个 FancyButton
,页面要实现的静态效果如下:
实现代码如下:
class FancyButtonPage extends StatefulWidget {
const FancyButtonPage({
Key? key}) : super(key: key);
@override
State<FancyButtonPage> createState() => _FancyButtonPageState();
}
class _FancyButtonPageState extends State<FancyButtonPage> {
int counter = 0;
bool _reversed = false;
void resetCounter() {
setState(() => counter = 0);
swapButton();
}
void swapButton() {
setState(() => _reversed = !_reversed);
}
@override
Widget build(BuildContext context) {
final incrementButton = FancyButton(
onPressed: () => setState(() => counter++),
child: const Text(
"Increment",
style: TextStyle(fontSize: 20),
));
final decrementButton = FancyButton(
onPressed: () => setState(() => counter--),
child: const Text(
"Decrement",
style: TextStyle(fontSize: 20),
));
List<Widget> buttons = [incrementButton, decrementButton];
if (_reversed) {
buttons = buttons.reversed.toList();
}
return Scaffold(
appBar: AppBar(title: const Text("FancyButton")),
body: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 30),
Text("$counter", style: const TextStyle(fontSize: 22)),
const SizedBox(height: 30),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttons,
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: resetCounter, child: const Text("Swap Button"))
],
),
);
}
}
运行后测试效果:
这时你会发现一个奇怪的现象:当按下交换按钮时,上面两个按钮确实会交换位置,但是只有按钮的文字被交换了,背景色却没有变,而点击按钮时对应的功能正常。也就是说,左边的按钮与交换前的背景颜色相同,即使按钮本身是不同的。这是为什么呢?这明显不是我们期望的,我们期望的是两个按钮可以“真正的”对调位置。但这只换了一半是几个意思?
Element Tree 和 State
在解释这个问题之前我们先来了解一下关于 Element Tree
和 State
的几个概念:
-
State
对象实际上是由Element Tree
管理的。(确切的说是由StatefulElement
创建并持有) -
State
对象是长期存在的。与Widget
不同,每当Widget
重新渲染时,它们都不会被销毁和重新构建。 -
State
对象是可以被重用的。 -
Element
引用了Widget
。同时State
对象也会保存对Widget
的引用,但这种持有不是永久的。
Element
很简单,因为它们只包含元信息和对Widget
的引用,但它们也知道如果一旦Widget
发生更改,自己该如何更新对不同Widget
的引用。
当Flutter决定在调用build
方法进行重建和重新渲染时,一个element
将会查找与它引用的前一个Widget
完全相同的位置处的Widget
。
然后,它将决定Widget
是否相同(若相同,它不需要做任何事情),或者Widget
是否发生了变化,或者它是一个完全不同的Widget
(若完全不同,它需要重新被渲染)。
但问题是Element
是根据什么来判断更新的内容,它们只查看Widget
上的几个属性:
-
在运行时的确切类型(runtimeType)
-
一个
Widget
的key
(如果有的话)
其实也就是 Flutter 执行 Build 重建流程中Element
源码中 updateChild()
方法的逻辑:
// flutter/lib/src/widgets/framework.dart
Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
// Element
if (newWidget == null) {
if (child != null) {
deactivateChild(child);
}
return null;
}
final Element newChild;
if (child != null) {
...
if (hasSameSuperclass && child.widget == newWidget) {
if (child.slot != newSlot) {
updateSlotForChild(child, newSlot);
}
newChild = child;
} else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
if (child.slot != newSlot) {
updateSlotForChild(child, newSlot);
}
...
child.update(newWidget);
...
newChild = child;
} else {
deactivateChild(child);
newChild = inflateWidget(newWidget, newSlot);
}
} else {
newChild = inflateWidget(newWidget, newSlot);
}
return newChild;
}
该方法主要逻辑总结如下:
其中的Widget.canUpdate()
方法的源码如下:
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
本例中交换的两个FancyButton
对象是内存地址完全不同的两个实例对象,因此肯定会直接执行上面updateChild()
方法逻辑的情况2,排除情况1。也就是会执行 canUpdate
方法的逻辑判断。
我们知道,Widget Tree 只是 Element Tree 的映射,它只提供描述UI树的配置信息,而在本例中,这些Widget
的颜色不在Widget
的配置中;它们保存在对应Widget
的State
对象中。Element
指向更新的Widget
并显示新的配置,但仍然保留原始State
对象。因此,当Element