”比较文学“:Flutter的Key及React、Compose、Android的同类比较

0.缘起

Flutter中每个Widget的构造方法都会有一个Key,除了标识Widget,在更新或者移动Widget中也有重要作用。Key或类似的机制,在众多GUI框架中都有影子。为解决某些特定问题,在这些框架中,能看到相似的思路,一些限制或者不同,也跟所用的语言、底层的渲染机制相关。
这里以Flutter为主线,介绍Key的同时,横向比较其他GUI框架。

1.Flutter中的Key

Flutter中每个Widget的构造方法都有一个Key,主要为了标识Widget及对应的Element,很多情况下我们并不传这个值。
根据继承关系,Key有LocalKeyGlobalKey两种, 而LocalKey又有ValueKeyObjectKeyUniqueKey三种子类。

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的跨层传递和合并转发也有那么一点点破坏单向数据流的原则。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容