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

本文深入探讨了Jetpack Compose的UI层面,包括通过Compose runtime集成UI、从Compose UI的角度理解组合和子组合。讨论了初始组合和重组过程、节点类型如LayoutNode和VNode、Applier的概念以及它们如何映射到平台节点。文章还介绍了SubcomposeLayout、语义树、测量策略和绘制流程,展示了Compose UI如何将UI变化反映在实际屏幕上。
摘要由CSDN通过智能技术生成

通过 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工厂函数),初始化它( update lambda),并创建一个可替换的group组来包装所有内容。该group会被分配一个唯一的key,以便稍后可以识别它。通过可替换的group内的 content lambda 的调用而发出的任何节点,实际上都将成为该节点的子节点。

update block 块内的 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 中,过度绘制不是问题),因此需要通知许多祖先。<

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

川峰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值