到目前为止,我们将 Compose runtime 在内存中维护的状态称为“Composition”,这是一个较为肤浅的概念。让我们从了解用于存储和更新Composition状态的数据结构开始探索。
slot table 和 change list
我发现这两种数据结构之间存在一些混淆,可能是由于目前缺乏关于Compose内部结构的文档。现在,我认为有必要首先澄清这一点。
slot table
(插槽表)是一个优化的内存结构,runtime 使用它来存储组合的当前状态。它在初始组合时填充数据,并在每次重新组合时更新。我们可以把它看作是所有Composable函数调用的跟踪,包括它们在源码中的位置、参数、记住的值、CompositionLocals
等等。它包含了在合成过程中发生的一切内容。所有这些信息将被 Composer
稍后用于生成下一个更改列表,因为对树的任何更改总是依赖于当前状态。
slot table
记录Composable的状态,而 change list
则是对节点树进行实际更改的内容。每次 Composable 函数的状态发生更改时,对应的更改操作会被添加到 change list
中。它可以理解为一个补丁文件,一旦应用,它就会更新树。所有要执行的更改都需要先被记录,然后再被应用。
最后,Recomposer
协调所有这些,决定在什么时候和在什么线程上进行重组,以及在什么时候和在什么线程上应用更改。稍后会详细介绍。
slot table 深入了解
slot table
是一种为快速线性访问而优化的数据结构。它用于存储 Composition
的状态,它基于 “gap buffer
”(间隙缓冲区)的思想,在文本编辑器中非常常见。它将数据存储在两个线性数组中。其中一个数组保存关于Composition
中可用的 group
的信息,另一个数组存储属于每个 group
的插槽(slot
)。
在上一篇中,我们学习了编译器如何包装可组合函数体以使它们发出
group
。一旦 Composable 存储在内存中,这些组将为它提供唯一key
,以便以后可以标识它。group
包含了 Composable 调用及其子调用的所有相关信息,并提供了关于如何处理Composable(作为一个组)的信息。根据可组合主体内部的控制流模式,group
可以有不同的类型:可重启组、可移动组、可替换组、可重用组……
存储 groups
的数组使用 Int
类型,因为它只存储 group fields
(表示组的元数据)。父组和子组以 group fields
的形式存储。鉴于它是一个线性数据结构,父组的 group fields
总是在前面,所有子组的 group fields
将紧随其后。这是一种建模group
树的线性方法,它有利于对子节点进行线性扫描。除非通过group
锚点访问,否则对其进行随机访问是非常昂贵的。(锚点就像指针一样的存在)
另一方面,slots
数组存储这些组中的每一个group
的相关数据。它存储任何类型的值(Any?
),因为它意味着存储任何类型的信息。这是实际 Composition
组合数据存储的地方。存储在 groups
数组中的每个group
都描述了如何在 slots
数组中查找和解释它的 slot
,因为一个group
总是链接到一系列的slots
。
slot table
依赖于一个 gap
(间隙) 来读写。可以把它看成是表中的一系列位置。这个间隙会四处移动,并决定何时从哪里读取数据,何时将数据写入数组。gap
有一个指针来指示从哪里开始写入,并且可以移动它的开始和结束位置,因此表中的数据也可以被覆盖。
一个gap buffer是一种表示带有当前索引或光标的集合的数据结构。它在内存中用一个扁平数组实现。该扁平数组的大小大于它所表示的数据集合,未使用的空间称为gap。当需要插入新数据时,gap会移动到对应的位置,以便插入新的元素。这个过程会使得数据移动,但是由于gap的存在,移动的数据量会被最小化。这个结构的好处是,随机访问非常快速,而且插入和删除操作的时间复杂度是O(n)。
考虑以下条件逻辑:
由于此 Composable 被标记为 non-restartable
,因此将插入一个可替换的 group
(而不是一个可重启的 group
)。该 group
将在 slot
表中为当前处于 “active
” 状态的子元素的存储数据。如果 a
为true
,那么处于 “active
” 状态的子元素将是 Text(a)
。当条件切换时,gap
将移回 group
的起始位置,并从那里开始写入,用 Text(b)
的数据覆盖所有这些位置。
为了读写表,我们有SlotReader
和SlotWriter
。表可以有多个active
的 Reader
,但 只能有一个active
的Writer
。在每次读或写操作后,相关的 Reader
或Writer
都会被关闭。可以启用任意数量的 Reader
来读取数据,但为了安全起见,只有在表未被写入时才能够从中读取。SlotTable
会保持无效状态直到active
的Writer
被关闭,因为它将直接修改 groups
和 slots
,如果我们在写的时候同时尝试从中读取,可能会导致竞争。
一个 reader
的作用类似于访问者。它跟踪正在从 groups 数组中读取的当前组、它的开始和结束位置、父组(紧挨着存储的)、当前正在读取的组中的当前slot
、组的数量等等。Reader可以重新定位、跳过组、从当前 slot 中读取值、从特定索引中读取值等等。换句话说,它被用来从数组中读取有关group
和它们的slot
的信息。
相对的,Writer
用于向数组中写入 groups 和 slots。正如上面所述,它可以将任何类型(Any?
)的数据写入表中。SlotWriter
依赖于上面提到的 gap
来操作 groups 和 slots,因此它使用它们来确定在数组中进行写入的位置。
把gap
想象成一个可滑动可调整大小的线性数组区间。Writer
维护着每个gap
的起始位置、结束位置和长度,它可以通过更新起始位置和结束位置来移动gap
的位置。
Writer
能够添加、替换、移动和删除 groups 和 slots。比如,想象一下向树中添加一个新的Composable 节点,或者在条件逻辑下需要替换的 Composable 节点。
Writer
可以跳过 groups 和 slots,按给定数量的位置前进,seek 到由 Anchor
确定的位置,以及许多类似的操作。
Anchor
是一个指向表中特定索引的位置的引用,SlotTable
的 writer
维护着指向特定位置的 Anchor
列表。同时,Anchor
追踪每个group
在表中的位置,也称为group
索引。如果在Anchor
指向的位置之前插入,移动,替换或删除group
,Anchor
会相应地进行更新。这个机制可以帮助writer
快速访问表中的指定位置。
slot table
还可以作为 composition groups的迭代器,因此它可以向工具提供关于它们的信息,以便这些工具能够检查和呈现组合的详细信息。现在是时候了解更改列表。
如果需要获取有关slot table的更多细节,建议阅读Jetpack Compose团队的Leland Richardson的这篇文章。
change list
我们已经学习了关于 slot table
知识,了解了它是如何允许运行时跟踪合成的当前状态。但是,change list
的确切作用是什么呢?它是什么时候产生的?它模拟了什么?这些更改是在何时应用的,出于什么原因?我们仍然有许多事情需要搞清楚。
每当合成(或重新合成)发生时,我们的源Composable函数将被执行 和 发射(Emit)。Emit 这个词意味着创建延迟的更改以更新 slot table
,最终也会更新实际生成的树。这些更改存储为一个列表。生成这个新的更改列表是基于已经存储在 slot table
中的内容。记住:对树的任何更改都必须依赖于 Composition 的当前状态。
这可以举一个移动节点的例子。假设我们要重新排列列表的Composable函数。我们需要检查该节点在表中之前的位置,删除所有这些槽,并从新位置开始再次写入它们。
换句话说,每当一个 Composable 函数发射时,它都会查看slot table
,根据当前可用的需求和信息创建延迟更改,并将其添加到包含所有更改的列表中。稍后,在 Composition 完成后,就是实例化的时候了,那些记录的更改将会被有效地执行。这时,它们会使用 Composition 的最新可用信息有效地更新slot table
。这个过程使得发射过程非常快速:它只是创建一个等待被运行的延迟操作。
由此,我们可以看出change list
是最终对slot table
进行更改的。在这之后,它将通知 Applier
更新实例化的节点树。
正如我们上面所说的,Recomposer
协调这个过程,并决定在哪个线程上进行合成或重新合成,以及使用哪个线程来应用change list
中的更改。后者也是LaunchedEffect
用于执行副作用的默认上下文。
投喂 Composer
注入的$composer
将我们编写的 Composable 函数连接到 Compose runtime。
让我们探讨如何将节点添加到内存表示的树中。我们可以使用 Layout Composable来讨论。Layout 是 Compose UI 提供的所有UI组件的管道。下面是其代码实现:
Layout 使用 ReusableComposeNode 来将 LayoutNode 发射到组合中。但即使听起来像是立即创建并添加节点,它实际上是在告知 runtime 如何在组合的当前位置创建、初始化和插入节点。以下是其实现代码:
这里省略了一些不相关的部分,但请注意它将一切委托给currentComposer
实例。我们还可以看到它是会启动一个可替换的group
,以便在存储时来包装Composable的内容。在 content
lambda 内发射的任何子元素都将有效地存储为 Composition 中该 group
的子元素。
其他Composable函数也是同样的发射操作。例如,remember
:
remember
Composable 函数使用 currentComposer
来将用户提供的lambda返回值 缓存(记住)到 Composition 中。invalid
参数强制更新值,无论它之前是否已被存储。缓存的代码实现如下:
它首先会在 Composition(slot table)中查找这个值。如果未找到,它就会计划安排一次对该值更新操作(换句话说,是记录它)。否则如果找到的话,就原样返回该值。
对更改进行建模
正如前面所解释的,所有委托给 currentComposer
的发射操作在内部都被建模为 Changes
并添加到列表中。Change
是一个延迟执行的函数,它可以访问当前的 Applier
和 SlotWriter
(记住一次只有一个活动的 writter
)。让我们看一下它的代码:
这些更改被添加到列表中(记录)。“发射”操作本质上意味着创建这些Changes
,它们是延迟执行的lambdas,以潜在地从 slot table
中添加、删除、替换或移动节点,并进而通知 Applier
(因此这些更改就被实现)。
出于这个原因,每当我们讨论“发射更改”时,我们可能还会使用“记录更改”或“调度更改”这样的词。它们都指的是同一件事。
在组合完成之后,一旦所有Composable函数调用完毕,所有更改都会被记录下来,此时 Applier
就会批量的应用它们。
优化写入时间
如上所述,插入新节点被委托给 Composer
。这意味着它总是知道什么时候已经进入了将新节点插入组合的过程。在这种情况下,Composer
可以缩短流程,并在发射更改时立即开始向slot table
写入,而不是记录它们(即将它们添加到列表中稍后才进行解释)。在另一种情况下,这些更改被记录并延迟