深入 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> oldChildrenList<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.mzph.cn/diannao/67016.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈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…

lerna使用指南

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

天机学堂笔记1

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

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 算法过…

搭建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 静止初始化 …

HTML实战课堂之简单的拜年程序

一、目录&#xff1a; &#xfffc;&#xfffc; 一、目录&#xff1a; 二、祝福 三&#xff1a;代码讲解 &#xff08;1&#xff09;详细解释&#xff1a; 1.HTML部分 2. CSS部分 三、运行效果&#xff08;随机截图&#xff09;&#xff1a; 四、完整代码&#xff1a; 二、祝福…

vue 与 vue-json-viewer 实现 JSON 数据可视化

前言 接口的调试和测试是确保系统稳定性的重要步骤。为了让开发人员和测试人员能够直观地查看接口返回的 JSON 数据&#xff0c;使用合适的工具至关重要。vue-json-viewer 插件为 vue 开发者提供了一个简单而强大的解决方案。本文将详细介绍如何在 vue 项目中使用该插件&#x…

用Pygame Zero 画矩形(空心、实心、多个矩形、多层同心矩形、彩虹条矩形、条纹相间、随机颜色矩形、特殊效果、渐变效果)

用Pygame Zero 画矩形 &#xff08;空心、实心、多个矩形、多层同心矩形、彩虹条矩形、条纹相间、随机颜色矩形、特殊效果、渐变效果&#xff09; 本文目录&#xff1a; 零、时光宝盒 一、绘制空心矩形 二、绘制实心矩形 三、画多个静止矩形 四、绘制多层同心矩形 4.1、…

【Rust自学】11.9. 单元测试

喜欢的话别忘了点赞、收藏加关注哦&#xff0c;对接下来的教程有兴趣的可以关注专栏。谢谢喵&#xff01;(&#xff65;ω&#xff65;) 11.9.1. 测试的分类 Rust把测试分为两类&#xff0c;一个是单元测试&#xff0c;一个是集成测试。 单元测试比较小也比较专注&#xff…

[java基础-集合篇]优先队列PriorityQueue结构与源码解析

优先队列PriorityQueue 优先级队列表示为平衡二进制堆&#xff1a; queue[n] 的两个子级是 queue[2*n1] 和 queue[2*&#xff08;n1&#xff09;]。 注&#xff1a;左子节点index2*parentIndex1,右子节点index2*parentIndex2,源码中计算parent位置时就是这样反过来计算的 优…

回归预测 | MATLAB实RVM-Adaboost相关向量机集成学习多输入单输出回归预测

回归预测 | MATLAB实RVM-Adaboost相关向量机集成学习多输入单输出回归预测 目录 回归预测 | MATLAB实RVM-Adaboost相关向量机集成学习多输入单输出回归预测预测效果基本介绍程序设计参考资料 预测效果 基本介绍 RVM-Adaboost相关向量机集成学习多输入单输出回归预测是一种先进…

Linux(Centos7)安装Mysql/Redis/MinIO

安装Mysql 安装Redis 搜索Redis最先版本所在的在线安装yum库 查看以上两个组件是否是开机自启 安装MinIO 开源的对象存储服务&#xff0c;存储非结构化数据&#xff0c;兼容亚马逊S3协议。 minio --help #查询命令帮助minio --server --help #查询--server帮助minio serve…