深入 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.rhkb.cn/news/999.html

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

相关文章

Elasticsearch—索引库操作(增删查改)

Elasticsearch中Index就相当于MySQL中的数据库表 Mapping映射就类似表的结构。 因此我们想要向Elasticsearch中存储数据,必须先创建Index和Mapping 1. Mapping映射属性 Mapping是对索引库中文档的约束&#xff0c;常见的Mapping属性包括&#xff1a; type&#xff1a;字段数据类…

occ的开发框架

occ的开发框架 1.Introduction This manual explains how to use the Open CASCADE Application Framework (OCAF). It provides basic documentation on using OCAF. 2.Purpose of OCAF OCAF (the Open CASCADE Application Framework) is an easy-to-use platform for ra…

esp32在编译是报错在idf中有该文件,但是说没有

报错没有头文件esp_efuse_table.h D:/Espressif/frameworks/esp-idf-v5.3.1/components/driver/deprecated/driver/i2s.h:27:2: warning: #warning "This set of I2S APIs has been deprecated, please include driver/i2s_std.h, driver/i2s_pdm.h or driver/i2s_tdm.h …

git - 用SSH方式迁出远端git库

文章目录 git - 用SSH方式迁出远端git库概述笔记以gitee为例产生RSA密钥对 备注githubEND git - 用SSH方式迁出远端git库 概述 最近一段时间&#xff0c;在网络没问题的情况下&#xff0c;用git方式直接迁出git库总是会失败。 失败都是在远端, 显示RPC错误。 但是git服务器端…

http和https有哪些不同

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

超详细-java-uniapp小程序-引导关注公众号、判断用户是否关注公众号

目录 1、前期准备 公众号和小程序相互关联 准备公众号文章 注册公众号测试号 微信静默授权的独立html 文件 2&#xff1a; 小程序代码 webview页面代码 小程序首页代码 3&#xff1a;后端代码 1&#xff1a;增加公众号配置项 2&#xff1a;读取公众号配置项 3&…

【Python进阶——分布式计算框架pyspark】

Apache Spark是用于大规模数据处理的统一分析引擎 简单来说&#xff0c;Spark是一款分布式的计算框架&#xff0c;用于调度成百上千的服务器集群&#xff0c;计算TB、PB乃至EB级别的海量数据&#xff0c;Spark作为全球顶级的分布式计算框架&#xff0c;支持众多的编程语言进行开…

基于 FastExcel 与消息队列高效生成及导入机构用户数据

&#x1f3af; 本文档详细介绍了开发机构用户数据导入功能的必要性及实现方法&#xff0c;如针对教育机构如学校场景下提高用户体验和管理效率的需求。文中首先分析了直接对接学生管理系统与平台对接的优势&#xff0c;包括减少人工审核成本、提高身份验证准确性等。接着介绍了…

校园跑腿小程序---轮播图,导航栏开发

hello hello~ &#xff0c;这里是 code袁~&#x1f496;&#x1f496; &#xff0c;欢迎大家点赞&#x1f973;&#x1f973;关注&#x1f4a5;&#x1f4a5;收藏&#x1f339;&#x1f339;&#x1f339; &#x1f981;作者简介&#xff1a;一名喜欢分享和记录学习的在校大学生…

前端练习题

图片&#xff1a; 代码&#xff1a; <!DOCTYPE html> <html> <head><meta charset"UTF-8"><title>用户信息页面</title><style>body {font-family: Arial, sans-serif;margin: 20px;}.user-info {display: flex;align-it…

AllData是怎么样的一款数据中台产品?

&#x1f525;&#x1f525; AllData大数据产品是可定义数据中台&#xff0c;以数据平台为底座&#xff0c;以数据中台为桥梁&#xff0c;以机器学习平台为中层框架&#xff0c;以大模型应用为上游产品&#xff0c;提供全链路数字化解决方案。 ✨奥零数据科技官网&#xff1a;…

一学就废|Python基础碎片,OS模块

Python 中的操作系统模块提供了与操作系统交互的功能。操作系统属于 Python 的标准实用程序模块。该模块提供了一种使用依赖于操作系统的功能的可移植方式。os和os. path模块包括许多与文件系统交互的函数。 Python-OS 模块函数 我们将讨论 Python os 模块的一些重要功能&…

2.Numpy练习(1)

一.练习一&#xff1a; 1.打印当前numpy版本&#xff1a; 2.构造一个全零的矩阵&#xff0c;并打印其占用内存大小&#xff1a; 3.打印一个函数的帮助文档&#xff0c;比如numpy.add&#xff1a; 4.创建一个10~49数组&#xff0c;并将其倒序排列: 5.找到一个数组中不为0的索引…

Ubuntu Server挂载AWS S3成一个本地文件夹

2023年&#xff0c;AWS出了个mountpoint的工具&#xff1a; https://github.com/awslabs/mountpoint-s3 如下是另外一种方式&#xff0c;通过s3fs-fuse 这个工具 sudo apt-get install automake autotools-dev \fuse g git libcurl4-gnutls-dev libfuse-dev \libssl-dev libx…

CSS3的aria-hidden学习

前言 aria-hidden 属性可用于隐藏非交互内容&#xff0c;使其在无障碍 API 中不可见。即当aria-hidden"true" 添加到一个元素会将该元素及其所有子元素从无障碍树中移除&#xff0c;这可以通过隐藏来改善辅助技术用户的体验&#xff1a; 纯装饰性内容&#xff0c;如…

nvm use使用nodejs版本时报错

文章目录 报错原因分析解决方法 报错 nvm use报错出现乱码&#xff1a; 比如nvm use 22.12.0&#xff0c;出现下面报错&#xff1a; exit status 1: ‘D:\Program’ &#xfffd;&#xfffd;&#xfffd;&#xfffd;&#xfffd;ڲ&#xfffd;&#xfffd;&#xfffd;&…

C++中线程同步与互斥的4种方式介绍、对比、场景举例

在C中&#xff0c;当两个或更多的线程需要访问共享数据时&#xff0c;就会出现线程安全问题。这是因为&#xff0c;如果没有适当的同步机制&#xff0c;一个线程可能在另一个线程还没有完成对数据的修改就开始访问数据&#xff0c;这将导致数据的不一致性和程序的不可预测性。为…

1、docker概念和基本使用命令

docker概念 微服务&#xff1a;不再是以完整的物理机为基础的服务软件&#xff0c;而是借助于宿主机的性能。以小量的形式&#xff0c;单独部署的应用。 docker&#xff1a;是一个开源的应用容器引擎&#xff0c;基于go语言开发的&#xff0c;使用时apache2.0的协议。docker是…

信息安全、网络安全和数据安全的区别和联系

信息安全、网络安全和数据安全是信息安全领域的三大支柱&#xff0c;它们之间既存在区别又相互联系。以下是对这三者的详细比较&#xff1a; 一.区别 1.信息安全 定义 信息安全是指为数据处理系统建立和采用的技术和管理的安全保护&#xff0c;保护计算机硬件、软件和数据不…

Linux网络编程5——多路IO转接

一.TCP状态时序理解 1.TCP状态理解 **CLOSED&#xff1a;**表示初始状态。 **LISTEN&#xff1a;**该状态表示服务器端的某个SOCKET处于监听状态&#xff0c;可以接受连接。 **SYN_SENT&#xff1a;**这个状态与SYN_RCVD遥相呼应&#xff0c;当客户端SOCKET执行CONNECT连接时…