Jetpack Compose 深入探索系列三:Compose runtime

Posted 川峰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Jetpack Compose 深入探索系列三:Compose runtime相关的知识,希望对你有一定的参考价值。

到目前为止,我们将 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” 状态的子元素的存储数据。如果 atrue,那么处于 “active” 状态的子元素将是 Text(a)。当条件切换时,gap 将移回 group 的起始位置,并从那里开始写入,用 Text(b) 的数据覆盖所有这些位置。

为了读写表,我们有SlotReaderSlotWriter。表可以有多个activeReader,但 只能有一个activeWriter。在每次读或写操作后,相关的 ReaderWriter都会被关闭。可以启用任意数量的 Reader 来读取数据,但为了安全起见,只有在表未被写入时才能够从中读取SlotTable会保持无效状态直到activeWriter被关闭,因为它将直接修改 groupsslots,如果我们在写的时候同时尝试从中读取,可能会导致竞争。

一个 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 是一个指向表中特定索引的位置的引用,SlotTablewriter 维护着指向特定位置的 Anchor 列表。同时,Anchor追踪每个group在表中的位置,也称为group索引。如果在Anchor指向的位置之前插入,移动,替换或删除groupAnchor会相应地进行更新。这个机制可以帮助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 是一个延迟执行的函数,它可以访问当前的 ApplierSlotWriter(记住一次只有一个活动的 writter)。让我们看一下它的代码:

这些更改被添加到列表中(记录)。“发射”操作本质上意味着创建这些Changes,它们是延迟执行的lambdas,以潜在地从 slot table 中添加、删除、替换或移动节点,并进而通知 Applier (因此这些更改就被实现)。

出于这个原因,每当我们讨论“发射更改”时,我们可能还会使用“记录更改”或“调度更改”这样的词。它们都指的是同一件事。

在组合完成之后,一旦所有Composable函数调用完毕,所有更改都会被记录下来,此时 Applier 就会批量的应用它们

优化写入时间

如上所述,插入新节点被委托给 Composer。这意味着它总是知道什么时候已经进入了将新节点插入组合的过程。在这种情况下,Composer 可以缩短流程,并在发射更改时立即开始向slot table写入,而不是记录它们(即将它们添加到列表中稍后才进行解释)。在另一种情况下,这些更改被记录并延迟,因为现在还不是进行更改的时候。

读写 groups

一旦组合完成,composition.applychanges()最终被调用以构建树,并将更改写入slot tableComposer可以写入不同类型的信息:数据、节点或组。也就是说,为了简单起见,所有这些数据最终都以 group 的形式存储。

Composer可以“开始”和“结束”任何group 。根据所采取的行动,这有不同的含义。如果它正在写入,它将代表从slot table中“创建的组”和“删除的组”。如果它正在读取,则会要求SlotReader将其读取指针移到组外,以便开始或结束从其中进行读取。

Composable 树上的节点(最终是表中的组)不仅可以插入,还可以删除或移动。删除一个组意味着从表中删除它及其所有对应的槽。为此,Composer 会要求重新定位SlotReader,并让它跳过该组(因为它不存在了),同时记录从applier中删除其所有节点的操作。任何修改操作都需要先记录,然后批量应用,主要是为了确保它们都有意义。Composer还将丢弃已删除组的任何 pending invalidations(挂起的失效), 因为它们永远不会发生。

并非所有组都是可重启、可替换、可移动或可重用的。在以group形式存储的其他内容中,我们可以找到默认的包装代码块。这个代码块包含了生成默认参数所需的可组合调用的remembered值:例如 model: Model = remember DefaultModel() 。这样也是会以一个非常特定的group形式来存储的。

Composer 想要创建一个group时,会发生以下事情:

  • 如果 Composer 正在插入值,它将继续写入 slot table,因为没有理由等待。
  • 如果有挂起的操作,它将在应用这些更改的时候将这些更改记录到applier。这时,如果group已经在 slot table中存在,Composer将尝试重用该group
  • group已经存储但位置不同时(它已被移动),将记录移动组的所有slot的操作。
  • 如果group是新的(在表中没有找到),它将进入插入模式,先将group及其所有子group写入一个临时insertTable表(另一个SlotTable),直到组完成之后,再将安排其插入到最终表。
  • 如果Composer没有插入,并且也没有挂起的写操作,它将尝试开始读取组。

重用组是很常见的。有时不需要创建新节点,但我们可以重用它,以防它已经存在。(参见上面的ReusableComposeNode)。这将触发(记录)Applier导航到该节点的操作,但将跳过创建和初始化该节点的操作。

当节点的属性需要更新时,该操作也被记录为Change

记忆值

我们了解了Composer如何能够将值rememberComposition中(将它们写到 slot table中),并且它还可以稍后更新这些值。当调用remember时,会立即检查比较它是否自上次组合以后发生了变化 ,但更新操作只会被记录为Change,除非Composer已经在执行插入。

当要更新的值是一个RememberObserver时,Composer也会记录一个隐式的Change来跟踪构图中的记忆操作。(如果稍后需要忘记那些已经remembered的值,这是必要的)

重组作用域

Composer在重组作用域时还会发生的其他事情,如支持智能重组。它们直接链接到重启的group。每次创建一个重启的group时,Composer都会为其创建一个RecomposeScope,并将其设置为 CompositioncurrentRecomposeScope

RecomposeScope对组合的一个区域进行建模,该区域可以独立于组合的其他部分进行重新组合。它可用于手动使可组合对象失效和触发重新组合。失效是通过composer请求的,如 composer.currentRecomposeScope().invalidate() 。为了重新组合,Composer 将slot table定位到该组的起始位置,然后调用传递给 lambda 的重组 block 块。这将再次调用 Composable 函数,并再次执行发射操作,从而会进一步要求 Composer 重写表中的现有数据。

Composer维护所有已失效的重组作用域的堆栈。这意味着它们正在被挂起并等待重新组合,换句话说,它们需要在下一次重新组合中被触发。currentRecomposeScope实际上是通过这个栈的peek操作获得的。

也就是说,RecomposeScopes 并不总是被启用的。只有当 Compose 发现 Composable 从它的 State 快照中执行读操作时,才会发生这种情况。在这种情况下,Composer 会标记 RecomposeScope 为已使用,这使得在 Composable 的末尾插入的end调用不再返回null,因此会激活后面的 recomposition lambda (参见下面,在?字符后面的部分) 。


当需要重新组合当前父组的所有无效子组时,Composer可以重新组合,或者当不需要重新组合时,Composer也可以简单地让读取器跳过该组直到结束(即跳过重组)。

Composer 中的副作用

Composer 也能够记录 SideEffectSideEffect 总是在合成之后运行。它们被记录为一个函数,以便在已经应用了对相应树的更改时调用。它们表示发生在旁边的效果,所以这种类型的效果完全不知道 Composable 的生命周期。我们不会在离开合成时自动取消它,也不会在重组时重新尝试它。这是因为这种类型的效果没有存储在slot table中,因此如果组合失败就会被丢弃。

存储CompositionLocals

Composer 还提供了注册 CompositionLocals 并在给定key的情况下获取其值的方法。 CompositionLocal.current 的调用依赖于此。 Provider和它的值一起也作为一个group被存储在slot table中。

存储源信息

Composer 还以CompositionData的形式存储那些在合成过程中通过 Compose tools 收集的源信息。

通过 CompositionContext 链接 Compositions

Compose 中不存在单一的组合,而是由组合和子组合组成的树。子组合是内联创建的组合,其唯一目的是在当前组合的上下文中构造一个单独的组合,以支持独立的失效操作。

子组合通过父组合的CompositionContext引用连接到它的父组合。此上下文的存在是为了将组合和子组合作为树链接在一起。它确保CompositionLocalsinvalidations被透明地解析/沿着树向下传播,就好像它们属于单个Composition一样。CompositionContext本身也作为一个group被写入到slot table中。

创建子组合通常通过rememberCompositionContext完成:

这个函数在slot table的当前位置记忆了一个新的Composition,如果它已经被记忆,则返回该 Composition。它用于需要从单独的 Composition 的位置创建子 Composition 的情况,比如 VectorPainterDialogSubcomposeLayoutPopup 或 实际的androidView (一个包装器,用于将 Android Views 集成到 Composable 树中)。

访问当前State快照

Composer具有对当前快照的引用,即当前线程返回的可变状态和其他状态对象的值的快照。除非在快照中显式更改了状态对象,否则所有状态对象在快照中的值与创建快照时的值相同。

节点导航

节点树的导航是由applier执行的,但并不是直接执行的。它是通过记录所有节点在reader遍历时的位置,并将它们记录在一个downNodes数组中来完成的。当节点导航被实现时,所有在down nodes中的downs都将被传递给applier。如果在对应的down被实现之前就记录了up,则它将被简单地从downNodes堆栈中删除,作为一种快捷方式。

保持读写同步

这是一些较低层次的实现细节,但由于组(group)可以插入、删除或移动,一个组在writer中的位置可能会与在reader中的位置有一段时间的不同(直到更改被应用)。因此需要维护一个delta来跟踪差异。该delta将通过插入、删除和移动更新,并反映 “writer必须移动的 unrealized distance 以匹配reader中的当前slot”(根据文档所说)。

应用更改

正如前面多次提到的,Applier负责执行这个过程。当前的Composer委托这个抽象来应用Composition后记录的所有更改。这就是我们所说的“materializing”。此过程执行Changes列表,并更新slot table,解释存储在其中的 Composition 数据,以有效地产生结果。

runtime 本身并不关心Applier如何实现。它依赖于一个公共的锲约接口协议,期望客户端库实现。这是因为Applier 是与平台集成的接口,因此它将根据用例而变化。该接口协议如下:


接口声明中的第一个是N类型参数。它是我们正在应用的节点类型。这就是为什么compose可以与通用调用图或节点树一起使用。它始终不关心使用的节点类型。Applier提供操作来遍历树、插入、删除或移动节点,但它不关心这些节点的类型或它们如何最终插入。提示:这将被委托给节点本身

锲约还定义了如何从当前节点中删除给定范围内的所有子节点,或移动子节点以更改它们的位置。clear操作定义了如何指向根并从树中删除所有节点,准备将Applier及其根用作将来的新组合的目标。

Applier 会遍历整个树,访问和应用所有节点。可以从上到下或从下到上遍历整个树。Applier 一直保持对其正在访问和应用更改的当前节点的引用。Composer 在应用更改之前和之后调用 beginend 应用更改的调用。Applier 提供了从上到下或从下到上插入节点的方式,并提供了向上导航(导航到当前节点的父节点)和向下导航(导航到当前节点的子节点)的方式。

构建节点树时的性能

这里有一个从上往下或从下往上构建树的重要区别。

自上而下地插入节点

当使用自上而下的方式构建树时,节点按其在树中的位置创建并添加到 applier 中。这意味着每个父节点都在其子节点之前添加,因此子节点可以直接引用其父节点。这是一个自然的方式来构建树,也是通常的用法。

在这种情况下,applier 首先遍历根节点,然后处理根节点的每个子节点。在遍历树时,每当它到达一个新节点时,它会调用开始方法,然后在处理完节点及其所有子节点后,调用结束方法。

自上而下的插入是一种基于深度优先搜索的算法,每当它到达叶子节点时,就开始向上回溯,直到回溯到根节点。

考虑以下树:

如果我们想从上到下构建这棵树,我们会先将B插入到R中,然后将A插入到B中,最后将C插入到B中。

自下而上地插入节点

从下往上构建树,首先将A和C插入到B中,然后将B树插入到R中。


使用自上而下或自下而上方式构建树的性能可能会有很大差异。这个决定取决于所使用的Applier实现,通常取决于每次插入新子节点时需要通知的节点数量。想象一下,我们要用Compose表示的图形需要在每次插入节点时通知所有祖先节点。在自上而下的构建中,每次插入都可能会通知多个节点(父节点,父节点的父节点…等)。随着每个新层级的插入,这个计数将呈指数级增长。相比之下,如果改为自下而上,您仍然只需要通知直接父节点,因为父节点仍未附加到树上。但如果我们的策略是通知所有子节点,那么情况可能就会反过来。因此,这取决于我们要表示的树以及如何将更改通知到树的顶部或底部。这里唯一关键的一点是选择一种插入策略,而不是两种都选。

如何应用更改

正如我们之前所述,客户端库提供Applier接口的实现,其中一个示例是UiApplier,用于Android UI。我们可以使用它作为完美的例子来说明“应用节点”是什么以及在这种特定用例中如何生成我们可以在屏幕上看到的组件。


我们可以看到的第一件事是,泛型类型 N 被固定为 LayoutNode。这是 Compose UI 选择用来表示将呈现的 UI 节点的节点类型。

接下来我们注意到的是它继承了 AbstractApplier 类。这是一个默认实现,它在一个堆栈中存储已访问的节点。每当访问树的下一个节点时,它都会将其添加到堆栈中,每当访问者向上移动时,它将从堆栈顶部删除最后一个访问的节点。这通常是applier之间共用的逻辑,因此将其放在一个共同的父类中是一个好主意。

我们还可以看到在 UiApplierinsertTopDown 方法被忽略了,因为在 Android 中,插入操作将会是自下而上的。正如我们之前所说,选择一种策略而非两者同时使用非常重要。在这种情况下,自下而上会更为合适,以避免在插入新子节点时出现重复通知。关于性能方面的差异我们之前也已经解释过了。

方法插入、删除或移动节点都是委托给节点本身处理的。LayoutNodeCompose UI模型化UI节点的方式,因此它知道父节点和子节点的所有信息。插入节点意味着将其附加到给定位置的新父节点中(它可以有多个子节点)。移动它本质上是重新排序其父节点的子节点列表。最后,将其删除只需将其从列表中移除。

附加和绘制节点

现在,我们终于能回答这个真正的问题:将一个节点插入树中(即将其附加到其父节点)是如何让它最终呈现在屏幕上的?答案是:节点知道如何附加并绘制自己

LayoutNode(布局节点)是针对 Android UI 这一具体用例选择的节点类型。当 UiApplier 实现委托将插入操作交给它时,会按照以下顺序发生事情:

  • 检查是否满足插入节点的条件。 例如:它还没有父节点。
  • 无效化 Z 轴排序的子节点列表。这是一个平行列表,通过 Z 轴索引维护所有子节点的排序,以便按顺序绘制它们(较低的 Z 轴索引优先)。无效化该列表将使其在需要时重新创建(并排序)。
  • 将新节点附加到其父节点和其Owner
  • Invalidate (无效化)

Owner 位于树的根部,实现了 Composable 树与底层 View 系统的连接。我们可以将其视为与 Android 系统的薄集成层。实际上,它是由 AndroidComposeView(一个标准 View)实现的。所有布局、绘制、输入和 accessibility 都是通过 owner 进行 hook 处理的。一个 LayoutNode 节点必须附加到 Owner 上才能够在屏幕上显示出来,而且它的 owner 必须与其父项的 owner 相同。Owner 也是 Compose UI 的一部分。在我们附加一个节点之后,可以通过 Owner 调用 invalidate,以便渲染 Composable 树。

最后,最终的集成点发生在Owner被设置时。这发生在我们从ActivityFragmentComposeView调用setContent的时候。此时,会创建一个AndroidComposeView,然后被附加到 View 视图层次结构中,并将其设置为 Owner,以便可以根据需要执行 invalidate 操作。

至此,我们终于知道 Compose UI 如何为 Android 系统实现一棵节点树。

Composition

我们在之前的部分中学习了许多关于 Composer 的有趣细节。我们知道它如何记录更改以读取或写入 slot table ,知道当组合期间 Composable 函数执行时如何发射这些更改,以及这些记录的更改最终是如何被应用的。但事实上,我们尚未提及谁负责创建组合,以及如何、何时进行组合,以及涉及哪些步骤。因此,组合是我们到目前为止缺失的一块。

我们曾经说过,Composer 有一个对 Composition 的引用,但这可能会让我们认为Composition 是由 Composer 创建和拥有的,实际上情况恰恰相反。当创建一个Composition 时,它自己会创建一个 ComposerComposer 通过 currentComposer 机制变得可访问,它将被用于创建和更新由 Composition 管理的树形结构。

客户端库连接到 Jetpack Compose runtime 的入口点分为两个不同的部分:

  • 编写 Composable 函数:这将使它们能够发射所有相关信息,从而将我们的用例与 runtime 连接起来。
  • Composable 函数很好,但如果没有 Composition 过程,它们将永远不会执行。这就是为什么需要另一个入口点:setContent。这是与目标平台的集成层,在这里会创建并启动一个Composition

创建 Composition

Android 平台为例,它是通过调用 ViewGroup.setContent 来返回一个新的 Composition:


WrappedComposition 是一个装饰器,它知道如何将 Composition 链接到一个AndroidComposeView,以便将其直接连接到 Android View 系统。它启动受控效果来跟踪诸如键盘可见性更改或 accessibility 之类的内容,并将关于 Android Context 的信息以 CompositionLocals 的形式传输暴露给 Composition (例如:Context本身、配置、当前LifecycleOwner、当前savedStateRegistryOwner或Owner的View等等)。这就是为何这些内容在我们所有的Composable 函数当中是隐式可用的。

请注意,一个指向树的根 LayoutNodeUiApplier 实例被传递给了 Composition 对象。(Applier 是一个节点访问者,所以它从根节点开始指向)。这是我们第一次明确看到客户端库负责选择 Applier 的实现。

我们还可以看到最后调用了 composition.setContent(content)Composition#setContent 是设置 Composition 内容的方法。 (使用content提供的所有信息更新Composition)

另一个非常好的创建 Composition 的例子是 VectorPainter,它也是 Compose UI 的一部分,它用于在屏幕上绘制矢量图形。 VectorPainter 会创建并维护它自己的 Composition


我们将在以后更深入地探讨这个问题,以讨论Jetpack Compose的高级用例。但是我们可以在这里注意到不同的 Applier 策略是如何被选择的:一个VectorApplier,它从向量树的根节点开始指向,而这种情况下将是一个VNode

最后,我们还可以在 Compose UI 中找到另一个示例,即 SubcomposeLayout。它是一种布局,它维护自己的 Composition,因此在测量阶段可以子组合其内容。当我们需要父级的测量来组合其子级时,这非常有用。

无论何种用例,每当创建一个Composition时,都可以传递父级 CompositionContext对象(见上文)。但请注意,它可能为 null。父上下文(如果可用)将用于将新的 Composition 以某种逻辑链接到现有的 Composition,这样 InvalidationsCompositionLocals 可以在不同的组合中解析,就像它们是同一个组合一样。

创建 Composition 时也可以传递一个 recompose context,这将是 Applier 用于应用更改并最终实现树形结构的 CoroutineContext。如果未提供,则默认值为由 Recomposer 提供的CoroutineContext,即 EmptyCoroutineContext。这意味着 Android 可能会在AndroidUiDispatcher.Main 主线程调度器上进行重组。

在创建 Composition 的同时,也必须记得将其销毁——即在不再需要它时调用 composition.dispose() 。这是同时也是其 UI(或其他用例)被销毁的时刻。我们可以说 Composition 作用域是其 owner 所有者。有时候销毁可能有点隐蔽,例如 ViewGroup.setContent (背后是由生命周期观察者负责销毁),但它总是存在的。

初始组合过程

每当创建一个新的 Composition 时,紧随其后的总是调用 composition.setContent(content) (参见前面的两个片段)。事实上,这就是 Composition 最初填充数据的地方(slot table将填充相关数据)。

这个调用会被委托给父级 Composition 去触发初始的 Composition 过程(记住CompositionsSubcompositions是通过父级的CompositionContext链接在一起的)。

对于 Subcompositionsparent 将是另一个 Composition。对于 root 组合,parent 将是 Recomposer。但无论如何,执行初始组合的逻辑始终依赖于 Recomposer,因为对于子组合,composeInitial方法的调用会被不断地委托给父组合,直到达到 root 根组合。

因此,对于parent.composeInitial(composition, content)的调用可以转换为recomposer.composeInitial(composition, content),它在此处执行了几件重要的事情来填充初始的 Composition

  • 获取所有 State 对象当前值的快照。这些值将与其他快照的潜在更改隔离开来。这个快照是可变的,同时也是并发安全的。它可以安全地修改,而不影响任何其他现有的 State 快照,因为任何其他 State 对象的更改只会发生在其自身的该快照中,并且在稍后的步骤中,它将原子性的与全局共享状态同步所有这些更改。
  • 可变快照中的 State 值,只有在调用 snapshot.enter(block: () -> T) 时传递的 block 代码块中,才能被修改。
  • 在进行快照时,Recomposer 还会传递观察者,用于观察 State 对象的读写,因此当这些操作发生时 Composition 可以获得通知。这使得 Composition 能够标记受影响的重组范围为已使用,这将在适当的时候使它们重新组合。
  • 进入快照 snapshot.enter(block) 即通过传递以下block执行快照:composition.composeContent(content)。这是组合实际发生的地方。进入快照的含义是让 Recomposer 知道在组合期间读写任何 State 对象都将被跟踪(通知给Composition)。
  • 这个步骤将组合过程委托给了 Composer。下面会更详细地介绍这个步骤。
  • 当组合完成后,任何对 State 对象的更改都只会应用于当前 State 的快照 ,因此接下来就是将这些更改传播到全局状态,这是通过snapshot.apply()实现的。

这是关于 Compose 中初始 Composition 的大致顺序。所有关于状态快照系统的内容将在以后详细展开。

现在,让我们详细说明实际的 Composition 过程本身,这个过程委托给了 Composer。以下是大致的过程。

  • 如果 Composition 已经在运行,就不能再启动了。在这种情况下,会抛出异常并丢弃新的Composition。不支持重入式Composition
  • 如果存在任何挂起的失效 ,它会将这些复制到由 Composer 维护的无效列表中,以等待无效化的 RecomposeScopes
  • 将标志isComposing设置为true,因为 Composition 即将开始。
  • 调用startRoot()来启动Composition,这将在slot table中启动 Compositionroot group 并初始化其他所需的字段和结构。
  • 调用startGroup来为slot table中的内容启动一个组。
  • 调用 content lambda 以发出所有更改。
  • 调用 endGroup 以结束 slot table 中的组。
  • 调用 endRoot() 以结束 Composition
  • 将标志 isComposing 设置为 false,因为 Composition 已完成。
  • 清除维护临时数据的其他结构。

在初始组合后应用更改

在初始组合之后,会调用 composition.applyChanges() 通知 Applier 应用在该过程中记录的所有更改。Composition 也会调用 applier.onBeginChanges(),遍历所有更改,并将所需的 ApplierSlotWriter 实例传递给每个更改。最后,在所有更改都应用后,它会调用applier.onEndChanges()

在此之后,Compose 会分发所有已注册的 RememberedObservers,这样任何实现 RememberObserver 接口的类都可以在进入或离开 Composition 时得到通知。例如,LaunchedEffectDisposableEffect 都实现了该接口,因此它们可以将副作用限制在 Composable 的组合的生命周期内。

在此之后,所有的 SideEffect 都按照它们被记录的顺序依次触发。

关于组合的附加信息

Composition 能感知到重组时挂起的无效操作,并且也知道自己是否正在进行组合。这个信息可以用于立即应用无效性(当它正在组合时),否则就推迟应用它们。当正在进行组合时,Recomposer 也可以利用这个信息来跳过重组。

runtime 依赖于 Composition 的一种变体:ControlledComposition ,它添加了一些额外的功能,以便可以从外部控制它。这样,Recomposer 可以协调无效性和进一步的重组。composeContentrecompose 等函数就是很好的例子。如果需要的话,Recomposer 可以在组合中触发这些操作。

Composition 提供了一种方法来检测是否正在观察一组对象,以便在这些对象变化时强制重组。 例如,Recomposer 在父组合中的 CompositionLocal 变化时,可以使用此功能强制在子组合中进行重组。记住,在这种情况下,Compositions 是通过父元素 CompositionContext 来连接的。

有时候在组合期间会发现错误,这种情况下可以终止组合,这基本上会重置Composer及其所有引用、堆栈和其他一切。

Composer 在不插入、重用任何东西,也没有无效操作提供者(无效会导致重组)的情况下,假定它正在跳过重组。并且当前的 currentRecomposeScope不需要重组。

Recomposer

我们已经知道了初始组合是如何进行的,并且了解了一些关于RecomposeScopes和失效(invalidation)的知识。但是,我们仍然几乎不知道Recomposer实际上是如何工作的。它是如何创建的,何时开始运行?它是如何开始监听失效以自动触发重组的?

Recomposer 控制 ControlledComposition,并在需要时触发重组来最终应用更新。它还确定要在哪个线程上进行组合或重组,以及要用于应用更改的线程。

创建 Recomposer

客户端库进入Jetpack Compose的入口是创建一个Composition并在其上调用setContent(参见上面的“创建Composition”部分)。在创建Composition时需要为其提供 parent。由于 root Composition 的父级是Recomposer,因此这也是创建它的时刻。

这个入口是平台和 Compose runtime 之间的连接,并且是由客户端提供的代码。在 Android 平台中,客户端就是指 Compose UI 库。该库创建一个 Composition(内部创建其自己的Composer),以及一个Recomposer用作其父级。

请注意,正如我们之前了解的那样,每个平台的每个潜在用例都可能创建自己的 Composition,同样,它也可能创建自己的 Recomposer

当我们想要在 AndroidViewGroups 中使用 Compose 时,我们调用 ViewGroup.setContent 方法,最终在一些间接操作之后,会将 parent context 的创建委派给一个 Recomposer 工厂:

该工厂为当前窗口创建一个 Recomposer。我认为创建过程非常有趣,可以探索 Android 如何解决与 Compose 的集成。

调用 createRecomposer 需要传递对根视图的引用,因为创建的 Recomposer 将具有生命周期感知能力,这意味着它将链接到 View 视图树的根部的 ViewTreeLifecycleOwner。这将允许在View 视图树被 unattached 时取消(关闭)Recomposer,例如,这对于避免泄漏重组过程非常重要。此过程将被建模为一个挂起函数,否则可能会泄漏。

接下来讲述Compose UI的情况:在Compose UI中,UI上发生的所有事情都是使用AndroidUiDispatcher协调/调度的,因此它与一个Choreographer实例和一个用于主线程Looperhandler相关联。此调度程序在处理程序回调或Choreographer的动画帧阶段期间执行事件分派,以先到者为准。它还有一个关联的MonotonicFrameClock,使用suspend来协调帧渲染。这是Compose中驱动整个UX的部分,像动画这样的东西非常依赖它以实现与系统帧同步的平滑体验。

工厂函数的第一件事是创建一个PausableMonotonicFrameClock。这是AndroidUiDispatcher单调时钟的包装器,它添加了对手动暂停withFrameNanos事件的支持,直到它被恢复。这使得它非常适用于在特定时间段内不应该生成帧的情况,例如,当托管 UI 的窗口不再可见时。

任何MonotonicFrameClock也是一个CoroutineContext.Element,这意味着它可以与其他CoroutineContexts组合使用。

在实例化 Recomposer 时,我们必须为其提供 CoroutineContext。此上下文是使用AndroidUiDispatcher的当前线程上下文和刚刚创建的可暂停帧时钟的组合创建的。

这个组合上下文将被 Recomposer 用于创建一个内部 Job,以确保在关闭 Recomposer 时可以取消所有组合或重组的效果。例如,当Android窗口被销毁或unattached时,将需要此操作。该上下文将用于在组合/重组之后应用更改,并且将是LaunchedEffect使用的默认上下文,以运行副作用。这使得副作用会在我们用于应用更改的相同线程中启动,在Android中通常是主线程。当然,我们始终可以在我们的副作用中随意跳出主线程。

所有副作用处理程序都是可组合函数,因此会发出记录的更改。LaunchedEffect实际会被记录并在需要时写入slot table,因此它是可感知Composition生命周期的,不像 SideEffect

最后,使用相同的 combined context 创建了一个协程作用域:即 val runRecomposeScope = CoroutineScope(contextWithClock)。这个作用域将用于启动重组作业(一个挂起函数),它将等待失效并相应地触发重组。让我们来瞅一眼代码:

这里一个观察者被添加到视图树的生命周期上,并且它使用 pausableClock 在视图树为 startedstopped 状态时进行恢复和暂停事件分发。它还会在onDestroy时关闭(取消)Recomposer,并在onCreate时启动重组作业。

重组作业是由 recomposer.runRecomposeAndApplyChanges() 启动的,它就是前面提到的挂起函数,它将等待任何相关 Composers(及其 RecomposeScopes)的失效,重组它们,最终将新更改应用于它们关联的 Composition

这个工厂函数展示了Compose UI如何生成与Android生命周期相连的Recomposer。它很好地展示了RecomposerComposition在与平台进行集成时的创建方式。 我们再次复习一下如何在设置ViewGroups的内容时创建Composition


这里的 parent 将是一个 Recomposer,并且将由setContent的调用方(对于此用例来说是AbstractComposeView)提供。

重组过程

recomposer.runRecomposeAndApplyChanges() 函数被调用以开始等待无效化并在发生无效化时自动重新组合。让我们探索学习所涉及的不同步骤。

在之前的部分中,我们学习了 snapshot State 如何在其自己的快照中进行修改,但稍后需要通过snapshot.apply()将这些更改传播到全局状态进行同步。当调用recomposer.runRecomposeAndApplyChanges()时,它首先注册了一个观察者来进行这种变化传播。当发生这种变化时,这个观察者会唤醒并将所有这些更改添加到快照失效列表中,这些列表将传播到所有已知的 Composer,以便它们可以记录组合的哪些部分需要重组。简单来说,这个观察者是触发状态更改时自动重组的一个跳板

在注册快照应用观察者之后,Recomposer 使所有 Compositions 失效,以假设所有内容都已更改为起点。在此之前发生的任何更改都没有被跟踪,因此这是从头开始的方法。然后,它会暂停,直到有工作可用于重组。 “有可用工作” 意味着有任何未处理的 State 快照失效或来自 RecomposeScopes 作用域的组合失效。

接下来 Recomposer 会使用创建它时提供的单调时钟,并调用parentFrameClock.withFrameNanos 来等待下一帧。从这里开始的剩余工作都将在那一刻完成(而不是之前)。这样做的目的是将更改聚合到帧中。

在这个块中,Recomposer 首先为任何潜在的等待器(比如动画)分派单调的时钟帧。这可能会产生新的失效结果,也需要进行跟踪(例如在动画结束时切换一个有条件的Composable)。

现在是真正行动的时候了。Recomposer 获取所有 挂起的快照失效,换句话说,自上次调用重组以来修改的所有 State 值,并将所有这些更改记录在 Composer 中作为挂起的重组。

还可以通过 composition.invalidate() 使 composition 失效,例如当一个State被写入Composable lambda 时。对于其中的每一个,Recomposer 都会执行重组(下面将对此进行讨论),并将其添加到待应用更改的composition列表中。

重组意味着重新计算组合状态(slot table)和构建树(Applier) 。重新组合将重用所有这些代码,因此没有必要重复流程遵循的所有步骤。

之后,它会发现由于组合的值更改而需要组合的潜在跟踪重组,并安排它们进行重组。例如,如果一个父组合中的 CompositionLocal 发生变化,并在子组合中读取。

最后,它遍历所有要应用更改的 Compositions,并对它们调用 composition.applyChanges()。之后,它更新 Recomposer 的状态。

并发重组

Recomposer具有并发执行重组的能力,即使 Compose UI 没有使用这个特性。不过,任何其他客户端库都可以根据需要依赖它。

Recomposer 提供了 runRecomposeAndApplyChanges 函数的并发对应物,称为 runRecomposeConcurrentlyAndApplyChanges。这是另一个用于等待状态快照失效并像前者那样触发自动重组的挂起函数,但唯一的区别是后者将在外部提供的CoroutineContext中的执行失效组合的重组:

这个挂起函数使用传递的上下文创建自己的CoroutineScope,并使用它来生成和协调所有为并发重组而创建的所有 child jobs

Recomposer 状态

Recomposer 在其生命周期中会切换一系列状态:

下面是这些状态的含义:

  • ShutDown:Recomposer 被取消,清理工作完成。不能再使用。
  • ShuttingDown:Recomposer 已被取消,但仍在清理过程中。不能再使用。
  • Inactive:Recomposer 将忽略来自 Composers 的无效,并且不会触发重组。必须调用runRecomposeAndApplyChanges来开始监听。这是 Recomposer 创建后的初始状态。
  • InactivePendingWork:有可能 Recomposer 是不活动的,但已经有一些等待帧的挂起效果。帧将在 Recomposer 开始运行时立即生成。
  • Idle:Recomposer 正在跟踪组合和快照失效,但目前没有工作要做。
  • PendingWork:Recomposer 已经收到了待完成工作的通知,并且已经在执行它或等待执行它的机会。

总结