深入 Flutter 和 Compose 在 UI 渲染刷新时 Diff 实现对比

众所周知,不管是什么框架,在前端 UI 渲染时,都会有构造出一套相关的渲染树,并且在 UI 更新时,为了尽可能提高性能,一般都只会进行「差异化」更新,而不是对整个 UI Tree 进行刷新,所以每个框架都会有自己的 Diff 实现逻辑,而本篇就是针对这部分实现进行一个简单对比。

Flutter

首先聊聊 Flutter ,众所周知,Flutter 里有三棵树:Widget Tree 、Element Tree 和 RenderObject Tree ,由于 Flutter 里 Widget 是不可变(类似 React 的 immutable) 的设定,所以 Widget 在变化时会被重构成新 Widget ,从而导致 Widget Tree 并不是真正 Flutter 里的渲染树。

所以 Widget Tree 在 Flutter 里更多只是「配置信息树」,实际工作的还是 Element Tree ,或者说,Element Tree 才是正式的 UI Tree 实体,只是它并不负责渲染,负责布局和渲染的是对应的 RenderObject Tree 。

当然,今天我们聊的是 「 Diff 实现」 ,所以我们的核心需要聚焦在 Element ,因为正是它负责了控件的「生命周期」和「创建/更新」逻辑,Element 作为 “大脑” 沟通着整个 UI 渲染流程。

所以今天 Flutter 主要的话题应该是围绕在 Element ,我们知道 Widget 在加载之初就会创建出一个 Element ,然后根据传入的 key 和 runtimeType ,会决定 Widget 变化时 Element 是否可以复用,而复用 Element 就是 Flutter 里 「Diff 机制」 的关键:

我们先简单看一个时序图,大致就是 setState() 所作用的一个流程,其中:

  • setState() 过程就是记录所有的脏 Element 的过程,它会执行 Element 内的 _dirty = true
  • 然后 Element 会添加自己到 BuildOwner 对象的 _dirtyElements 成员变量
  • 最后 buildScope 会对 _dirtyElements 进行 Element._sort 排序,然后触发 rebuild :

所以整个更新流程,会跳过干净的 Element,只处理脏 Element,同时在构建阶段,信息在 Element 树中单向流淌,每个 Element 最多被访问一次,而一旦被“清理”,Element 就不会再次变脏,因为可以通过归纳,它的所有祖先元素也是干净的。

然后就是 Flutter 里经典的 Linear reconciliation ,在 Flutter 里没有采用树形差异算法,而是通过使用 O(N) 算法独立检查每个 Element 的子列表来决定是否复用 Element。

当框架能够复用一个 Element 时,用户界面的逻辑状态将被保留,并且之前计算的布局信息也可以被重用,从而避免整个子树遍历。

是否复用 Elmenet 主要是在 Element 的 updateChildren 里去判断,而在整个 updateChildren 的实现里,首先我们需要理解下面这段代码:

  if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget)) {break;}static bool canUpdate(Widget oldWidget, Widget newWidget) {return oldWidget.runtimeType == newWidget.runtimeType&& oldWidget.key == newWidget.key;}

这里的判断逻辑很好理解:

  • 没有 oldChild 存在,那还更新什么,肯定不用更新了,直接走创建
  • canUpdate 主要是判断 runtimeType 或者 key 是否一致,如果不一致那就不是同一个 Widget 了,那也没办法复用

剩下的基本就是基于这判断条件的细分查找实现,在 updateChildren 函数里主要是处理两个列表的更新,即 List<Element> oldChildren 和 List<Widget> newWidgets ,当然,本质上其实是处理这两个 Element 列表的 Diff ,整体流程上:

  • 从顶部和尾部进行快速扫描和同步:

    • 先从头部往下,直到「没有 oldChild 或者 canUpdate 条件不满足」的地方就停止,在这个过程里符合更新的通过 updateChild 得到一个能更新的 newChild Element 列表

    • 从底部往上,快速扫描到没有「 oldChild 或者 canUpdate 条件不满足」的地方,停止扫描,底部扫描这里不处理更新,只是为了快速确定到中间区域

  • 处理中间区域

    • 在中间区域还存在的 oldChild 放到一个 <Key, Element>{} 的 oldKeyedChildren 对象里,用于后续快速匹配,因为只要 Key 相同,即使后面位置发生变化,也可以继续复用对应的 Element

    • 扫描中间区域,根据前面得到的 oldKeyedChildren 提取出来 oldChild, 如果 oldChild 不存在就直接创建新 Element ,如果存在且符合 canUpdate ,则更新 Element

  • 直接更新剩下未处理的底部区域,因为剩下的这部份肯定是能更新的

  • 清理掉前面的 oldKeyedChildren 残留

  • 返回全新 Element 列表

如果你觉得这样说比较抽象,那么我们看一个简单的例子,首先我们有 new 和 old 两个列表,按照上面流程,一开始从上往下更新,当 new 的 top 指针遍历到 a 时,因为不符合条件会停下来,然后我们得到了一个 newChildren 列表,里面是前面已经遍历更新的 1 和 2 :

然后就是第二步,从下往上扫描,这里不做任何更新,之后遇到 c 位置后,不符合条件无法更新,停下来,此时 bottom 指针停在了 c:

接着开始处理中间部份,old 的 top 指针逐步遍历到 bottom 位置,先从 old 列表得到一个 <Key, Element>{} 的 oldKeyedChildren 对象用于后续快速匹配,过程中如果 key == null,则可以提前释放掉对应 old Element :

image-20250106233732147

根据前面中间区域得到的 oldKeyedChildren 提取出来的 oldChild,在 new 的 top 指针继续往下遍历时, 如果 oldChild 不存在就直接创建新 Element ,如果存在且符合 canUpdate ,则更新 Element :

最后更新之前没处理的底部的 Element ,然后清空 oldKeyedChildren 并释放 old Element :

这里为什么底部最后一开始遍历时不更新?因为此时需要的 slot 信息不存在。

所谓 slot,主要是维护子元素在父元素中的逻辑位置,用于确保子元素的渲染顺序与逻辑顺序一致,并且在子元素顺序发生变化时,通过重新分配槽位触发 RenderObject 的位置更新。

Object? slotFor(int newChildIndex, Element? previousChild) {return slots != null? slots[newChildIndex]: IndexedSlot<Element?>(newChildIndex, previousChild);
}

它主要出现在于每次 updateChild(oldChild, newWidget, slotFor(newChildrenTop, previousChild)) 时的更新:

而前面开始扫描底部的时候,没有直接先 updateChild 的原因也是在于:那个时候是从底部开始扫描,而 slot 需要的是一个「previousChild」,也就是前一个节点的引用,它需要自上而下的顺序迭代,所以 一开始 bottom 扫描的时候,并没有前置节点 slot ,所以当时只能是扫描,没更新动作。

自此整个更新流程就 update 完成,另外如果是 InheritedWidget 的更新,框架会通过在每个 Element 上维护一个 _inheritedElements 哈希表来向下传递共享 Element 信息,从而避免避免父链的遍历。

可以看到,Flutter 主要是基于 Key 和 runtimeType 的线性对账处理。

Compose

来到 Compose ,因为 Compose 的渲染更新逻辑更复杂,因为它涉及了很多模块系统,这里我们用最简洁的理解快速过一遍,详细参考后面放的链接。

我们知道 @Composable 注释是 Compose 的基本构建模块:

当然,在编译 Composable 函数时,Compose 编译器会更改所有 Composable 函数,它会在编译的 IR 阶段为函数参数添加 Composer,就像前面我们聊 Kotlin suspend 生成 Continuation 一样:

///我们的代码
@Composable
fun MyComposable(name: String) {
}
///编译后添加了 composer 
@Composable
fun MyComposable(name: String, $composer: Composer<*>, $changed: Int) {

所以也和 suspend 一样,普通函数也不能调用 Composable 函数,而 Composable 函数可以调用普通函数。

而这里就出现了两个参数:

  • Composer :创建 Node、通过操作 SlotTable 的来创建和更新 Composition
  • changed: 根据 int 里的状态判断是否可以跳过更新,比如静态节点,还有是否状态变化情况

我们知道 @Composable 函数并不是和 Flutter 一样 return ,所以实际工作中,Compose 代码在编译时会给 @Composable 函数添加参数 ,而实际的 UI Node Tree 等的创建,都是从 Composer 开始:

简单解释下:

  • changed 部份提前判断是否支持跳过,它的判断基本都是各种位的 and 操作,重点是它会影响后面 $dirty 判断是否参与重组
  • State 会变成各种副作用
  • Composer.startxxxxGroup ~ Composer.endxxxxGroup 会创建出 Node 和 SlotTable
  • updateScope 部份记录生成 snapshot 用于 diff 对比

而在 Compose 里也有两颗树:

  • 一颗 Virtual 树 SlotTable 用于记录 Composition 状态
  • 一颗 UI 的树 LayoutNode 负责测量和绘制等逻辑

其实从 .startxxxxGroup 到 endxxxxGroup 整个部分构造的两个树里,SlotTable 就是 Diff 渲染更新的重点。

Composable 里所产生的所有信息都会存入 SlotTable, 如 State、 key 和 value 等 ,而 SlotTable 支持跨帧「重组」,「重组」的新数据也会重新更新 SlotTable。

事实上 SlotTable 的表现形式是用线性数组来表达一棵树的语义,因为它并不是一棵树的结构:

internal class SlotTable : CompositionData, Iterable<CompositionGroup> {/*** An array to store group information that is stored as groups of [Group_Fields_Size]* elements of the array. The [groups] array can be thought of as an array of an inline* struct.*/var groups = IntArray(0)private set/*** An array that stores the slots for a group. The slot elements for a group start at the* offset returned by [dataAnchor] of [groups] and continue to the next group's slots or to* [slotsSize] for the last group. When in a writer the [dataAnchor] is an anchor instead of* an index as [slots] might contain a gap.*/var slots = Array<Any?>(0) { null }private set

在 SlotTable 有两个数组成员,groups 数组存储 Group 信息,slots 存储数据,而 Group 信息根据 Parent anchor 模拟出一个树,而 Data anchor 则承载了数据部分,同时因为 groups 和 slots 不是链表,所以当容量不足时,它们可以进行扩容:

而这里的 key 其实非常重要,在插入 startXXXGroup 代码时,Compose 就会基于代码位置生成可识别的 $key,并在首次「组合」时 $key 会随着 Group 存入 SlotTable,而在「重组」里,Composer 可以基于 $key 的识别出 Group 的增、删或者位置移动等信息。

另外重组的最小单位也是 Group,比如在 SlotTtable 上,各种 Compose 函数或 lambda 会被打包位 RestartGroup 。

Group 类型有很多。

而 Snapshot 则是观察状态变化的实现,因为 state 的变化会带来「重组」,比如当我们使用 mutableStateOf 创建一个 MutableState 时,会创建一个「快照」,它会在 Compose 执行时注册相关 Observer :

有了快照,我们就有了所有状态的信息,也就是可以 diff 两个状态去对比更新,从而得到新的 SlotTable:

所以在「重组」时,就可以根据 changed 状态和 SlotTable 数据(key、data等)去判断,从而得到一个 change list :

最后 applyChanges 会对 changes 遍历和执行, 生成新的 SlotTable,SlotTable 结构的变化最后会通过 Applier 更新到对应的 LayoutNode:

这里需要注意的是:

  • 如果 Compose 的重组被中断,那么其实 Composable 中执行的操作并不会真正反映到 SlotTable,因为 applyChanges 需要发生在 composiiton 成功结束之后
  • startXXXGroup 中会操作 SlotTable 中的 Group 进行 Diff,这个过程产生的「插入/删除/移动」等过程都是基于 Gap Buffer 实现,可以简单理解为 Group 中的未使用的区域,这段区域支持在 Group 里移动,从而提升 SlotTble 变化时的更新效率:

Gap Buffer 也就是减少每次操作 Node 时导致 SlotTable 中已有 Node 的移动。

如果单从 Diff 部分考虑,其实就是 startXXXGroup 会对 SlotTable 进行遍历和 Diff ,根据 Group 的位置,key,data 等信息进判断,而其更新来源在于状态变化时的 Snapshot 系统,最后得到的差异化 SlotTable 会更新到真实的 LayoutNode:

image-20250110135241771

可以看到整个流程上 Compose 的「重组」diff 涉及很多模块和细节,即比如 $changed 标识位都可以单独聊一整篇,而在 Compose 里,这里的一切开发者其实在外部都感受不到,而它的实现核心,就是来自编译后的 Composer 内部构建的基于 gap 数组的 slotTable,整体上感觉和 React 的 Virtual DOM 相似:

如果你对详细的 Compose 渲染和更新实现好奇,建议再看看参考链接里的详细内容。

最后简单总结一下:

  • Flutter 采用一套自定义线性对账算法替代树形对比,通过最小化查找把 Widget 配置信息更新到 Element 里

  • Jetpack Compose 使用 gap buffer 数据结构更新所需的 SlotTable,并且 SlotTable 会是在重组完成后才被 applyChange,最终差异部分才会通过 Applier 更新到对应的 LayoutNode

参考链接

  • https://docs.flutter.dev/resources/inside-flutter
  • https://juejin.cn/post/7113736450968911908
  • https://github.com/takahirom/inside-jetpack-compose-diagram/blob/main/diagram.png

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.tpcf.cn/diannao/67016.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

http和https有哪些不同

http和https有哪些不同 1.数据传输的安全性&#xff1a;http非加密&#xff0c;https加密 2.端口号&#xff1a;http默认80端口&#xff0c;https默认443端口 3.性能&#xff1a;http基于tcp三次握手建立连接&#xff0c;https在tcp三次握手后还有TLS协议的四次握手确认加密…

html辅助标签与样式表

一、HTML其它常用标签 1.meta标签 &#xff08;1&#xff09;meta标签是一个特殊的HTML标签&#xff0c;提供有关网页的信息&#xff0c;如作者姓名、公司名称和联系信息等 &#xff08;2&#xff09;许多搜索引擎都使用meta标签 <head> <meta name"keyword…

中等难度——python实现电子宠物和截图工具

import io # 文件处理 import nt # windows nt 库直接获取对应的磁盘空间 import time # 时间 import zlib # 加解密 import ctypes # 调用 import struct # 处理字节二进制 import base64 # 编解码 import threading # 线程 import tkinter as tk # tk from datetime…

3D目标检测数据集——Nusence数据集

链接地址 [官网] nuScenes[arXiv] nuScenes: A multimodal dataset for autonomous driving[GitHub] nuScenes devkitnuScenes devkit教程数据集概述 2.1 数据采集 2.1.1 传感器配置 nuScenes的数据采集车辆为Renault Zoe迷你电动车,配备6个周视相机&#x

lerna使用指南

lerna版本 以下所有配置命令都是基于v8.1.9&#xff0c;lerna v5 v7版本差别较大&#xff0c;在使用时&#xff0c;注意自身的lerna版本。 lerna开启缓存及缓存配置 nx缓存是v5版本以后才有的&#xff0c;小于该版本的无法使用该功能。 初始化配置 缓存配置文件nx.json&am…

neo4j 安装 (提供镜像下载方式

neo4j 安装 docker # 获取镜像 docker pull neo4j:community# 启动 docker run \--publish7474:7474 --publish7687:7687 \neo4j:communityfile download 文件下载的方式&#xff0c;访问 URL&#xff1a;https://neo4j.com/deployment-center/ 然后选择对应的平台&#xff…

用Ingress生成route,如何让生成route的insecureEdgeTerminationPolicy 的值为Allow

对于此功能当前还没有实现&#xff0c;相关的新功能添加的 Jira ticket 如下&#xff1a; Annotate the ingress to create the route with the spec.tls.insecureEdgeTerminationPolicy set to Allow 目前已经实现的功能为termination&#xff0c;insecureEdgeTerminationPol…

JAVA安全编码规范

1. 数据校验 对外部输入进行校验入参的合法性&#xff0c; 防止内存越界&#xff0c;命令注入&#xff0c;SQL注入&#xff0c;格式化字符串漏洞 校验长度&#xff0c;范围&#xff0c;输入校验采用白名单形式 校验前做归一化处理&#xff0c;使用java.text.Normalizer的n…

天机学堂笔记1

FeignClient(contextId "course", value "course-service") public interface CourseClient {/*** 根据老师id列表获取老师出题数据和讲课数据* param teacherIds 老师id列表* return 老师id和老师对应的出题数和教课数*/GetMapping("/course/infoB…

Flutter pubspec.yaml 使用方式

Flutter pubspec.yaml 使用方式 pubspec.yaml 是 Flutter 项目中最重要的配置文件之一&#xff0c;用于管理应用的基本信息、依赖项、资源以及构建配置等内容。 1. 基本结构和字段 基本信息 name: my_flutter_app # 应用的名称 description: A new Flutter project …

NLP中常见的分词算法(BPE、WordPiece、Unigram、SentencePiece)

文章目录 一、基本概念二、传统分词方法2.1 古典分词方法2.2 拆分为单个字符 三、基于子词的分词方法&#xff08;Subword Tokenization&#xff09;3.1 主要思想3.2 主流的 Subword 算法3.3 Subword 与 传统分词方法的比较 四、Byte Pair Encoding (BPE)4.1 主要思想4.2 算法过…

OceanBase数据库设计与管理:构建高效分布式数据架构基石

《OceanBase数据库设计与管理&#xff1a;构建高效分布式数据架构基石》 在当今数字化浪潮汹涌澎湃的时代&#xff0c;数据量呈爆炸式增长&#xff0c;业务复杂度不断攀升&#xff0c;传统数据库在应对海量数据存储、高并发读写以及高可用性需求时愈发捉襟见肘。OceanBase作为…

搭建Hadoop分布式集群

软件和操作系统版本 Hadoop框架是采用Java语言编写&#xff0c;需要java环境&#xff08;jvm&#xff09; JDK版本&#xff1a;JDK8版本 &#xff0c;本次使用的是 Java: jdk-8u431-linux-x64.tar.gz Hadoop: hadoop-3.3.6.tar.gz 三台Linux虚拟节点: CentOS-7-x86_64-DVD-2…

分享一下使用高版本(3.10.x)sonar-maven-plugin 进行构建时需要注意的两个问题

SonarScanner用来执行源代码分析。这个独立的程序在CI/CD主机上运行&#xff0c;并将分析结果发送到SonarQube服务器&#xff0c;由其计算分析结果&#xff0c;计算质量门并生成报告。我们可以通过命令行和maven构建两种方式来执行SonarScanner的源码分析。关于SonarScanner更多…

C语言---函数和数组实践:扫雷游戏

函数和数组实践&#xff1a;扫雷游戏 在这次的实践项目中&#xff0c;需要编写一个可以在在控制台运行的经典的扫雷游戏。 一、游戏要求 游戏有菜单&#xff0c;可以通过菜单实现继续玩或者退出游戏游戏要求棋盘9*9&#xff0c;雷&#xff08;10个&#xff09;要求随机布置可…

数据结构与算法之二叉树: LeetCode 701. 二叉搜索树中的插入操作 (Ts版)

二叉搜索树中的插入操作 https://leetcode.cn/problems/insert-into-a-binary-search-tree/description/ 描述 给定二叉搜索树&#xff08;BST&#xff09;的根节点 root 和要插入树中的值 value &#xff0c;将值插入二叉搜索树返回插入后二叉搜索树的根节点。 输入数据 保…

数据集-目标检测系列- 石榴 检测数据集 pomegranate >> DataBall

数据集-目标检测系列- 石榴 检测数据集 pomegranate >> DataBall DataBall 助力快速掌握数据集的信息和使用方式&#xff0c;会员享有 百种数据集&#xff0c;持续增加中。 需要更多数据资源和技术解决方案&#xff0c;知识星球&#xff1a; “DataBall - X 数据球(fre…

项目实战——使用python脚本完成指定OTA或者其他功能的自动化断电上电测试

前言 在嵌入式设备的OTA场景测试和其他断电上电测试过程中&#xff0c;有的场景发生在夜晚或者随时可能发生&#xff0c;这个时候不可能24h人工盯着&#xff0c;需要自动化抓取串口日志处罚断电上电操作。 下面的python脚本可以实现自动抓取串口指定关键词&#xff0c;然后触发…

深入详解DICOM医学影像定位线相关知识:理解定位线的概念、定位线的作用以及定位线显示和计算原理

DICOM医学影像中的定位线(Localization Line) 在医学影像学中,DICOM是用于存储和交换医学影像的标准格式。定位线(Localization Line)在医学影像的显示和分析中起着重要作用,它帮助医生和医学专业人员在影像中精确地标定重要的解剖结构、区域或特征,辅助进行定位、治疗计…

《自动驾驶与机器人中的SLAM技术》ch8:基于 IESKF 的紧耦合 LIO 系统

目录 基于 IESKF 的紧耦合 LIO 系统 1 IESKF 的状态变量和运动过程 1.1 对名义状态变量的预测 1.2 对误差状态变量的预测及对协方差矩阵的递推 2 观测方程中的迭代过程 3 高维观测中的等效处理 4 NDT 和 卡尔曼滤波的联系 5 紧耦合 LIO 系统的主要流程 5.1 IMU 静止初始化 …