美文网首页
”比较文学“:Flutter的Key及React、Compose

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

作者: 刀客666 | 来源:发表于2023-01-21 21:35 被阅读0次

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

    相关文章

      网友评论

          本文标题:”比较文学“:Flutter的Key及React、Compose

          本文链接:https://www.haomeiwen.com/subject/pjahhdtx.html