100字范文,内容丰富有趣,生活中的好帮手!
100字范文 > Jetpack Compose 深入探索系列四: Compose UI

Jetpack Compose 深入探索系列四: Compose UI

时间:2023-12-10 11:11:10

相关推荐

Jetpack Compose 深入探索系列四: Compose UI

通过 Compose runtime 集成 UI

Compose UI 是一个 Kotlin 多平台框架。它提供了通过可组合函数发出 UI 的构建块和机制。除此之外,这个库还包括 Android 和 Desktop 源代码,为 Android 和 Desktop 提供集成层。

JetBrains积极维护Desktop代码库,而Google维护Android和通用代码库。Android和Desktop源代码库都依赖于通用源代码库。到目前为止,Web还没有出现在Compose UI中,因为它是使用DOM构建的。

当使用 Compose runtime 集成 UI 时,目标是构建用户可以在屏幕上体验的布局树。这个树是通过执行发出 UI 的 Composable 函数来创建和更新的。用于树的节点类型只有 Compose UI 知道,所以 Compose runtime 可以不知道它。即使 Compose UI 本身已经是一个Kotlin多平台框架,它的节点类型到目前为止只被 Android 和 Desktop 支持。其他库,如 Compose for Web 使用不同的节点类型。出于这个原因,客户端库发出的节点类型必须只有客户端库知道,并且 runtime 将从树中插入、删除、移动或替换节点的操作委托给客户端库。我们将在后面讨论这个问题。

初始组合和后续的重组过程都参与了布局树的构建和更新过程。这些过程执行我们的 Composable 函数,这使它们能够安排从树中插入、删除、移动或替换节点的更改。这将创建一个更改列表,稍后将使用 Applier 遍历这些更改,以检测影响树结构的更改,并将这些更改映射到树的实际更改,以便最终用户可以体验它们。 如果我们在初始的组合过程中,这些更改将插入所有节点,从而建立我们的布局树。如果我们在重组过程中,他们会被更新。当我们的可组合函数的输入数据(即参数或读取的可变状态)发生变化时,将触发重组。

从 Compose UI 的角度来看组合

如果我们以 Android 集成为例,从 Compose UI 库进入 runtime 的更频繁的入口点发生在我们调用setContent时,可能是针对我们的一个屏幕。

但是屏幕 (例如Android中的Activity/Fragment) 并不是我们可以找到setContent调用的唯一地方。它也可以发生在我们的视图层次结构的中间,例如,通过ComposeView(例如在一个混合 Android App里)。

在本例中,我们通过编程方式创建视图,但它也可以是应用程序中通过XML定义的任何布局层次结构的一部分。

setContent函数创建了一个新的root Composition,然后尽可能地重用它。我把它们称为“根”组合,因为每个组合都有一个独立的 Composable 树。这些组合之间没有任何联系。每个组合将像它所代表的 UI 一样简单或复杂。

在这种思维模式下,我们可以想象应用程序中有多个节点树,每个节点树都链接到不同的Composition。让我们想象假如有一个包含 3 个Fragment的 Android 应用,其中Fragment1Fragment3调用setContenthook它们的 Composable 树,而Fragment2在它的布局上声明了多个ComposeView(并调用setContent)。在这种情况下,我们的应用程序将有 5 个根Composition,它们都完全独立。

为了创建这些布局层次结构,相关的Composer将运行组合过程。相应setContent调用的所有Composable函数都将执行并发出它们的更改。对于Compose UI,这些更改就是插入、移动或替换 UI 节点,而这些操作通常是由构建 UI 的 block 代码块发出的。即:BoxColumnLazyColumn等。即使这些Composables函数通常属于不同的库(foundationmaterial),它们最终都被定义为Layoutcompose-ui),这意味着它们发出相同的节点类型:LayoutNode

LayoutNode在之前介绍过,它是 UI block 的表示形式,因此在Compose UI中,它是用于根Composition最常用的节点类型。

任何Layout Composable函数都会将LayoutNode节点发射到Composition中,这是通过ReusableComposeNode发射的。(请注意,ComposeUiNode是一个通用接口协议,LayoutNode接口实现了它)

这里发出一个更改,将可重用的节点插入或更新到 composition 中。这将适用于我们使用的任何 UI 构建 block 块。

可重用节点是 Compose runtime 中的一种优化。当节点的键(key)发生变化时,可重用节点允许 Compose 在重组时更新节点内容(就地更新),而不是将其丢弃并创建一个新节点。为了实现这一点,Composition 就像正在创建新的内容一样,但是slot table在重组时被遍历。这种优化仅适用于那些在发射调用过程中,可以完全被setupdate操作描述的节点,或者换句话说,不包含隐藏内部状态的节点。这对于LayoutNode是正确的,但对于AndroidView就不是这样了。因此,AndroidView使用标准的ComposeNode而不是可重用节点。

在上面代码中,我们可以看到ReusableComposeNode会创建节点(通过factory工厂函数),初始化它(updatelambda),并创建一个可替换的group组来包装所有内容。该group会被分配一个唯一的key,以便稍后可以识别它。通过可替换的group内的contentlambda 的调用而发出的任何节点,实际上都将成为该节点的子节点。

updateblock 块内的set方法调用会安排其后的 lambda 执行。这些 lambda 执行的时机是:节点第一次创建时,或对应属性的值自上次被remembered后已更改时。

这就是LayoutNode是如何被提供给我们应用中的多个Compositions的。这可能会让我们认为任何Composition都只包含LayoutNode。但这是错误的!在Compose UI中,还有其他类型的Compositions和节点类型需要考虑。虽然LayoutNodes是最常用的节点类型,但还有其他类型的节点,比如ModifierNodeAttachNodes等等。它们都是由 Composable 函数发出,但是不同于 LayoutNode,它们可能只代表对树上现有节点的修改而非全新节点的插入或替换。因此,runtime 需要有一些机制来处理这些不同类型的节点并将它们合并到同一棵树中。

从 Compose UI 的角度来看子组合(Subcomposition)

Composition不仅仅存在于root级别。我们也可以在Composable 树的更深层次中创建Composition,并将其链接到其父Composition。这就是Compose所谓的Subcomposition

我们在前面学到的一件事是,Composition可以连接为树形结构。也就是说,每个Composition 都有一个指向其父 CompositionContext 的引用,该引用代表其父Composition(当root的parent为Recomposer本身时除外)。这就是 runtime 如何确保CompositionLocals和无效化可以像单个Composition一样在树中向下传播的方式。

在Compose UI中,创建Subcomposition主要有两个原因:

推迟初始组合过程,直到某些信息已知。修改子树生成的节点类型。

让我们来讨论一下这两个原因。

延迟初始组合过程

我们有一个关于SubcomposeLayout例子,它类似于Layout,会在布局阶段创建和运行一个独立的Composition。这允许子Composables依赖于其计算的任何结果。例如,BoxWithConstraints组件就是使用SubcomposeLayout实现的,它在 block 块中公开其接收的父约束,以便可以根据它们调整其内容。以下是从官方文档中提取的示例,在BoxWithConstraints中根据可用的最大高度在两个不同的 Composables 之间做出选择:

Subcomposition的创建者可以控制初始组合过程发生的时间,而SubcomposeLayout决定在布局阶段进行它,而不是在根组合时。

Subcomposition允许独立于父Composition进行重组。例如,在SubcomposeLayout中,每当发生 layout 布局时,传递给其 lambda 的参数可能会发生变化,这将触发重组。另一方面,如果从 Subcomposition 中读取的状态发生更改,则将在执行初始组合后为父组合安排重组。

从发出的节点方面考虑,SubcomposeLayout也会发出一个LayoutNode,因此子树使用的节点类型将与父Composition使用的节点类型相同。这引出了以下问题:是否可以在单个Composition中支持不同的节点类型

好吧,从技术上来讲是可以实现的,只要相应的Applier允许。这取决于节点类型的含义。如果使用的节点类型是多个子类型的共同父级,则可以支持不同的节点类型。即便如此,这样做可能会使Applier的逻辑更加复杂。事实上,在Compose UI中可用的Applier实现都被固定为单个节点类型

也就是说,Subcomposition实际上可以在子树中支持完全不同的节点类型,这是我们前面列出的使用Subcomposition的第二个原因。

更改子树中的节点类型

Compose UI中有一个很好的例子可以解释这个概念:创建和显示矢量图形的Composable(例如:rememberVectorPainter)。

Vector Composables是一个很好的研究案例,因为它们还创建了自己的Subcomposition来将矢量图形建模为一棵树。在组合时,Vector Composable会发出一个不同于LayoutNode的节点类型来提供给其SubcompositionVNode类型。这是一个递归类型,用于建模独立的PathsPaths组。

这里有一件有趣的事情需要思考,通常我们使用VectorPainterImageIcon或其他类似的 Composable 组件中来绘制这些矢量图形,就像我们在上面的代码片段中看到的那样。这意味着包含它的 Composable 是一个Layout,因此它会发出一个LayoutNode到其关联的Composition中。但同时,VectorPainter创建了自己的Subcomposition来模拟矢量图形的树形结构,并将其链接到前一个Composition中(前者会成为其父级)。以下是一个图形示例:

这种配置使得 vector 子树(Subcomposition)可以使用不同的节点类型:VNode

Vectors通过Subcomposition进行建模,因为通常可以从父组合中访问某些CompositionLocals以在vector Composable调用中使用(例如:rememberVectorPainter)。诸如主题颜色或density之类的东西就是很好的例子。

用于矢量图的子组合Subcomposition)会在其对应的VectorPainter 离开父 Composition 时被销毁。我们将在后面学习更多有关 Composable 生命周期的知识,但请记住,任何Composable都会在某个时刻进入和离开Composition

现在我们已经对使用 Compose UI 的应用(Android 或 Desktop)中树的外观有了更完整的了解,其中通常存在root CompositionSubcomposition

反映 UI 中的变化

在前面,我们已经了解了 UI 节点是如何通过初始化合成和后续的重组合成被发射到 runtime 中的。此时,runtime 会接管这些节点,并执行其工作。但这只是一面,还需要存在某种集成来在实际的 UI 中反映所有这些发射的更改,以便用户可以体验它们。这个过程通常被称为节点树的Materialization(中文翻译过来可以叫实体化实例化具体化),这也是客户端库(Compose UI)的职责之一。

不同类型的 Appliers

Applier是一个抽象层,runtime 依赖于它最终实现树中的任何更改。这反转了依赖关系,使 runtime 完全不依赖于使用库的平台。这个抽象层允许客户端库(如Compose UI)hook 自己的 Applier 实现,并使用它们自己的节点类型来集成平台。下面是一个简单的图示:

(顶部的两个方框(ApplierAbstractApplier)是Compose runtime的一部分。底部列出了一些Applier实现,它们由Compose UI提供。)

AbstractApplierCompose runtime提供的基本实现,用于在不同applier之间共享逻辑。它将已访问的节点存储在一个中,并维护对当前访问节点的引用,以便知道应该在哪个节点上执行操作。每当沿着树访问到一个新节点时,Composer通过调用applier#down(node:N)来通知Applier。这会将节点压入栈中,Applier可以对其运行任何所需的操作。

每当访问者需要返回到父节点时,Composer就会调用applier#up(),这将从堆栈中弹出上次访问的节点。

让我们通过一个相当简单的例子来理解它。假设我们有以下需要materializeComposable树:

condition改变时,Applier将会:

接收一个down()调用以访问Column节点。然后是另一个down()调用以进入Row节点。接着是删除(或插入,具体取决于condition)可选子Text节点。然后是一个up()调用以返回到父节点(Column)。最后,删除(或插入)第二个条件文本Text节点。

AbstractApplier提供了堆栈downup操作,因此子applier可以共享相同的导航逻辑,而不管它们使用的节点类型如何。这提供了节点之间的父子关系,因此在导航树时,从技术上讲,它消除了特定节点类型维护此关系的需要。即使如此,特定的节点类型仍然可以自由地实现自己的父子关系,如果它们碰巧需要它们用于特定于客户库的更特定的原因。

这实际上是LayoutNode的情况,因为并非所有操作都在组合期间执行。例如,如果由于某种原因需要重绘节点,则Compose UI会遍历父节点,以查找创建绘制节点所需的层的节点,以便在其上调用invalidate。所有这些操作都发生在组合之外,因此 Compose UI 需要一种方法来自由遍历树形结构。

让我们回顾一下在上一篇文章中,我们提到了Applier如何以从上到下从下到上的两种方式来构建节点树。我们还描述了每种方法的性能影响,并且这些影响取决于每次插入新节点时需要通知的节点数。( 可以点击这里回顾其中的构建节点树时的性能部分 )我想回顾一下这一点,因为Compose UI中实际上有两种构建节点树的策略的例子。这些策略由库使用的两种Applier实现。

Jetpack Compose 提供了两个AbstractApplier的实现来将Android平台与Jetpack Compose runtime集成:

UiApplier:用于呈现大多数Android UI。它将节点类型固定为LayoutNode,因此它将实现我们树中的所有Layouts布局。VectorApplier:用于呈现矢量图形。它将节点类型固定为VNode,以表示和实现矢量图形。

如前面介绍的那样,这是两种节点类型。

到目前为止,这是 Android 提供的唯一两个实现,但是对于一个平台来说,可用的实现并不一定是固定的。如果需要表示不同于现有节点树的节点树,则未来可能会在 Compose UI 中添加更多实现。

根据访问的节点类型,将使用不同的Applier实现。例如:如果我们有一个使用LayoutNodes提供的root Composition,以及使用VNodes提供的Subcomposition,那么两个Applier都将被用于将完整的 UI 树实例化。

让我们快速浏览一下两种 Applier 用于构建树的策略。

UiApplier采用自下而上的方式插入节点。这是为了避免新节点进入树时出现重复通知

这里再贴一遍前一篇中的自下而上插入策略图示以使其更清晰。

自下而上构建树的方式是先将AC插入B,然后将B树插入R中,从而完成树的构建。这意味着每次插入新节点时,只通知直接父节点。这对于Android UI(也就是UiApplier)特别有意义,因为我们通常有很多嵌套(特别是在Compose UI中,过度绘制不是问题),因此需要通知许多祖先。

另一方面,VectorApplier是一个自上而下构建树的示例。如果我们想使用自上而下的策略构建上面的示例树,我们会先将B插入到R中,然后将A插入到B中,最后将C插入到B中。

在这种策略中,每次插入一个新节点时,我们都需要通知它的所有祖先节点。但在矢量图形的情况下,没有必要将通知传播到任何节点,因此两种策略的性能都是相等的,因此完全有效。没有一个很强的理由支持从上往下的构建比从下往上的构建更好。每当一个新的子节点被插入到一个VNode中时,该节点的监听器(listener)会被通知,但是它的子节点或父节点不会被通知。

既然我们已经很好地了解了 Compose UI 库使用的两种不同的 Applier 实现,那么接下来是时候了解它们是如何最终实现UI中的变化的了。

实体化/插入一个新的 LayoutNode

下面是 Compose UI 使用的UiApplier的简化版:

在这个实现中,我们清楚地看到节点类型被固定为LayoutNode,以及所有插入、删除或移动节点的操作都委托给当前访问的节点。这是有道理的,因为LayoutNode知道如何实现自身,因此 runtime 可以对此保持无感知。

这个LayoutNode是一个纯 Kotlin 类,没有任何Android依赖,因为它只建模了一个 UI 节点,而且应该被多个平台(Android、Desktop)使用。它维护其子节点列表,并提供插入、删除或移动(重新排序)子节点的操作。所有的LayoutNodes被连接成一棵树,所以每个LayoutNode都有一个指向其父节点的引用,并且它们全部连接到同一个Owner。每个新加入的节点都需要满足这个要求。下面是我们之前的文章中提到是层次结构图。

这里的Owner是一个抽象层,因此每个平台可以不同的方式实现它。它有望成为与平台的集成点。在Android中,它是一个View(AndroidComposeView),因此它是我们的Composable 树LayoutNodes) 和Android View系统之间的底层连接。

无论何时节点被attachdetachreoderremeasure或如何update,都可以通过使用旧的Android ViewAPI 的Owner触发失效,因此最新的更改将在下一个绘制阶段反映在屏幕上。这就是奇迹发生的过程。

让我们深入研究一下LayoutNode#insertAt操作的简化,以便了解如何插入新节点并实现新节点。(注意,这里故意省略一些细节,因为实现细节可能会随着时间的推移而不断变化。)

在进行了几次安全检查以确保该节点没有在树中,也没有附加,然后将当前节点设置为插入的新节点的父节点。然后,新节点被添加到它的新父节点维护的子节点列表中。在此之上,按照Z轴索引中排序的子元素列表将会被失效。这是一个并行列表,它维护所有按照Z轴索引排序的子元素,因此它们可以按顺序绘制(Z轴 index 较低的先绘制)。使列表无效会使它重新排序。在插入一个新节点之后,这是必要的,因为Z轴索引不仅由布局中placeable.place()调用的顺序(它们被摆放的顺序)决定,而且还可以通过修饰符设置为任意值。如Modifier.zIndex()。(这相当于在布局中,当过时的Views被放置在其他Views之后时,如何将它们显示在其他Views之上,以及如何将它们的Z轴索引设置为任意值)。

接下来我们看到的是一个涉及某种 “outer” 和 “inner” 的LayoutNodeWrappers的赋值。

这与一个Node节点、其修饰符、及其子节点如何被测量和绘制有关。(由于涉及一些复杂性,因此将后面独立的小节中详细描述)

最后就是附加节点的时候了(attach),这意味着将其分配给与其新的父节点相同的Owner。下面是attach调用的简化,它在插入的节点上调用instance.attach(owner):

在这里,我们发现有一个防御代码,它强制要求所有子节点都被分配与其父节点相同的Owner。由于attach函数会在子节点上递归调用,因此挂在该节点上的完整子树最终将附加到同一个Owner。也就是说,将来自同一个 Composable 树的任何节点所需的所有失效强制通过同一个View进行传递,这样它就可以处理所有协调。在防御代码之后,owner对象被保存到当前节点中。

此时,如果附加的节点包含任何语义元数据,就会通知Owner。这可能发生在插入节点或删除节点时。如果更新了节点的值,或者,添加或删除了语义修饰符而没有添加或删除实际的节点,也会发生这种情况。在Android中,Owner有一个专门的 accessibility 委托,负责转发通知,因此它可以处理更改,更新语义树,并与 Android SDK accessibility API 进行连接。

语义树是一个并行的树,以一种可以被辅助功能服务和测试框架理解的方式描述UI。有两棵并行的语义树,我们将在后面的部分详细描述它们。

然后,主动请求了对新节点及其父节点进行重新测量(即requestRemeasure())。这是整个过程中非常重要的一步,因为它有效地实体化了节点:任何重新测量请求都通过Owner传递,如此它就可以在需要时,使用View原语来调用invalidate()或者requestLayout()。这样就使得节点最终显示在屏幕上了。

完成闭环

为了完整闭环,当在ActivityFragmentComposeView中调用setContent函数时,Owner将被附加到视图层次结构中。这是目前唯一缺失的部分。

让我们通过一个简单的例子来回顾一下整个过程。假设我们有以下树形结构,其中调用了Activity#setContent,因此会创建一个AndroidComposeView并将其附加到视图层次结构中。同时我们已经有了一些LayoutNodes:一个根节点((LayoutNode(A))和一个子节点(LayoutNode(B))。

现在,让我们假设Applier调用current.insertAt(index, instance)来插入(实例化)一个新节点LayoutNode(C)。这将连接新节点,并通过Owner请求自己和新父节点的重新测量。

当这种情况发生时,大多数情况下会调用AndroidComposeView#invalidate。 就算如果有两个节点(当前节点和父节点)在同一时刻对相同的ViewOwner)发起失效操作,也没有关系,因为无效化就是将View标记为dirty(脏的) 。在两个帧之间,你可以多次执行此操作,但在任何情况下,View都将仅在下一个绘制阶段重绘一次。在那个时候,AndroidComposeView#dispatchDraw将被调用,这是 Compose UI 执行所有请求节点的实际重新测量布局的地方。如果在这个重新测量过程中,根节点的大小发生了变化,则会调用AndroidComposeView#requestLayout(),以便重新触发onMeasure并能够影响所有View兄弟节点的大小

测量和布局完成后,dispatchDraw调用将最终调用根LayoutNodedraw函数,它知道如何将自己绘制到Canvas画布上并触发其所有子节点的绘制。

如果在节点正在测量时发出了重新测量节点的请求,则会被忽略。如果节点已经安排了重新测量,则同样会发生这种情况。

节点通常是先进行测量,然后是布局,最后是绘制。这是基本的顺序。

这就是插入/实例化新节点以便用户可以在屏幕上体验的过程。

删除节点

删除一个或多个子节点看起来非常相似。UiApplier调用current.removeAt(index, count)方法将删除任意数量的子项委托给当前节点本身。然后,当前节点(父节点)从最后一个开始迭代要删除的所有子项。对于每个子项,它从当前节点的子项列表中删除该子项,触发Z轴索引子项列表的重新排序,并将子项及其所有后代从树中分离。为此,它将它们的owner引用重置为null,并请求对父项的重新测量,因为它将受到删除的影响。

与添加新节点时一样,如果由于移除节点而导致语义发生了更改,Owner将被通知。

移动节点

换句话说,也就是重新排列子节点。当UiApplier调用current.move(from, to, count)来移动一个或多个子节点时,它也会遍历它们并对要移动的每个节点调用removeAt(如上所述)。然后,它将节点再次添加到其新位置。最后,它请求对当前节点(父节点)进行重新测量。

清除所有节点

这与删除多个节点相同。它迭代所有子节点(从最后一个开始),并分别分离每个子节点,从而请求父节点重新测量。

Compose UI 中的测量

我们已经知道何时以及如何请求重新测量。现在是了解测量实际工作原理的时候了。

任何LayoutNode都可以通过Owner请求重新测量,例如当一个子节点被添加、删除或移动时。此时,视图(Owner)被标记为 “dirty”(无效,invalidate),节点会被添加到一个需要重新测量和重新布局的节点列表中。在下一次绘制过程中,AndroidComposeView#dispatchDraw将被调用(就像对于任何无效的ViewGroup一样),AndroidComposeView将遍历列表并使用代理执行这些操作。

对于每个被安排进行重新测量和重新布局的节点,会按照以下顺序执行3个步骤:

检查节点是否需要重新测量,并在需要时进行测量。在测量完成后,检查节点是否需要重新布局,并在需要时进行布局。最后,检查是否有任何节点需要延迟测量请求,并在下一次绘制周期中安排重新测量。也就是将它们添加到下一次需要重新测量和重新布局的节点列表中,从而回到步骤1。当在进行测量时发生测量请求时,这些测量请求会被延迟。

在对每个节点进行测量时(步骤1),它将委托给外部的LayoutNodeWrapper。还记得吗?我们说过我们会详细介绍LayoutNode的内部和外部包装器,所以现在就来介绍一下。但在此之前,有一个小细节:如果测量导致节点的大小发生变化,并且该节点具有父级,则它将根据需要为父级请求重新测量或重新布局。

我们现在来了解一下包装器,以便了解测量的过程。

回到LayoutNode#insertAt函数(由UiApplier调用以插入新节点),我们观察到与外部和内部LayoutNodeWrappers相关的赋值操作。

每个LayoutNode都有一个外部的和一个内部的LayoutNodeWrapper外部的负责测量绘制当前节点,内部的负责对其子节点执行相同的操作。

这很棒,但是很遗憾也不完整。实际上,节点可以应用修饰符,而修饰符也可以影响测量,因此在测量节点时还需要考虑修饰符。例如:Modifier.padding直接影响节点子项的测量。除此之外,修饰符甚至可以影响链式连接在它们后面的其他修饰符的大小。例如:Modifier.padding(8.dp).background(Color.Red),只有应用 padding 填充后的空间会被涂成红色。这意味着需要将修饰符的测量大小存储在某个地方。但是Modifier是一个无状态的东西,因此需要一个包装器来保存其状态。因此,LayoutNode不仅具有外部和内部包装器,还具有每个应用于它的修饰符的包装器。所有包装器(外部的、修饰符的和内部的)都被链接在一起,以便按顺序解决和应用。

一个修饰符的包装器包括其测量大小,还包括其他受测量影响的 hooks,例如执行绘制的 hooks(用于Modifier.drawBehind()等修饰符)或与触摸检测相关的 hooks。

下面是所有包装器如何链接在一起的方式。

父 LayoutNode 使用它的 measurePolicy(在 Layout composable 中定义,稍后会详细介绍)来测量它所有子节点的外部包装器(outer wrapper)。每个子节点的外部包装器包装(wraps)链中的第一个修饰符(modifier)。第一个修饰符包装第二个修饰符。第二个修饰符包装第三个修饰符。第三个修饰符包装内部包装器(inner wrapper)(假设该节点有 3 个修饰符)。这将带我们回到第 1 步:内部包装器使用当前节点的 measurePolicy 来测量每个子节点的外部包装器。

这确保了测量按顺序进行,并且还考虑了修饰符。请注意,这种包装仅适用于 Compose 1.2 开始的LayoutModifiers,因为其他类型的修饰符被包装为更简单的抽象。由于 Jetpack Compose 团队在不断更新迭代,所以你实际看到的源码可能与本文有所出入。本文所讨论的这种包装大概限定在Compose 1.3 之前的版本。 但是,无论最终使用的抽象是什么,方法都是相似的。

在绘制方面,它的工作方式也相同,但在最后一步中,内部包装器只是按 Z 轴索引的顺序迭代子项列表,并对它们的每一个调用绘制方法。

现在让我们回到insertAt函数中的LayoutNodeWrapper赋值部分。

请看这个节点插入时的外部包装器被当前节点(它的新父节点)的内部包装器包装,这由我们上面的图表中的第一步和最后一步表示。

当附加一个新节点时,所有的LayoutNodeWrappers都会收到通知。由于它们是有状态的,因此它们有一个生命周期,因此它们在任何附加和分离上都会被通知,以防它们需要初始化和处理任何东西。其中一个示例是 focus 修饰符,它们在附加时发送 focus 事件。

每当需要重新测量一个节点时,此操作被委托给该节点的外部 LayoutNodeWrapper,它使用父节点的测量策略进行测量。然后,它按照链条依次重新测量其每个修饰符,最后再使用当前节点的测量策略进入内部 LayoutNodeWrapper重新测量其子节点

在测量节点时,任何在测量 lambda(measurePolicy)内读取的可变状态都会被记录。这将使 lambda 在所涉及的状态变化时重新执行自己。毕竟,测量策略是从外部传递的,可以依赖于Compose 状态。(自动读取快照状态的内容部分我们将在以后展开探索)

测量完成后,会将上次测量的大小与当前大小进行比较,如果有变化,则会请求重新测量父节点。

测量策略

当需要测量节点时,相应的LayoutNodeWrapper依赖于发出节点时提供的测量策略。

但是请注意measurePolicy实际上是如何从外部传递的。LayoutNode始终不知道用于测量自身及其子节点的测量策略。Compose UI 期望Layout的任何实现都提供它们自己的测量策略,因为每个用例都不同。

每当LayoutNode的测量策略更改时,都会请求重新测量。

如果你以前在 Jetpack Compose 中创建过任何自定义布局,这可能听起来有点熟悉。测量策略是我们在创建自定义布局时传递给它的 lambda。

最简单的策略之一可能是由SpacerComposable 设置的策略:

后面的 lambda 定义了策略。它实际是MeasurePolicy#measure函数的实现,该函数使用布局子元素的measurables列表(在本例中该参数被忽略了),以及每个子元素应该遵守的约束constraints。这些约束用于确定布局的宽度和高度。当它们被固定(被设置为精确的宽度或高度)时,Spacer基本上会设置它们 (在本例中是maxWidth == minWidthmaxHeight == minHeight)。否则,它默认两个维度都为0。这实际上意味着Spacer总是需要通过父元素或修饰符获得一些施加的大小限制。这是有道理的,因为Spacer这个组件本身不包含任何子元素,它的唯一用途就是用作填充空白间隙,所以它不能适应其包装的内容。

另一个可能更完整的测量策略的例子可以在BoxComposable 中找到:

此策略取决于Box的对齐设置(默认为TopStart,因此它将从上到下和从左到右对齐子元素),以及是否需要对内容施加父元素最小约束。在 Compose 中有很多组件是在Box基础之上定义的,或者在布局中包含了它,如Crossfade,Switch,Surface,FloatingActionButton,IconButton,AlertDialog等等。我们不能全部提到,但由于这个原因,传播最小约束的选项保持开放,以便让每个组件都可以自己决定。

请记住,这些都是实现细节,因此可能会随着时间而改变。尽管如此,它可以作为一个学习的例子。

这意味着,如果Box根本没有子元素,它将适应父元素(或修饰符)施加的最小宽度和高度。在此之后,如果将最小约束设置为要传播给子节点(即propagateMinConstraints=true),它将接受所施加的约束。否则,它会取消最小宽度和高度的限制,让子节点可以自己决定:

在约束准备好之后,它会检查是否只有一个子元素,如果是这样,它会继续测量并放置它:

我们在这里发现了一个有趣的区别。我们有两种场景:调整Box的大小以包装其内容,或者调整Box的大小以匹配其父元素。

当唯一的子元素没有被设置为匹配父元素的大小(例如Modifier.fillMaxSize())时,Box将根据其包装的内容调整其大小。要做到这一点,它首先使用施加的约束来测量子对象。这会返回施加这些限制后,子元素的大小。然后Box使用该大小来确定自己的大小。Box的宽度将是约束中的minWidth和子元素宽度二者的最大值。高度也是一样。这实际上意味着Box永远不会比它的单个子元素更小。

另一方面,当Box被设置为匹配父大小时,它将其宽度和高度设置为与父约束所施加的最小宽度和高度完全相同的值。

但这只适用于只有一个子元素的情况。如果有更多会发生什么?当然,该策略也对此作出了规定。它首先测量所有被设置为不匹配父节点大小的子节点,以获得 Box 的大小:

这个代码片段中的第一行初始化了一个placeables对象的数组,以跟踪所有测量的子对象,因为它最终需要摆放它们。

在此之后,它遍历所有子节点,以计算父节点施加的最小约束与每个子节点为这些约束所采取的宽度和高度之间可能存在的最大宽度和高度。这得使Box能够在它的子元素超过所施加的最小约束时,适应它的子元素,或者以其他方式将最小约束设置为它的大小。由于此过程需要测量每个子元素,因此它在这个过程中将每个子元素的测量结果placeable添加到placeables列表中。请注意,在此过程中,任何被设置为匹配父节点大小的子节点都将被忽略,因为它们将在下一步中用于测量Box。

当测量的子元素设置为匹配父元素大小时,有一些有趣的要点值得注意:如果到目前为止计算的Box拥有无边界的约束(unbounded constraints),则会将测量子元素的最小约束设置为0。这意味着每个子元素都可以决定自己想要多窄。只有前面计算的boxWidthboxHeight等于无穷大时才有可能出现此情况,这是由于当父组件(Box的父组件)约束中施加的最小维度约束是无界的时才会发生这种情况。如果不是这种情况,则将使用已经计算好的boxWidthboxHeight作为测量子元素的最小约束:

在上面代码片段的最底部,我们可以看到所有与父节点大小匹配的子节点使用计算出的约束进行测量,并添加到placeables列表中。

有很多 Compose UI 中的测量策略示例。即使我们无法列出并描述所有这些示例,但学习这个示例应该有助于更详细地了解它们的工作原理。

MeasurePolicy 包括一些方法来计算布局的内在尺寸。也就是说,在没有约束条件的情况下,估计布局的大小。

Intrinsic 固有特性测量

Intrinsic测量在我们实际测量一个子元素之前提供估计尺寸时非常有用。一个例子是:如果我们想要将一个子元素的高度与它的最高兄弟节点的高度匹配,我们在测量阶段如何实现呢,如果兄弟节点还没有被测量过?

有一种方法是使用子组合(subcomposition),但有时我们并不需要那样。我们也可以考虑测量两次,但这是不可能的:Compose 强制对 Composables 进行一次测量(出于性能原因)。试图进行第二次测量会抛出异常。

Intrinsic测量可以是一个很好的妥协解决方案。任何LayoutNode节点都有一个测量策略,正如我们已经了解到的那样,但它也有一个依赖于前者的Intrinsic测量策略。这种依赖关系使得任何依赖节点Intrinsic测量的祖先在节点的测量策略更改时重新计算它们的布局。

LayoutNode上分配的这个Intrinsic测量策略提供了计算以下内容的方法:

给定高度minIntrinsicWidth给定宽度minIntrinsicHeight给定高度maxIntrinsicWidth给定宽度maxIntrinsicHeight

如你所见,我们总是需要提供相反的尺寸才能计算我们需要的尺寸。这是因为我们没有任何约束条件,所以我们可以向库提供的唯一线索是一个尺寸(这样它可以计算另一个尺寸)以正确地绘制布局内容的大小

通过阅读每个函数的官方文档,我们可以更清楚地看到这一点:

minIntrinsicWidth函数提供给定特定高度的情况下,布局可以占用的最小宽度,以使布局内容能够正确绘制。minIntrinsicHeight函数提供给定特定宽度的情况下,布局可以占用的最小高度,以使布局内容能够正确绘制。maxIntrinsicWidth函数提供最小宽度,以便将其进一步增加不会降低最小固有高度。maxIntrinsicHeight函数提供最小高度,使得进一步增加高度不会降低最小固有宽度。

我们可以使用Modifier.width(intrinsicSize: IntrinsicSize)(或其高度对应项)作为了解Intrinsics的示例。请注意,这是与通常的Modifier.width(width: Dp)不同的变体。后者用于声明节点的精确首选宽度,而前者用于声明节点的首选宽度,以匹配其自身的最小或最大Intrinsic宽度。下面是它的实现方式。

我们可以通过Modifier.width(IntrinsicSize.Max)这样的方式来使用。在这种情况下,它会选择MaxIntrinsicWidthModifier,它会覆写最小固有宽度以匹配最大固有宽度,并且还会固定内容约束以匹配传入maxHeight约束的最大固有宽度。内容约束在使用 intrinsics modifier 进行测量时被使用。

这很不错,但我们可能需要从最终用户的角度理解它的最终效果,以便更好地理解。当我们使用这个修饰符时,UI看起来是什么样子的,或者它的行为是什么样的?我们可以查看DropdownMenuComposable 的实现方式,它依赖于DropdownMenuContent来在Column中显示菜单项。

这将设置Column的首选宽度以匹配所有子元素(菜单项)的最大固有宽度。这样做实际上强制下拉菜单与其最宽的子元素的宽度匹配。

你可以阅读官方的 intrinsics measurement 文档了解更多实际用例的例子。

布局约束

布局约束可以来自父LayoutNode节点或者一个修饰符。Layoutlayout修饰符使用约束来测量其子布局。为了选择约束,约束提供了一个宽度和高度的最小和最大值的范围(px单位)。测量的布局(子项)必须适配这些约束。

minWidth <= chosenWidth <= maxWidthminHeight <= chosenHeight <= maxHeight

大多数现有布局要么将未修改的约束条件向下传递给其子项,要么将min约束条件设置为0。前面提到的Box就是后一种情况的例子。我们可以通过另一个场景展示这一点:

有时,父节点或修饰符希望要求其子项提供其首选大小。在这种情况下,它可以传递无限大的maxWidthmaxHeight约束条件(即Constraints.Infinity)。当一个子节点被定义为填充所有可用空间时,为该维度传递一个无限约束就像是向子节点发出了一个信号,让它自己决定要占用多大的空间。假设我们有以下的Box

默认情况下,这个Box会填满所有可用的高度。但如果我们把它放在一个LazyColumn中(由于它是可滚动的,因此会使用无限高度约束来测量子节点),Box将会根据Text的高度来包裹其内容。这是因为填充无限高度没有意义。在核心布局组件中,对于子节点在这些情况下自适应其内容大小非常普遍。然而,这最终取决于布局是如何定义的。

LazyColumn也可以作为学习如何使用不受限制的Constraints的一个好案例。它依赖于一个更通用的布局称为LazyList,后者使用子组合(SubcomposeLayout)来测量子项。当我们需要基于可用大小延迟组合 items 时,子组合非常有用。在这种情况下,是屏幕大小,因为LazyList只组合屏幕上可见的项。以下是测量其大小时如何创建约束条件的:

每次需要测量子项时,都会使用这些约束。首先,它将对项目的内容进行子组合。这是在LazyList测量传递期间完成的,这要归功于SubcomposeLayout。子组合内容会生成一个可见子项measurables列表,然后使用创建的childConstraints测量这些子项。此时,子项可以基本上自行选择其高度,因为高度约束是无限的。

另一方面,有时我们想要为所有子项设置确切的大小。当父元素或修饰符想要强制施加确切的大小时,它会强制执行minWidth == maxWidthminHeight == maxHeight。这基本上强制子元素适配该确切空间。

LazyVerticalGrid是一个例子,它可以高效地显示具有动态项目数的垂直网格。这个 Composable 非常类似于LazyColumnLazyRow,因为它也是延迟组合可见的子项。当网格中单元格的数量固定时,它实际上使用了LazyColumn,对于每个列,它呈现一个ItemRow,其中包含多个items(与span数量相同)。ItemRow布局使用固定宽度来测量其每个子项(列),该宽度由span count(列数)、列大小和items之间的间距确定。

这会创建如下的约束,其中宽度是固定的,而高度范围在0到无限大之间(即没有上限)

如果你想探索更多关于Constraints的用法例子,我强烈建议你查看常见的用于控制大小的layout modifiers的内部实现,或者你熟悉的任何来自 Compose UI 源代码中的Layout组件。(我保证这是一个非常有意义的练习,可以帮助你理解测量的过程。)

Constraints被建模为内联类,用一个Long长整型数值以及几个位掩码来表示四个可用的约束(即minWidthminHeightmaxWidthmaxHeight)。通过从该值中读取不同的位掩码来解析不同的约束。

LookaheadLayout

关于LookaheadLayout我想先分享一个来自 Google Jetpack Compose 团队的 Doris Liu 在她的 Twitter 上分享的 一个使用 LookaheadLayout 实现的非常 Cool 的动画示例 :

你可以在 上面找到关于这个例子的全部源代码:SceneHostExperiment.kt 和 LookaheadWithMovableContentDemo.kt

这个例子的效果虽然很酷,但是可能我们还不能够确切的知道它到底能用来干嘛,如果要考虑实际一点的场景的话,假设有如下代码,它在应用中的两个可组合屏幕之间导航。

这两个屏幕当中可能包含一些共享元素,这也许是一个图片,一个文本或整个行。因此当我们在CharacterList中点击某个角色时,它们会发生动画。在这种情况下,如果我们想要动画过渡,我们可以选择设置一些魔法数字作为动画目标,因为我们恰好能够知道目标屏幕中所有共享元素的最终位置和大小。但这样做并不好。理想情况下,Compose UI 应该能够预先计算并提供这些信息,以便我们可以将其用于设置我们的动画目标。这就是LookaheadLayout发挥作用的地方。

LookaheadLayout可以在其直接或间接子项更改时预先计算它们的测量和摆放。这使每个子项能够观察其测量/布局传递中预计算的值,并使用这些值随着时间逐渐改变其大小/位置(从而创建动画效果)。在上面的共享元素转换示例中,每个共享元素将观察其在转换到的屏幕中的最终大小和位置,并使用这些值来将自己动画化。

LookaheadLayout 是如何工作的

实际上,LookaheadLayout在 “正常” 的测量/布局之前执行测量和布局的预查看操作(lookahead),以便后者可以利用前者预先计算的值来在每一帧上更新节点。这个预查看操作仅在树结构改变或者由于状态改变导致布局改变时发生。

当进行lookahead的过程时,布局动画将被跳过,因此测量和布局的过程将被执行,就好像动画已经完成一样。未来,所有的布局动画 API 都将被设计为在lookahead过程中自动跳过。这将适用于LookaheadLayout的任何直接或间接子项

为了暴露预计算的数据,LookaheadLayoutLookaheadLayoutScope中执行其contentlambda,这使其子元素可以访问一些修饰符:

Modifier.intermediateLayout:每次测量带有该修饰符的布局时都会调用。允许访问布局lookahead(预计算)的尺寸大小,并将该尺寸作为目标尺寸,以产生中间布局(过渡动画)。

Modifier.onPlaced: 当带有此修饰符的布局重新布局时调用。允许子元素根据其父元素调整自己的位置。它提供了对LookaheadLayout和修饰符本身(LookaheadLayout 的子元素)的lookahead坐标的访问,这允许计算相对于父元素的lookahead位置和当前位置。在生成中间布局时,这些坐标可以保存并用于布局位置的动画。

这两个修饰符在正常的测量/布局过程中运行,因此它们可以使用在它们的lambda函数中提供的预先计算的信息来调整布局大小/位置以达到目标值。期望用户在这两个修饰符的基础上构建自定义修饰符,以创建自定义动画。下面是官方文档的一个示例,用于根据预计算大小动画化用于测量其子元素的约束。

创建了一个尺寸动画来动画化尺寸值(宽度/高度)。每次布局更改时都会重新启动动画(借助snapshotFlow)。

这个动画是在LaunchedEffect中运行的,以避免在测量/布局阶段(此修饰符运行时)运行不受控制的副作用。

intermediateLayoutlambda 中,可以使用预计算的lookaheadSize,并被设置为targetSize,以在其变化时触发动画。

lookaheadSize被用于测量子节点的大小,从而使它们逐渐改变大小。这是通过在每一帧创建跟随size动画值的新的固定约束来实现的,从而在时间上产生了最终的动画效果。

在预计算阶段,中间布局(intermediateLayout)的lambda被跳过,因为这个修饰符是用于生成以lookahead 状态为目标状态的中间状态。

我们有了用于动画约束的自定义修饰符之后,就可以在任何LookaheadLayout中使用它。以下是一个例子,也是从官方源代码中提取的:

这里展示了如何使用之前创建的自定义modifier来实现一个可动画的Row布局。

可变状态用于在“full width”和“short width”之间切换Row。使用animateConstraints自定义modifier动画化Row布局更改。每次切换可变状态,Row宽度都会更改,这将触发新的 lookahead预计算(因此也会触发动画)。所有子项中的最大宽度和最大高度用于测量 LookaheadLayout,以便所有子项都可以适合其中。所有子项放置在 (0,0) 处。

这样就可以在每次布局发生变化时产生一个不错的自动动画。这是一个基于预先计算的前瞻大小来调整目标大小动画示例。那么,如何实现根据预先计算的前瞻位置调整目标位置的动画呢?

前面提到了Modifier.onPlaced可以在LookaheadLayoutScope中使用,用于根据其父级调整子级的位置。它提供了足够的数据来计算布局相对于父级的当前位置和前瞻位置。我们可以将这些位置保存在变量中,并在后续对intermediateLayout的调用中使用它们,以便根据前瞻值相应地重新定位布局。

关于Modifier.onPlaced的使用示例,这里不再展开,其使用方式跟上面的例子实际上非常相似,如果你感兴趣的话可以在上面找到完整的示例代码:LookaheadLayoutSample.kt

LookaheadLayout 的内部机制

下图展示了 LookaheadLayout 的测量过程:

(上图中用橙色部分表示预先测量过程)

当一个LayoutNode需要第一次测量尺寸(例如:它刚刚被attach到父视图)时,它会检查它是否位于LookaheadLayout的根节点处,以便首先开始预测量过程(预测量过程仅运行于LookaheadLayout子树中)。

LayoutNode调用LookaheadPassDelegate#measure()以开始预测量阶段。该代理负责节点的所有预测量/布局请求。此调用使用外部LayoutNodeWrapper(通常用于测量节点)通过其LookaheadDelegate运行其lookahead测量。

预测量过程基本上遵循我们前文介绍过的测量步骤,唯一的区别是,使用的是LookaheadDelegate在所有步骤中执行。

一旦根节点及其所有直接或间接子节点的预先测量都完成,或者该节点不是 LookaheadLayout 的根节点,则“正常”的实际测量过程开始运行。

现在我们来看一下布局过程:

布局流程和测量流程没有什么区别。它们使用相同的委托。唯一的区别是,这里调用的是placeAt(...)函数,以便摆放节点及其子节点(用于普通布局流程),或者计算其预先位置(用于预先布局流程,用橙色表示)。

迄今为止,我们专注于第一次测量/布局的节点。另一方面,当LayoutNode需要重新测量/重新布局(例如其内容已更改)时,时间略有不同。为了尽可能减少无效范围,测量/布局和前瞻性的无效化进行了优化。这样,不受树变化影响的LayoutNodes将不会无效化。这使得只有LookaheadLayout子树的一小部分因为树的更改而无效化成为完全可能。

当一个新的LayoutNode被附加时,它会从树中继承最外层现有的LookaheadScope,因此所有LookaheadLayout的直接或间接子项可以共享同一个作用域。这是因为支持嵌套的LookaheadLayout。Compose UI 也确保为它们执行单个的 lookahead 阶段。

LookaheadLayout可以与movableContentOfmovableContentWithReceiverOf结合使用, 以使 Composables 可以在动画期间/之后被重用并保持它们的状态。

预先计算布局的几种方式

我们可以将LookaheadLayoutSubcomposeLayout(子组合)和intrinsics一起视为 Jetpack Compose 中预先计算布局的几种方式。尽管如此,三者之间存在重要的区别。

SubcomposeLayout: 将组合(composition)延迟到测量(measure)阶段执行,以便我们有足够的时间来确定要构建哪些节点/子树。但它更多的是关注条件组合而不是预布局。它的成本也非常高,因此我不建议在任何其他情况下使用它进行布局预计算,因为它不仅仅是测量/布局传递。

Intrinsics:它是比子组合更有效率的方法,内部的工作方式与LookaheadLayout非常相似。这两种都是在同一帧中以不同的约束条件调用用户提供的LayoutModifiersMeasurePolicys中的measure函数。 但是在Intrinsics的情况下,它们更像是试算,以便使用得到的值进行实际测量。想象一下一个具有3个子元素的行,为了使其高度与最高子元素的高度相匹配,它需要获取所有子元素的固有尺寸,最终使用其中最大的一个来进行自身的测量。

LookaheadLayout:它用于精确预计算任何(直接或间接)子项的大小和位置,以便实现自动动画。除了测量之外,LookaheadLayout 还基于提前计算的尺寸大小进行摆放位置的预计算。 LookaheadDelegate 会将提前测量和摆放位置的结果缓存起来,以避免不必要的重复计算。除非树已更改(例如新的节点、修饰符更改等),否则不会提前计算。与Intrinsics的另一个区别是,LookaheadLayout 中有一个隐含的保证,即布局最终会到达提前计算的状态,因此它不允许用户操纵提前约束。

Modifier 链的建模

Modifier 接口模型化了一组不可变元素,用于装饰或为UI Composables添加行为。Modifier是一个抽象层,提供了以连接任何类型的修饰符(then)的组合能力、在遍历修饰符链时的累加值的折叠能力(foldInfoldOut),以及一些操作来检查链中的任何或所有修饰符是否符合给定的断言。

每当我们在代码中找到如下所示的一串修饰符时,我们得到的是一个“链表”,由其头部引用:Modifier类型本身。

请注意,链接修饰符可以是显式的,也可以是隐式的,就像上面的代码片段中一样,我们有两者的组合。当没有指定then时,这是因为我们正在通过扩展函数链接那些修饰符,这些扩展函数实际上在内部为我们调用then。这两种方法是等价的。在实际项目中,扩展函数的使用频率要高得多。下面是一个通过扩展函数声明修饰符的例子:

当调用then来链接两个修饰符时,它会生成一个CombinedModifier,这就是链接建模的方式。CombinedModifier有一个对当前修饰符的引用(outer),还有一个指向链中下一个修饰符的指针(inner),它也可以是一个CombinedModifier

节点被称为outer外部节点和inner内部节点,是因为当前节点会将下一个节点包裹在一个CombinedModifiers链当中:

这就是修饰符链的建模方法。

将修饰符设置到 LayoutNode

任何LayoutNode都分配了一个Modifier(或它们的一个链)。当我们声明一个Layout时,Compose UI 传递给它的一个参数是updatelambda:

updatelambda 在通过工厂创建节点时立即被调用,它初始化或更新LayoutNode的状态。这里设置了测量策略measurePolicydensity、布局方向或视图配置等内容。当然还有修饰符。在最后一行,我们看到一个 “materialized” 修饰符链被设置给LayoutNode:

那么现在的问题是:什么是“materialized”修饰符?在上面的几行中,我们可以找到修饰符 “materialized” 的地方(就在发出节点之前):

这里的modifier参数可以是一个修饰符,也可以是一个修饰符链。如果传递给Layout的所有修饰符都是“标准的”,这个函数只返回未修改的修饰符,因此可以将它们设置给LayoutNode,而不需要进行任何额外的处理。以上就是“标准”或“正常”修饰符的设置方法。但还存在另一种修饰符叫做ComposedModifier 组合修饰符。

组合修饰符实际上是一种特殊类型的有状态修饰符。当需要一个Composition来实现它们时,这是有意义的。这方面的一个例子是,当我们需要从修饰语逻辑中记住一些东西时。或者当修饰符需要从CompositionLocal中读取时。如果不在Composition的上下文中运行修饰符lambda,这些事情是永远不可能实现的。

组合修饰符是针对它们所修改的每个元素组合的。如果一个布局获得一个或多个组合修饰符作为修饰符链的一部分传递,它们将需要首先通过它们的可组合工厂函数进行组合,然后再分配给LayoutNode:

首先需要运行factoryComposable lambda,因为LayoutNode不知道如何使用组合修饰符,因此需要在分配它们之前将它们转换为常规修饰符。factorylambda 将在每次使用节点时在 Composition 的上下文中执行,这将解锁对块内任何 Composable 函数的访问。factorylambda 就是我们用来编写composed修饰符的.

让我们用一个例子来学习:clickable修饰符。请注意如何使用posed()扩展函数来创建有状态(composed)修饰符:

由于Composition被要求能够remember块内的状态,所以使用了一个composed修饰符。

除了clickable的修饰符,还有许多其他组合修饰符的例子。如focusable, scroll, swipeable, border, selectable, pointerInput, draggable, toggleable以及更多。关于这方面的更多例子,我建议检查源代码并搜索posed()扩展函数的用法。

有时候,Compose UI 需要为某些目的创建一个特别的LayoutNode,它也会利用机会为其设置修饰符。这方面的一个例子是一个给定Owner根 LayoutNode,例如AndroidComposeView:

AndroidComposeViewhook 它的root LayoutNode时,它利用这个机会为它设置测量策略measurePolicydensity,以及通过View系统 hook accessibility 的一些修饰符。这些设置了语义树的默认配置,一个用于处理 accessibility 的焦点事件的管理器。

LayoutNode 如何接受新的修饰符

每当将修饰符(或修饰符链)设置给LayoutNode时,已为当前节点设置的所有修饰符都会保存到缓存中。此缓存用于查找可复用的修饰符,用于正在设置的新修饰符链。

缓存中的所有修饰符都初始化为不可重用。然后,节点从其头部开始折叠到新的修饰符链上。这是通过Modifier#foldIn函数完成的,这是Modifier折叠功能之一。对于每个修饰符,它将检查缓存中是否包含等效的修饰符,如果是,则将其标记为可重用的。然后,如果缓存的修饰符有一个父修饰符,它将向上层访问,标记它和它的所有祖先也是可重用的。这将完成缓存修饰符的初始预处理,以便为下一步做好准备。

在此之后,LayoutNode继续重建其外层LayoutNodeWrapper。外部包装器包装链中的第一个修饰符,第一个修饰符包装第二个,依此类推。由于我们正在设置一个新的修饰符链,因此需要重新构建外部包装器。

为了重建外部包装,它再次折叠新的修饰符链,但方向相反:从尾部到头部。这是通过Modifier#foldOut函数完成的。现在的目标是为新的修饰符构建新的LayoutNodeWrappers链。

由于我们想从尾部开始,折叠的初始值将是innerLayoutNodeWrapper(图表底部的大部分)。然后我们可以爬上去遍历当前LayoutNode上设置的所有修饰符。

对于每个修饰符,它将首先通过检查缓存来检查是否可以重用它。如果能重用,则将其链接并从缓存中删除。如果不能重用,它将使用特定修饰符类型的适当LayoutNodeWrapper类型来包装修饰符,然后再将其链接。有多种包装器类型,每一种都有自己的功能,这取决于修饰符的性质。

当折叠到达头部(即outerLayoutNodeWrapper)时,外部包装器被分配给父节点的内部包装器,正如我们在上图的最顶部看到的那样。这就完成了构建新包装器链的过程。

下一步是从缓存中分离所有剩余的修饰符,因为它们不会被重用,然后在所有新的包装器上调用attach()。最后,可以清除缓存,并且可以重绘所有修饰符。为了这样做,他们会被设置失效。

最后一步是请求重新测量父节点(如果需要的话)。

绘制节点树

对于绘制,也会遵循LayoutNodeWrapper链,因此当前节点被首先绘制,然后是其修饰符(按顺序),最后是其子节点。这个过程对每个子节点重复,直到绘制出完整的节点层次结构。

Android系统中,绘制过程发生在测量和布局之后。在每一个绘制周期中,系统调用我们的ViewViewGroup上的draw()方法,并执行绘图。在Compose中也是如此。当一个LayoutNode请求重新测量时,它会被标记为dirty(脏),在下一个绘制周期中,Owner(类似于AndroidComposeViewViewGroup)将重新测量并重新布局所有脏节点,然后开始绘制。绘制发生在dispatchDraw()函数中,这是Android系统中ViewGroup绘制其子元素的地方。

让我们继续以AndroidComposeView作为Owner的例子。在绘制之前,AndroidComposeView会使层级中所有LayoutNode的绘制层失效,这意味着它们被标记为需要重新绘制。这也包括所有可用于绘制的修饰符包装器。每当需要使一个节点的绘制层失效时,它会首先检查它自己是否已经在绘制了。

绘制过程是从根 LayoutNode开始启动的。这是通过root.draw(canvas)实现的,它将被委托给节点的outerLayoutNodeWrapper

Compose UI中使用的Canvas是一个抽象,因为Compose UI是一个跨平台库。对于Android,它将所有的绘制都委托给原生Canvas。除此之外,Compose Canvas提供了比原生更人性化的API 接口。两者之间的一个关键区别是,Compose Canvas函数不再接受Paint对象作为参数,因为在Android中分配Paint实例是非常昂贵的,特别是不建议在绘制调用期间这样做。取而代之的是,团队决定重新设计 API 接口,使函数隐式地创建和重用相同的Paint对象。

每个LayoutNodeWrapper都有自己的绘制层,外层包装器也不例外。当通过root.draw(canvas)将绘制分派给根节点时,外层包装器的绘制层会完成工作。这里有不同的情况需要考虑:

如果存在与包装器相关联的绘制层,则会在其上调度绘制操作。这将有效地绘制节点,它可以是一个LayoutNode或一个修饰符。

如果该包装器没有与之关联的绘制层,则会检查是否有任何绘制修饰符与之相关联。如果有,则会绘制所有这些修饰符。(绘制修饰符作为链接列表附加到包装器上。它们不像其他修饰符那样被其自己的单独包装器包装)。

如果该包装器既没有与之关联的绘制层也没有任何绘制修饰符,则会继续调用链中的下一个包装器的draw函数,以继续绘制完整的LayoutNode层次结构。

步骤1是绘制节点的地方。为此,Compose UI 为LayoutNode提供了两种类型的绘制层:

RenderNodeLayer:用于绘制RenderNodes。这是呈现 Jetpack Compose UI 节点的默认方式。RenderNodes是一种更高效的硬件驱动绘制工具。它们允许仅绘制一次,然后多次重新绘制非常高效。ViewLayer:这个是基于View的实现。只有在无法直接使用RenderNodes时才会使用。这种实现比RenderNodeLayer更 “笨拙”,因为它将Views视为RenderNodes,并且为此需要一些管道。但是Views实际要比RenderNodes复杂的更多。

两种实现都是硬件加速的,因为两种实现最终都直接或间接地依赖于RenderNodes。例如,两种实现都可以作为Modifier.graphicsLayer的有效实现。

当一个新的节点被附加时,会创建绘图层。绘图层类型的决定取决于Owner。在AndroidComposeView的情况下,如果平台版本支持,它将始终优先使用RenderNodeLayer,否则将回退到ViewLayer

不同的 Android 版本为RenderNodes提供不同的 API,因此RenderNodeLayer依赖于Compose UI提供的一个抽象层,在执行时将绘图委托给相应的RenderNode系统实现。

另一方面,ViewLayer依赖于Views来进行绘制。任何一个ViewLayer都有与之关联的容器View,用于触发绘制。这个容器通常是Owner,也就是一个ViewGroup。当ViewLayer被创建时,它会将自己作为其容器的child。这是因为ViewLayer实际上是作为一个View实现的,这是其中的关键技巧。当layer需要进行绘制时,它会调用其所属容器的ViewGroup#drawChild函数,从而重用View的绘制机制。正如我们之前所述,这比直接使用RenderNodes更加笨拙,因为它涉及到使用Views来使其工作。

在绘制之前,如果一个ViewLayer有一些高度(elevation)的属性,那么它就有机会启用 “高度模式”。 “高度模式” 由 AndroidCanvas支持,它允许渲染阴影,并根据不同高度重新排列层级。一旦“高度模式”被启用,该层就可以继续绘制,完成后它将被禁用。

一旦链中的所有包装器都被绘制完成,AndroidComposeView#dispatchDraw会通知所有标记为dirty的层更新其显示列表,从而更新当前画布上的绘制。

当图层被无效化时,它们会被标记为dirty。由于我们在dispatchDraw函数的开始时将所有图层都无效化了,这将强制让层级中所有节点的所有图层都更新。

在更新显示列表的前面几个步骤中可能会导致相同的图层失效。在这种情况下,这些图层将被标记为脏,以便在下一轮中更新它们。

有时候,我们在将LayoutNode绘制到图层中时会读取快照状态。每当此状态更改时,我们可能需要重新绘制节点以保持一致性。根节点有一个图层修饰符,用于观察所有子节点中的状态更改,以相应地使其绘制层无效。这是为根节点设置的测量策略的一部分:

这个RootMeasurePolicy测量和摆放附加到根节点的子节点。为此,它调用placeable.placeRelativeWithLayer(0, 0),将测量的子节点放置在坐标(0,0)处,并引入一个图形层graphic layer)。该图形层被配置为自动观察和响应任何快照状态的更改。

Jetpack Compose 中的语义

在 Jetpack Compose 中,Composition是一棵描述 UI 的树。同时,还有另一棵树,它以一种accessibility服务和测试框架能够理解的方式进行描述UI。该树上的节点提供有关其语义含义的相关元数据。

在前面,我们了解到LayoutNode层次结构的Owner有一个accessibility委托,可以通过 Android SDK 来传输语义。然后,每当节点被附加、分离或其语义更新时,语义树都会通过Owner得到通知。

Owner被创建时(对于AndroidComposeView来说,就是当setContent被调用时),它会创建一个带有一些默认修饰符的临时root LayoutNode

这些修饰符与accessibility和语义相关。语义修饰符semanticsModifier将具有默认配置的核心语义添加到根节点,以便开始构建语义树。在它之上,焦点管理器_focusManager修饰符设置根修饰符,用于跨 Composable 层次结构处理焦点。这个管理器负责根据需要在我们的组合中设置和移动焦点。最后,键输入修饰符keyInputModifier处理键盘输入,并将任何KeyDown事件传递给焦点管理器_focusManager,因此焦点也可以使用键盘处理。后两个修饰符对于accessibility也非常重要。

为了向不同于根节点的其他节点添加语义,我们通过Modifier.semantics来实现。 我们设置给Layout的每个语义修饰符都包含一个id和一些语义配置:

在这里,我们可以看到如何隐式地创建一些调试检查器信息,以便向 Compose tools 提供关于该节点的一些详细信息,以便它们在检查 Composable 树时显示它。

一个id是自动生成并被remembered的。这些idsLayoutNode层次结构中是唯一的(静态共享),并按顺序生成。每个新生成的id都比前一个id大。这意味着一个新的Composition可能从最开始就开始生成id,或者在之前创建的任何其他Composition生成的最新id之后依次生成id

最后,使用这些元素和提供的配置参数创建修饰符。

现在,semantics修饰符是作为composed修饰符实现的,因为它需要访问Composition上下文,以便在块中使用生成的id。也就是说,这个实现很快就会改变,因为有一个正在进行的重构,它将在创建相应的LayoutNode时移动生成的id,因此将不再需要remember,因此对Composition上下文的需求将被移除。

这个语义修饰符的实现目前正在进行重构(也就是说,你看到的最新版本源码和本文描述的可能略有不同)。

AndroidComposeView将默认的语义修饰符分配给根节点时,它以同样的方式创建它:

当我们使用materialfoundation库提供的 Composables 时,它们很可能已经隐式地 hook 了它们的语义。这很好,但当我们在自己的自定义布局中工作时,它不会起作用。因此,每当我们在 Jetpack Compose 中编写一个新的Layout时,我们需要自己提供它的语义。Accessibility和测试必须优先考虑。

通知语义更改

AndroidX核心库添加了一个AccessibilityDelegateCompat,用于规范跨系统版本的accessibility服务。LayoutNode层次结构的Owner使用此委托的实现来处理accessibility更改。这个实现利用了系统accessibility服务,因此它可以通过从Owner获得的 AndroidContext来查询这些服务。

当通过Owner通知语义变化时,检查语义树上的语义变化的操作将通过本地Handler发送到Main Looper。这个操作按顺序做以下事情:

比较新旧语义树寻找结构变化。即:添加或删除子元素。当检测到结构更改时,委托使用合并通道通知这些更改。这是因为通知系统 accessibility 服务的代码是在协程中运行的挂起函数。对象的整个生命周期内循环此任务合成实例,使用最近的布局更改并分批(每100ms)向accessibility框架发送事件。Channel是为这个任务生成要事件的一个合适的选择。比较新旧语义树寻找语义属性的变化。当语义属性发生变化时,它将使用本地机制通知accessibility 服务 (ViewParent#requestSendAccessibilityEvent)。用所有当前语义节点更新以前的语义节点列表。

合并和未合并的语义树

Jetpack Compose 提供了两个语义树:MergedunMerged.。有时我们需要从语义上合并一组 Composables,因为对于最终用户来说,将它们作为一个组来体验比单独体验具有更好的语义意义。想象一下,像TalkBack这样的辅助工具在一个非常大的项目列表的每一行中读取每个次要的Composable。它将产生一种非常让人疲惫且无用的用户体验。我们可能更喜欢把每一行都读一遍。

合并是通过mergeDescendants属性完成的,我们可以从上面的代码片段中找到。这个属性允许我们决定一个语义节点(例如一个Composable或一个modifier)什么时候需要将它的后代合并到它。这实际上在foundationmaterial库提供的许多 Composable 组件和 Modifier 中都是开箱即用的。合并的树将基于我们可以设置节点的mergeDescendants执行合并。未合并的,不会这样做。它将使这些节点保持分离。工具决定它们想要使用什么树。

以下是内部合并的过程:

语义属性分配了一个合并策略。让我们以contentDescription为例:

语义属性是通过SemanticsPropertyReceiver上的扩展属性定义的,它是用于语义块的作用域,允许我们在块内设置任何属性。

要设置语义属性,需要两个东西:一个语义key和一个vallue值。语义key是以类型安全的方式定义的,并且需要一个属性名和一个合并策略来创建:

在合并策略 lambda 中,属性决定如何合并其后代。在这种情况下,ContentDescription将所有后代ContentDescription值添加到列表中,但可以做任何它想做的事情。Compose 中语义属性的默认合并策略实际上就是根本不合并。如果节点可用,则保留父值,并丢弃子值。

注意,这里我们只是讨论一些语义内部细节,而不是为了罗列所有的语义API或配置。所以如果你想了解关于Jetpack Compose语义的API 接口使用和最终用户角度的更多细节,建议阅读官方文档。

总结

从 Compose UI 的角度来看组合:

Android中,Compose runtime 集成 UI 的入口点是在我们调用Activity/FragmentsetContent方法时。还可以通过单独调用ComposeViewsetContent方法来设置。

setContent函数创建了一个新的root Composition,每个组合都有一个独立的 Composable 树。这些组合之间相互独立。

相应setContent调用的所有 Composable 函数都将执行并发出它们的更改。对于 Compose UI ,这些更改就是插入、移动或替换 UI 节点,而这些操作通常是由构建 UI 的block代码块发出的,即:Box,Column,LazyColumn 等。这些组件最终都被定义为Layout。并发出相同的节点类型:LayoutNode

任何LayoutComposable 函数都会将LayoutNode节点发射到Composition中,这是通过ReusableComposeNode发射的。

ReusableComposeNode通过factory工厂函数创建节点,通过updatelambda 初始化它(节点第一次被创建时或节点值更改时才会执行这些 lambda)。

LayoutNode并不是 Compose UI 中唯一的节点类型,还有其他的节点类型,比如VNodeModifierNode等等。它们都是由 Composable 函数发出的。

从 Compose UI 的角度来看子组合(Subcomposition):

Composition不仅仅存在于root级别,还可以存在树的更深层次,并连接到父组合,即Subcomposition每个Composition都有一个指向其父CompositionContext的引用,该引用代表其父Composition

在 Compose UI 中,创建 Subcomposition 主要有两个原因:

推迟初始组合过程,直到某些信息已知。如SubcomposeLayoutBoxWithConstraints组件就是如此。修改子树生成的节点类型。例如矢量图形,即rememberVectorPainter函数。它创建了自己的Subcomposition来将矢量图形建模为一棵树。Vector Composable 会发出不同于LayoutNode的VNode节点类型。Vector 的 Subcomposition 会连接到包含它的LayoutComposable 对应的 Composition 中。

不同类型的Applier

Applier是一个抽象层,runtime 依赖于它而不是具体使用库的平台,最终实现树中的任何更改。即反转依赖。平台客户端库可以有自己不同的Applier实现。AbstractApplier:Compose runtime提供的共享逻辑。它将已访问的节点存储在一个栈中,每当在节点树上访问节点上下游走时,都通过applier#down()applier#up()方法来执行入栈出栈操作。UiApplier:用于呈现大多数 Android UI 。它将节点类型固定为LayoutNode,因此它将实现我们树中的所有Layouts布局。UiApplier采用自下而上的方式插入节点。这是为了避免新节点进入树时出现重复通知。VectorApplier:用于呈现矢量图形。它将节点类型固定为VNode,以表示和实现矢量图形。VectorApplier采用自上而下的方式构建树。UiApplierVectorApplier是到目前为止 Android 提供的Applier唯一的两个实现类,但不排除未来会添加更多实现类。

实体化/插入一个新的LayoutNode

UiApplier将节点类型固定为LayoutNode,所有插入、删除或移动节点的操作都委托给当前访问的节点。LayoutNode知道如何实现自身,runtime 可以对此保持无感知。每个LayoutNode都有一个指向其父节点的引用,并且它们全部连接到同一个Owner。每个新加入的节点也是如此。这里的Owner是一个抽象层,每个平台可以有不同的实现,在Android中,它是AndroidComposeView(一个 View),因此Owner是我们的LayoutNodes树 和Android View系统之间的底层连接。无论任何节点操作,都可以通过 AndroidViewAPI 的Owner触发失效,进而在下一个绘制阶段显示在屏幕上。

LayoutNode#insertAt

首先将当前节点设置为插入的新节点的父节点,然后,新节点被添加到它的新父节点维护的子节点列表中。在此之上,按照Z轴索引中排序的子元素列表将会被失效。经过某种 “outer” 和 “inner” 的 LayoutNodeWrappers 的赋值后,最后通过instance.attach(owner)将节点附加到与其新的父节点相同的Owner上。attach函数会在子节点上递归调用,因此挂在该节点上的完整子树最终将附加到同一个Owner。也就是说,来自同一个 Composable 树的任何节点所需的所有失效强制通过同一个View进行传递。owner对象被保存到了当前节点中。

通过Owner请求测量和绘制:

当在Activity、FragmentComposeView中调用setContent函数时,Owner将被附加到视图层次结构中。会创建一个AndroidComposeView(Owner)并将其附加到视图层次结构中。此时Applier调用current.insertAt(index, instance)来插入一个新节点LayoutNode时,将连接新节点到父节点,并通过Owner请求自己和新父节点的重新测量。会调用AndroidComposeView#invalidateView将在下一个绘制阶段调用AndroidComposeView#dispatchDraw触发 Compose UI 执行所有请求节点的实际重新测量和布局。如果在这个重新测量过程中,根节点的大小发生了变化,则会调用AndroidComposeView#requestLayout(),以便重新触发onMeasure并能够影响所有View兄弟节点的大小。测量和布局完成后,dispatchDraw调用将最终调用根LayoutNodedraw函数,它知道如何将自己绘制到Canvas画布上并触发其所有子节点的绘制。节点通常是先进行测量,然后是布局,最后是绘制。

删除节点:

UiApplier调用current.removeAt(index, count)方法,对于每个子项,它从当前节点的子项列表中删除该子项,触发Z轴索引子项列表的重新排序,将它们的owner引用重置为null,以从树中分离,并请求对父项的重新测量,因为它将受到删除的影响。移动节点和清除所有节点都是利用了removeAt方法。

LayoutNode的测量:

任何LayoutNode都可以通过Owner请求重新测量,例如当一个子节点被添加、删除或移动时。此时,视图(Owner)被标记为 “dirty”(无效),节点会被添加到一个需要重新测量和重新布局的节点列表中。在下一次绘制过程中,AndroidComposeView#dispatchDraw将被调用,AndroidComposeView将遍历列表并使用代理执行这些操作。每个LayoutNode都有一个外部的和一个内部的LayoutNodeWrapper。外部的负责测量和绘制当前节点,内部的负责对其子节点执行相同的操作。在测量时需要考虑Modifier对测量的影响,但是Modifier是一个无状态的东西,因此需要一个包装器来保存其状态。所有包装器(外部的、修饰符的和内部的)都被链接在一起,以便按顺序解决和应用。每当需要重新测量一个节点时,此操作被委托给该节点的外部LayoutNodeWrapper,它使用父节点的测量策略进行测量。然后,它按照链条依次重新测量其每个修饰符,最后再使用当前节点的测量策略进入内部LayoutNodeWrapper以重新测量其子节点。

测量策略:

对于一个节点来说,它的measurePolicy实际上是如何从外部传递的。LayoutNode始终不知道用于测量自身及其子节点的测量策略。Compose UI 中的Layout的任何实现都提供它们自己的测量策略。例如Box根据对齐设置、子元素是一个还是多个、是否匹配父元素大小等,提供不同的测量策略。

Compose 中除了 UI 树,还有一棵专门为Accessibility服务和测试框架 提供的语义树。

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。