Jetpack Compose 深入探索系列四: Compose UI
Posted 川峰
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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 应用,其中Fragment1
和Fragment3
调用setContent
来 hook
它们的 Composable 树,而Fragment2
在它的布局上声明了多个ComposeView
(并调用setContent
)。在这种情况下,我们的应用程序将有 5 个根 Composition,它们都完全独立。
为了创建这些布局层次结构,相关的 Composer 将运行组合过程。相应 setContent
调用的所有 Composable 函数都将执行并发出它们的更改。对于 Compose UI ,这些更改就是插入、移动或替换 UI 节点,而这些操作通常是由构建 UI 的 block 代码块发出的。即:Box,Column,LazyColumn 等。即使这些 Composables 函数通常属于不同的库(foundation
、material
),它们最终都被定义为 Layout
(compose-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
在重组时被遍历。这种优化仅适用于那些在发射调用过程中,可以完全被set
和update
操作描述的节点,或者换句话说,不包含隐藏内部状态的节点。这对于 LayoutNode 是正确的,但对于 AndroidView 就不是这样了。因此,AndroidView 使用标准的 ComposeNode 而不是可重用节点。
在上面代码中,我们可以看到 ReusableComposeNode 会创建节点(通过factory
工厂函数),初始化它( update
lambda),并创建一个可替换的group
组来包装所有内容。该group
会被分配一个唯一的key
,以便稍后可以识别它。通过可替换的group
内的 content
lambda 的调用而发出的任何节点,实际上都将成为该节点的子节点。
在 update
block 块内的 set
方法调用会安排其后的 lambda 执行。这些 lambda 执行的时机是:节点第一次创建时,或对应属性的值自上次被 remembered
后已更改时。
这就是 LayoutNode 是如何被提供给我们应用中的多个 Compositions 的。这可能会让我们认为任何 Composition 都只包含 LayoutNode。但这是错误的!在 Compose UI 中,还有其他类型的 Compositions 和节点类型需要考虑。虽然 LayoutNodes 是最常用的节点类型,但还有其他类型的节点,比如 ModifierNode、AttachNodes 等等。它们都是由 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的节点类型来提供给其 Subcomposition:VNode 类型。这是一个递归类型,用于建模独立的 Paths
或 Paths
组。
这里有一件有趣的事情需要思考,通常我们使用 VectorPainter 在 Image、Icon 或其他类似的 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 Composition 和 Subcomposition。
反映 UI 中的变化
在前面,我们已经了解了 UI 节点是如何通过初始化合成和后续的重组合成被发射到 runtime 中的。此时,runtime 会接管这些节点,并执行其工作。但这只是一面,还需要存在某种集成来在实际的 UI 中反映所有这些发射的更改,以便用户可以体验它们。这个过程通常被称为节点树的 Materialization (中文翻译过来可以叫实体化、实例化、具体化),这也是客户端库(Compose UI)的职责之一。
不同类型的 Appliers
Applier 是一个抽象层,runtime 依赖于它最终实现树中的任何更改。这反转了依赖关系,使 runtime 完全不依赖于使用库的平台。这个抽象层允许客户端库(如Compose UI)hook 自己的 Applier 实现,并使用它们自己的节点类型来集成平台。下面是一个简单的图示:
(顶部的两个方框(Applier和AbstractApplier)是 Compose runtime 的一部分。底部列出了一些 Applier 实现,它们由 Compose UI 提供。)
AbstractApplier 是 Compose runtime 提供的基本实现,用于在不同 applier 之间共享逻辑。它将已访问的节点存储在一个栈中,并维护对当前访问节点的引用,以便知道应该在哪个节点上执行操作。每当沿着树访问到一个新节点时,Composer 通过调用 applier#down(node:N)
来通知 Applier。这会将节点压入栈中,Applier 可以对其运行任何所需的操作。
每当访问者需要返回到父节点时,Composer 就会调用 applier#up()
,这将从堆栈中弹出上次访问的节点。
让我们通过一个相当简单的例子来理解它。假设我们有以下需要 materialize 的 Composable 树:
当 condition
改变时,Applier 将会:
- 接收一个
down()
调用以访问Column
节点。 - 然后是另一个
down()
调用以进入Row
节点。 - 接着是删除(或插入,具体取决于
condition
)可选子Text
节点。 - 然后是一个
up()
调用以返回到父节点(Column
)。 - 最后,删除(或插入)第二个条件文本
Text
节点。
AbstractApplier 提供了堆栈和 down
和 up
操作,因此子 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 采用自下而上的方式插入节点。这是为了避免新节点进入树时出现重复通知。
这里再贴一遍前一篇中的自下而上插入策略图示以使其更清晰。
自下而上构建树的方式是先将 A 和 C 插入 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 系统之间的底层连接。
无论何时节点被attach
、detach
、reoder
、remeasure
或如何update
,都可以通过使用旧的 Android View API 的 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()
。这样就使得节点最终显示在屏幕上了。
完成闭环
为了完整闭环,当在 Activity
、Fragment
或 ComposeView
中调用 setContent
函数时,Owner 将被附加到视图层次结构中。这是目前唯一缺失的部分。
让我们通过一个简单的例子来回顾一下整个过程。假设我们有以下树形结构,其中调用了 Activity#setContent
,因此会创建一个 AndroidComposeView
并将其附加到视图层次结构中。同时我们已经有了一些 LayoutNodes:一个根节点((LayoutNode(A)
)和一个子节点(LayoutNode(B)
)。
现在,让我们假设 Applier 调用 current.insertAt(index, instance)
来插入(实例化)一个新节点LayoutNode(C)
。这将连接新节点,并通过 Owner 请求自己和新父节点的重新测量。
当这种情况发生时,大多数情况下会调用 AndroidComposeView#invalidate
。 就算如果有两个节点(当前节点和父节点)在同一时刻对相同的 View(Owner)发起失效操作,也没有关系,因为无效化就是将 View 标记为 dirty (脏的) 。在两个帧之间,你可以多次执行此操作,但在任何情况下,View 都将仅在下一个绘制阶段重绘一次。在那个时候,AndroidComposeView#dispatchDraw
将被调用,这是 Compose UI 执行所有请求节点的实际重新测量和布局的地方。如果在这个重新测量过程中,根节点的大小发生了变化,则会调用 AndroidComposeView#requestLayout()
,以便重新触发 onMeasure
并能够影响所有 View 兄弟节点的大小
测量和布局完成后,dispatchDraw
调用将最终调用根 LayoutNode 的 draw
函数,它知道如何将自己绘制到 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。
最简单的策略之一可能是由 Spacer Composable 设置的策略:
后面的 lambda 定义了策略。它实际是 MeasurePolicy#measure
函数的实现,该函数使用布局子元素的 measurables
列表(在本例中该参数被忽略了),以及每个子元素应该遵守的约束 constraints
。这些约束用于确定布局的宽度和高度。当它们被固定(被设置为精确的宽度或高度)时,Spacer 基本上会设置它们 (在本例中是maxWidth == minWidth
和maxHeight == minHeight
)。否则,它默认两个维度都为 0
。这实际上意味着 Spacer总是需要通过父元素或修饰符获得一些施加的大小限制。这是有道理的,因为 Spacer 这个组件本身不包含任何子元素,它的唯一用途就是用作填充空白间隙,所以它不能适应其包装的内容。
另一个可能更完整的测量策略的例子可以在 Box Composable 中找到:
此策略取决于 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
。这意味着每个子元素都可以决定自己想要多窄。只有前面计算的 boxWidth
或boxHeight
等于无穷大时才有可能出现此情况,这是由于当父组件(Box的父组件)约束中施加的最小维度约束是无界的时才会发生这种情况。如果不是这种情况,则将使用已经计算好的 boxWidth
和 boxHeight
作为测量子元素的最小约束:
在上面代码片段的最底部,我们可以看到所有与父节点大小匹配的子节点使用计算出的约束进行测量,并添加到 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看起来是什么样子的,或者它的行为是什么样的?我们可以查看 DropdownMenu Composable 的实现方式,它依赖于 DropdownMenuContent 来在 Column 中显示菜单项。
这将设置 Column 的首选宽度以匹配所有子元素(菜单项)的最大固有宽度。这样做实际上强制下拉菜单与其最宽的子元素的宽度匹配。
你可以阅读官方的 intrinsics measurement 文档了解更多实际用例的例子。
布局约束
布局约束可以来自父 LayoutNode 节点或者一个修饰符。Layout 或 layout
修饰符使用约束来测量其子布局。为了选择约束,约束提供了一个宽度和高度的最小和最大值的范围(px
单位)。测量的布局(子项)必须适配这些约束。
- minWidth <= chosenWidth <= maxWidth
- minHeight <= chosenHeight <= maxHeight
大多数现有布局要么将未修改的约束条件向下传递给其子项,要么将min
约束条件设置为 0
。前面提到的 Box 就是后一种情况的例子。我们可以通过另一个场景展示这一点:
有时,父节点或修饰符希望要求其子项提供其首选大小。在这种情况下,它可以传递无限大的maxWidth
或maxHeight
约束条件(即 Constraints.Infinity
)。当一个子节点被定义为填充所有可用空间时,为该维度传递一个无限约束就像是向子节点发出了一个信号,让它自己决定要占用多大的空间。假设我们有以下的 Box:
默认情况下,这个 Box 会填满所有可用的高度。但如果我们把它放在一个 LazyColumn 中(由于它是可滚动的,因此会使用无限高度约束来测量子节点),Box 将会根据 Text 的高度来包裹其内容。这是因为填充无限高度没有意义。在核心布局组件中,对于子节点在这些情况下自适应其内容大小非常普遍。然而,这最终取决于布局是如何定义的。
LazyColumn 也可以作为学习如何使用不受限制的 Constraints 的一个好案例。它依赖于一个更通用的布局称为 LazyList,后者使用子组合(SubcomposeLayout)来测量子项。当我们需要基于可用大小延迟组合 items 时,子组合非常有用。在这种情况下,是屏幕大小,因为 LazyList 只组合屏幕上可见的项。以下是测量其大小时如何创建约束条件的:
每次需要测量子项时,都会使用这些约束。首先,它将对项目的内容进行子组合。这是在 LazyList 测量传递期间完成的,这要归功于 SubcomposeLayout。子组合内容会生成一个可见子项的 measurables
列表,然后使用创建的 childConstraints
测量这些子项。此时,子项可以基本上自行选择其高度,因为高度约束是无限的。
另一方面,有时我们想要为所有子项设置确切的大小。当父元素或修饰符想要强制施加确切的大小时,它会强制执行 minWidth == maxWidth
和minHeight == maxHeight
。这基本上强制子元素适配该确切空间。
LazyVerticalGrid 是一个例子,它可以高效地显示具有动态项目数的垂直网格。这个 Composable 非常类似于 LazyColumn 或 LazyRow,因为它也是延迟组合可见的子项。当网格中单元格的数量固定时,它实际上使用了 LazyColumn,对于每个列,它呈现一个 ItemRow,其中包含多个 items
(与 span
数量相同)。ItemRow 布局使用固定宽度来测量其每个子项(列),该宽度由 span count
(列数)、列大小和 items
之间的间距确定。
这会创建如下的约束,其中宽度是固定的,而高度范围在0到无限大之间(即没有上限)
如果你想探索更多关于 Constraints 的用法例子,我强烈建议你查看常见的用于控制大小的 layout modifiers 的内部实现,或者你熟悉的任何来自 Compose UI 源代码中的 Layout 组件。(我保证这是一个非常有意义的练习,可以帮助你理解测量的过程。)
Constraints 被建模为 内联 类,用一个 Long
长整型数值以及几个位掩码来表示四个可用的约束(即 minWidth
、minHeight
、maxWidth
和 maxHeight
)。通过从该值中读取不同的位掩码来解析不同的约束。
LookaheadLayout
关于 LookaheadLayout 我想先分享一个来自 Google Jetpack Compose 团队的 Doris Liu 在她的 Twitter 上分享的 一个使用 LookaheadLayout 实现的非常 Cool 的动画示例 :
你可以在 https://cs.android.com 上面找到关于这个例子的全部源代码:SceneHostExperiment.kt 和 LookaheadWithMovableContentDemo.kt
这个例子的效果虽然很酷,但是可能我们还不能够确切的知道它到底能用来干嘛,如果要考虑实际一点的场景的话,假设有如下代码,它在应用中的两个可组合屏幕之间导航。
这两个屏幕当中可能包含一些共享元素,这也许是一个图片,一个文本或整个行。因此当我们在 CharacterList
中点击某个角色时,它们会发生动画。在这种情况下,如果我们想要动画过渡,我们可以选择设置一些魔法数字作为动画目标,因为我们恰好能够知道目标屏幕中所有共享元素的最终位置和大小。但这样做并不好。理想情况下,Compose UI 应该能够预先计算并提供这些信息,以便我们可以将其用于设置我们的动画目标。这就是 LookaheadLayout 发挥作用的地方。
LookaheadLayout 可以在其直接或间接子项更改时预先计算它们的测量和摆放。这使每个子项能够观察其测量/布局传递中预计算的值,并使用这些值随着时间逐渐改变其大小/位置(从而创建动画效果)。在上面的共享元素转换示例中,每个共享元素将观察其在转换到的屏幕中的最终大小和位置,并使用这些值来将自己动画化。
LookaheadLayout 是如何工作的
实际上,LookaheadLayout 在 “正常” 的测量/布局之前执行测量和布局的预查看操作(lookahead),以便后者可以利用前者预先计算的值来在每一帧上更新节点。这个预查看操作仅在树结构改变或者由于状态改变导致布局改变时发生。
<以上是关于Jetpack Compose 深入探索系列四: Compose UI的主要内容,如果未能解决你的问题,请参考以下文章
Jetpack Compose 深入探索系列二:Compose 编译器
Jetpack Compose 深入探索系列六:Compose runtime 高级用例
Jetpack Compose 深入探索系列一:Composable 函数
Jetpack Compose 深入探索系列五:State Snapshot System