深入 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

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Docker 的安装和基本使用[SpringBoot之Docker实战系列] - 第535篇

历史文章&#xff08;文章累计530&#xff09; 《国内最全的Spring Boot系列之一》 《国内最全的Spring Boot系列之二》 《国内最全的Spring Boot系列之三》 《国内最全的Spring Boot系列之四》 《国内最全的Spring Boot系列之五》 《国内最全的Spring Boot系列之六》 《…

介绍下不同语言的异常处理机制

Golang 在Go语言中&#xff0c;有两种用于处于异常的机制&#xff0c;分别是error和panic&#xff1b; panic panic 是 Go 中处理异常情况的机制&#xff0c;用于表示程序遇到了无法恢复的错误&#xff0c;需要终止执行。 使用场景 程序出现严重的不符合预期的问题&#x…

车联网安全--TLS握手过程详解

目录 1. TLS协议概述 2. 为什么要握手 2.1 Hello 2.2 协商 2.3 同意 3.总共握了几次手&#xff1f; 1. TLS协议概述 车内各ECU间基于CAN的安全通讯--SecOC&#xff0c;想必现目前多数通信工程师们都已经搞的差不多了&#xff08;不要再问FvM了&#xff09;&#xff1b;…

【update 更新数据语法合集】.NET开源ORM框架 SqlSugar 系列

系列文章目录 &#x1f380;&#x1f380;&#x1f380; .NET开源 ORM 框架 SqlSugar 系列 &#x1f380;&#x1f380;&#x1f380; 文章目录 系列文章目录前言 &#x1f343;一、实体对象更新1.1 单条与批量1.2 不更新某列1.3 只更新某列1.4 NULL列不更新1.5 无主键/指定列…

51单片机入门基础

目录 一、基础知识储备 &#xff08;一&#xff09;了解51单片机的基本概念 &#xff08;二&#xff09;掌握数字电路基础 &#xff08;三&#xff09;学习C语言编程基础 二、开发环境搭建 &#xff08;一&#xff09;硬件准备 &#xff08;二&#xff09;软件准备 三、…

22、PyTorch nn.Conv2d卷积网络使用教程

文章目录 1. 卷积2. python 代码3. notes 1. 卷积 输入A张量为&#xff1a; A [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ] \begin{equation} A\begin{bmatrix} 0&1&2&3\\\\ 4&5&6&7\\\\ 8&9&10&11\\\\ 12&13&14&15 \end{b…

Python爬虫-汽车之家各车系周销量榜数据

前言 本文是该专栏的第43篇,后面会持续分享python爬虫干货知识,记得关注。 在本专栏之前,笔者在文章《Python爬虫-汽车之家各车系月销量榜数据》中,有详细介绍,如何爬取“各车系车型的月销量榜单数据”的方法以及完整代码教学教程。 而本文,笔者同样以汽车之家平台为例,…

web前端第五次作业---制作菜单

制作菜单 代码: <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><title>Document</title><style…

个人曾经ARM64_汇编角度_PLTHOOK的研究

ARM64基础HOOK研究_2024 之前为了实现一个修改器变速器的小功能,结果研究了很多关于ELF的内容,特别是so文件(ARM64的) 还研究了Hook,以及注入进程等操作,以及实现类似IDA那样的断点,汇编转换,以及软硬断点等(实现了CE那种谁写入/访问/读取的检测),这里就不作记录了,只记录一下简…

win10 Outlook(new) 企业邮箱登录 登录失败。请在几分钟后重试。

windows系统经常弹出使用Outlook(new&#xff09;&#xff0c;自动切过去。 但是登录企业的内网邮箱&#xff0c;折腾了好几次都使用不了。排查网络等问题&#xff0c;在社区找到了答案。 推出一年多不支持企业账户&#xff0c;所以之前的折腾都是浪费时间。 因为这个答案不太…

MySQL中的四种表联结

目录 1、联结、关系表 &#xff08;1&#xff09;关系表 &#xff08;2&#xff09;为什么使用联结 2、如何创建联结 &#xff08;1&#xff09;笛卡尔积&#xff08;叉联结&#xff09;--用逗号分隔 &#xff08;2&#xff09;where子句的重要性 &#xff08;3&#xff…

【Oracle专栏】group by 和distinct 效率

Oracle相关文档&#xff0c;希望互相学习&#xff0c;共同进步 风123456789&#xff5e;-CSDN博客 1.背景 查阅资料&#xff1a; 1&#xff09;有索引情况下&#xff0c;group by和distinct都能使用索引&#xff0c;效率相同。 2&#xff09;无索引情况下&#xff0c;distinct…

easyui datagrid表头和网格错位问题

问题&#xff1a;表头与数据网格错位 解决&#xff1a; 在onLoadSuccess事件中调用fitColumns方法 $(this).datagrid(‘fitColumns’);

[文献精汇]使用 LSTM Networks 的均值回归交易策略

Backtrader 策略实例 [Backtrader]实例:均线策略[Backtrader] 实例:MACD策略[Backtrader] 实例:KDJ 策略[Backtrader] 实例:RSI 与 EMA 结合[Backtrader] 实例:SMA自定义数据源[Backtrader] 实例:海龟策略[Backtrader] 实例:网格交易[Backtrader] 实例: 配对交[Backtrader] 机…

DBeaver执行本地的sql语句文件避免直接在客户端运行卡顿

直接在客户端运行 SQL 语句和通过加载本地文件执行 SQL 语句可能会出现不同的性能表现&#xff0c;原因可能包括以下几点&#xff1a; 客户端资源使用&#xff1a; 当你在客户端界面直接输入和执行 SQL 语句时&#xff0c;客户端可能会消耗资源来维护用户界面、语法高亮、自动完…

opencv warpAffine仿射变换C++源码分析

基于opencv 3.1.0源代码 sources\modules\imgproc\src\imgwarp.cpp void cv::warpAffine( InputArray _src, OutputArray _dst,InputArray _M0, Size dsize,int flags, int borderType, const Scalar& borderValue ) {...if( !(flags & WARP_INVERSE_MAP) ){//变换矩阵…

【数字化】华为-用变革的方法确保规划落地

导读&#xff1a;华为在数字化转型过程中&#xff0c;深刻认识到变革的必要性&#xff0c;并采用了一系列有效的方法确保转型规划的有效落地。华为认为&#xff0c;数字化转型不仅仅是技术层面的革新&#xff0c;更是企业运作模式、流程、组织、文化等深层次的变革。数字化转型…

Excel中SUM求和为0?难道是Excel有Bug!

大家好&#xff0c;我是小鱼。 在日常工作中有时会遇到这样的情况&#xff0c;对Excel表格数据进行求和时&#xff0c;结果竟然是0&#xff0c;很多小伙伴甚至都怀疑是不是Excel有Bug&#xff01;其实&#xff0c;在WPS的Excel表格中数据求和&#xff0c;结果为0无法正确求和的…

极客说|Azure AI Agent Service 结合 AutoGen/Semantic Kernel 构建多智能体解决⽅案

作者&#xff1a;卢建晖 - 微软高级云技术布道师 「极客说」 是一档专注 AI 时代开发者分享的专栏&#xff0c;我们邀请来自微软以及技术社区专家&#xff0c;带来最前沿的技术干货与实践经验。在这里&#xff0c;您将看到深度教程、最佳实践和创新解决方案。关注「极客说」&am…

【Block总结】稀疏自注意力机制SABlock,即插即用

SparseViT论文解读 论文标题: SparseViT: Nonsemantics-Centered, Parameter-Efficient Image Manipulation Localization through Spare-Coding Transformer 论文链接: https://arxiv.org/pdf/2412.14598 官方GitHub: https://github.com/scu-zjz/SparseViT #研究背景 图像操…