0.缘起
Flutter中每个Widget的构造方法都会有一个Key,除了标识Widget,在更新或者移动Widget中也有重要作用。Key或类似的机制,在众多GUI框架中都有影子。为解决某些特定问题,在这些框架中,能看到相似的思路,一些限制或者不同,也跟所用的语言、底层的渲染机制相关。
这里以Flutter为主线,介绍Key的同时,横向比较其他GUI框架。
1.Flutter中的Key
Flutter中每个Widget的构造方法都有一个Key,主要为了标识Widget及对应的Element,很多情况下我们并不传这个值。
根据继承关系,Key有LocalKey和GlobalKey两种, 而LocalKey又有ValueKey、ObjectKey、UniqueKey三种子类。
1.1我们使用Key可以解决什么问题?
假如一组StatefulWidget,各自维护自身的State,数据层可能只是一些纯展示类的属性,如文字、图片样式,当我们试图改变数据列表中的顺序,以期待Widget也遵照数据层发生改变时,结果可能并不如预期。
举例,一个简单的Counter计数器,State内维护自身被点击的次数。
class Counter extends StatefulWidget {
final String label;
const Counter({super.key, required this.label});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() {
_count++;
}),
child: Container(
margin: const EdgeInsets.all(8),
color: Colors.blue[200],
width: 100,
height: 100,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(widget.label),
Text(
'$_count',
style: const TextStyle(fontSize: 32),
),
],
),
),
);
}
}
在页面中摆出3个计数器,分别用['first', 'second', 'third']
来标识。在点击FloatingActionButton后,尝试切换顺序为['second', 'third', 'first']
class _MyHomePageState extends State<MyHomePage> {
List labels = ['first', 'second', 'third'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: labels.map((label) => Counter(label: label)).toList(),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() {
labels = ['second', 'third', 'first'];
}),
tooltip: 'Increment',
child: const Icon(Icons.add),
));
}
}
在初始状态下,我们分别点击几次计数器,下面的数字为点击的次数。
当我们点击点击FloatingActionButton后,尝试切换顺序为['second', 'third', 'first']
时,发现只是label顺序改变,计数器的数量并没有发生变化。
这里出现了直观的预想与实际代码表现的不一致,原因是Flutter并不会根据label的差异识别是否是相同计数器。这里做一下小的修改,修改Counter的构造函数。
Counter({required this.label}) : super(key: ValueKey(label));
直接将label包装为一个ValueKey。这时再次尝试之前的操作,发现排列确实如预期了。
这个小例子展示了Key的作用,Flutter中判断Widget是否是同一个,依据的是runtimeType和key是否相同。对于有动态刷新和移动等需求的,需要指定唯一Key。
1.2Flutter不同Key的作用
LocalKey和GlobalKey,最终要的差异是LocalKey只比较同级Widget,不去比较父级和子级的Widget。如果需求是修改了Widget Tree的结构,再使用LocalKey就可能状态丢失,因为Flutter在同级中,找不到相同Key,认为Widget被删除了,这种需求需要使用的是GlobalKey。
LocalKey
LocalKey中,三种子类,ValueKey、ObjectKey、UniqueKey。ValueKey和ObjectKey差异在依据什么判断相同。
ValueKey依照判断值相等做判断,Dart语言还是一门强类型语言,判断相等的考虑因素,跟Java中equals方法还挺像。
const ValueKey(this.value)
final T value;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType)
return false;
return other is ValueKey<T>
&& other.value == value;
}
ObjectKey中判断相同的依据除了值相当,还要求对象是同一个。
const ObjectKey(this.value);
final Object? value;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType)
return false;
return other is ObjectKey
&& identical(other.value, value);
}
UniqueKey就直接是object hash了,每次生成的都是唯一的。
class UniqueKey extends LocalKey {
UniqueKey();
@override
String toString() => '[#${shortHash(this)}]';
}
GlobalKey
GlobalKey的使用要比LocalKey更消耗性能,Flutter推荐大家尽量使用LocalKey。GlobalKey目前有两种常见用法:
- 用于Widget Tree有变化的场景,因为LocalKey只比较同级Widget。
- 作为一个跨越Widget的引用,在其他地方能够使用。
在GlobalKey中,有一些很方便的属性。
- currentContext: 可以找到包括renderBox在内的各种element有关的东西
- currentWidget: 可以得到widget的属性
- currentState: 可以得到state里面的变量
比如有些框架借助GlobalKey拿到ScaffoldMessenger,简化SnackBar操作。
2.Key机制在不同GUI框架的身影
通过上述分析,Flutter的Key大概有两种用法,一种是不同Widget标识,用于判断是否是同一个Widget以达到更新或Widget结构修改,避免状态错误或者丢失。另一种是作为一种全局的Widget引用,可以任何地方拿到Widget或State。
2.1 标识是否为相同Widget
讨论是否为相同Widget这个作用,需要再往背景挖一挖,那就是声明式UI。
在声明式UI之前,都是命令式的操作方式,如dom中的getElementById,如Android中的findViewById。这些都是通过标识拿到dom或者view,此时并没有判断Widget相同的需求。
主流GUI框架都在向着声明式UI发展,在UI= f(State)的思路之下,框架必然要面对的一个问题是,State或者说数据如何映射成为Widget。Flutter中各式各样的Widget有众多属性,同一个界面中很有可能Widget有着相同的属性,如Text中有相同的文字,Image中有相同的图片,State中甚至可能还有相同的状态。开发者感性以为的Widget及状态变化,通过数据传给框架之后,可能会出现误判,此时就需要Key来标识Widget,框架寻找新旧数据的变化,映射为控件树的变化。
这个过程可以抽象为通过新旧数据diff出变化的控件,再标脏渲染。
再深入就是如何高效的diff,如何避免频繁操作RenderObject或真实Dom,如何方便写出没有副作用的渲染函数,这些是声明式UI都要面临的问题,但不是今天讨论的主题。
2.1.1 React
React开发中,或多或少会遇到这样的警告。
Warning: Each child in an array or iterator should have a unique "key" prop.
React的创新之处,在于使用虚拟dom的diff,减少真实dom操作的消耗。key的作用就是用于diff算法中同级节点的对比策略。Flutter中的LocalKey和React中的key作用一致。React中key用于虚拟dom的diff,Flutter中key用于Widget的diff。Flutter中的Widget是非常轻量、消耗低的。
2.1.2 Compose
Compose在渲染列表中,也有可以指定Key的API,在使用LazyColumn或者LazyRow,可以传入一个生成Key的lambda表达式,这里返回类型是Any。
<T : Any?> LazyListScope.items(
items: List<T>,
noinline key: ((item) -> Any)?,
noinline contentType: (item) -> Any,
crossinline itemContent: @Composable LazyItemScope.(item) -> Unit
)
这里的Key也不需要全局唯一,在组合内唯一即可。
Compose列表中的Key还比较简单,在整个Compositioin做diff触发 Composable 函数更新要复杂的多。Compose处理的diff的树是SlotTable,实际渲染是LayoutNode,与Flutter中的Widget、RenderObject有相似的作用。SlotTable也有Key,但这个Key是编译器帮我们生成的。在编译期,会对我们的Composable函数插入很多Composer的方法。
比如一个计数器:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
}
编译后会增加很多composer方法, 也会将composer对象传递到函数体中所有的composable调用处。
fun Counter($composer: Composer) {
$composer.start(123)
var count by remember($composer) { mutableStateOf(0) }
Button(
$composer,
text="Count: $count",
onPress={ count += 1 },
)
$composer.end()
}
看Composer#start()
中的部分源码可以看到也有类似Key的判断。
//Composer.kt
private fun start(key: Int, objectKey: Any?, isNode: Boolean, data: Any?) {
//...
if (pending == null) {
val slotKey = reader.groupKey
if (slotKey == key && objectKey == reader.groupObjectKey) {
// 通过 key 的比较,确定 group 节点没有变化,进行数据比较
startReaderGroup(isNode, data)
} else {
// group 节点发生了变化,创建 pending 进行后续处理
pending = Pending(
reader.extractKeys(),
nodeIndex
)
}
}
//...
}
第一个参数即是根据代码分析生成的group唯一key。
2.1.3 传统Android UI
作为命令式GUI框架,通过Key判断是否是同一个控件并更新,场景不是那么直观。在RecyclerView中,有那么点数据驱动渲染的感觉。RecyclerView通过Adapter,将数据映射为不同的ViewHolder,触发创建或者更新,对判断为同一item而位置变化的场景,还可以设置item的移动和增删动画。在新的ListAdapter工具中,API只暴露一个submitList方法更新全量数据方法,已经具备数据驱动UI的样子。
public void submitList(@Nullable List<T> list)
RecyclerView判断是否相同有两种不同维度。
一种维度是判断是否为相同类型viewType,可以只更新数据而无需重新创建View,这在创建销毁View非常昂贵的体系中,能够带来不小的性能提升。有些麻烦的是viewType已经规定好了int类型,对于复杂或者非常动态的item场景,计算生成viewType比较麻烦。
另一种维度是判断是否为相同item,通过DiffUtil工具的 areItemsTheSame()
和 areContentsTheSame()
方法帮助RecyclerView计算哪些position发生了变化。这些机制的设计,使用起来要比Flutter中的key复杂的多。
object FlowerDiffCallback : DiffUtil.ItemCallback<Flower>() {
override fun areItemsTheSame(oldItem: Flower, newItem: Flower): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Flower, newItem: Flower): Boolean {
return oldItem == newItem
}
}
在传统Android UI之上,要提一下facebook的Litho库,也是一个声明式UI库,从React借鉴了很多概念。从这个库中能看到大家对传统UI改造的努力,里面有大量的优化手段,如异步加载、拍平布局、更新粒度的复用,但感觉生不逢时。Litho出现的时候,应该还没有kotlin和Compose,Litho最初用Java和注解处理器,创建了一套声明式UI,也是因为没有Compiler IR这种大杀器,API风格和实际使用手感稍显复杂,但响应式的理念是贯彻的最彻底的,因为Litho生成的都是static静态方法,很难出现副作用。在Android逐渐推广Compose的过程中,Litho未来很难有一席之地,但其中优化手段,已经被很多大厂吸收。Litho还在不断迭代,期待有新的变化。
在Litho中,列表更新同样有类似Flutter Key的机制。对于可更新的列表,也要求有能够唯一标识的id,如在LazyListScope中的方法,如果child方法不传id,会根据position生成一个,在children方法中,则需要根据item返回一个id。
fun child(
component: Component?,
id: Any? = null,
isSticky: Boolean = false,
onNearViewport: OnNearCallback? = null
)
fun <T> children(
items: Iterable<T>,
id: (T) -> Any,
componentFunction: ComponentCreationScope.(T) -> Component?
)
2.2全局引用
Flutter中的GlobalKey可以让开发者在其他地方获取到控件进行状态更新,用法显得不那么声明式,完全时命令式的风格,但有些情况还是很方便。
类似Android中findViewById,DOM中getElementById。Flutter通过GlobalKey能获取State、Widget、RenderObject,获取数据或者渲染参数如控件宽高。
在React中,全局引用的控件似乎并没有对应的场景,稍微有点神似的概念是ref,能在React中能脱离数据流props,命令式的操作子组件和DOM。
ref如果进行DOM增删等操作,会脱离React管控,所以一直有不要滥用ref的说法。现在React推荐使用forwardRef和useImperativeHandle,forwardRef的跨层传递和合并转发也有那么一点点破坏单向数据流的原则。