Jetpack Compose 深入探索系列六:Compose runtime 高级用例

Posted 川峰

tags:

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

Compose runtime vs Compose UI

在深入讨论之前,非常重要的一点是要区分 Compose UICompose runtimeCompose UIandroid 的新 UI 工具包,具有 LayoutNodes 的树形结构,它们稍后在画布上绘制其内容。Compose runtime 提供底层机制和许多状态/组合相关的原语。

随着 Compose 编译器 支持完整的 Kotlin 平台谱系,现在可以在几乎任何地方(只要它运行Kotlin)使用 runtime来管理 UI 或任何其他树形结构。注意“其他树形结构”部分:Compose runtime 中几乎没有直接提到 UI(或Android)。虽然该运行时肯定是为了支持该用例而创建和优化的,但它仍然足够通用,可以构建任何类型的树结构。事实上,它在这方面与 React JS 非常相似,React JS 的主要用途是在 Web上创建 UI,但它在合成器或 3D 渲染器等领域找到了更广泛的用途。大多数自定义渲染器重用 React runtime 的核心功能,但在浏览器 DOM 的位置上提供自己的构建块。

从上图中可以看到 JetBrains 团队在多平台下的 UI 模块布局,虽然它现在还不完整,缺少 iOS 端的支持,但是 Compose iOS 目前已经在极地的开发当中,这一点我们可以从 Kotlin 官网的讨论帖子中得到证实(如你感兴趣,可以点击这里查看他们的讨论):

也就是说在未来 compose-jb 发布稳定版本之后,我们是一定可以获得对 Compose iOS 的支持,这一点是毋庸置疑的,现在只是时间问题。到那时,Compose 将成为历史上真正意义上的只使用一种语言(Kotlin)实现的支持多平台的开发框架,这将是革命性的。

总而言之,在 Compose 编译器Compose runtime 的支持基础之上,我们可以构建任何一种类似于 Compose UI 这样的客户端 UI 库,而在 Android 平台上使用的 compose-ui 依赖库只是 Compose 架构能够支持的多平台场景之一。

Composition

Composition 为所有可组合函数提供上下文。它提供了由 SlotTable 支持的“缓存”以及通过 Applier创建自定义树的接口。Recomposer 驱动 Composition,在某些相关内容(例如状态)发生更改时启动重新组合。正如文档所提到的,通常情况下,Composition 由框架本身为您构建,但猜猜怎么着? 我们可以自己来管理它。

要构建 Composition,您可以使用提供的工厂方法:

  • 父级 context 通常可以通过 rememberCompositionContext() 在任何组合函数中获得。或者,Recomposer 也实现了 CompositionContext,它也可以在 Android 上获取到,或者为你自己的需求单独创建。
  • 第二个参数是 Applier,决定如何创建和连接由 Composition 生成的树形结构。

有趣的事实:如果需要可组合函数的其他属性,你可以提供一个完全不执行任何操作的 Appler实例。即使没有节点,@Composable注解也可以为数据流转换或事件处理程序提供动力,这些处理程序可以像所有的组合一样对状态变化做出反应。只需要这样Applier<Nothing>,不要在那里使用ComposeNode !

接下来的其余部分将着重介绍如何在不使用 Compose UI 的情况下使用 Compose runtime。第一个例子来自 Compose UI 库,其中使用自定义树来呈现矢量图形(我们在之前简要介绍过它)。之后,我们将切换到 Kotlin/JS 并使用 Compose 创建浏览器 DOM 管理库的玩具版本。

矢量图形的组合

在 Compose 中,矢量渲染是通过 Painter 抽象实现的,类似于经典 Android 系统中的 Drawable

rememberVectorPainter 块中的函数(特别是 GroupPath)也是组合函数,但是是不同类型的。它们不像 Compose UI 中的其他组合函数那样创建 LayoutNode,而是创建特定于矢量图的元素。组合这些元素会产生一个矢量树,然后将其绘制到画布上。

GroupPath 存在于一个不同的 Composition 之中,与其他 UI 组件分开。这个 Composition 被包含在 VectorPainter 之中,只允许使用描述矢量图像的元素,而普通的 UI 组件则是被禁止的。

构建矢量图像树

矢量图像是由比 LayoutNode 更简单的元素创建的,以更好地适应矢量图形的需求。

上面的节点定义了一个树结构,类似于经典的 vector drawable XML中使用的树结构。树本身由两种主要类型的节点构建:

  • GroupComponent:组合其子节点并对它们应用共享的变换;
  • PathComponent:叶子节点(没有子节点),用于绘制 pathData

函数 DrawScope.draw() 提供了一种绘制节点及其子节点内容的方法。这个函数的签名与 Painter 接口相同,后者与此树的根集成。

相同的 VectorPainter 也被用于显示来自经典 Android 系统的 XML vector drawable 资源。XML 解析器创建了类似的结构,这些结构转换为一系列 Composable 调用,从而为看似不同类型的资源提供了相同的实现。

上面的树节点被声明为 internal,而创建它们的唯一方式是通过相应的 @Composable 声明。这些函数是与 rememberVectorPainter 相关的函数。

ComposeNode 调用将节点发射到组合 Composition 中,创建树形元素。在此之外,@Composable函数不需要与树交互。在初始插入时(创建节点元素时),Compose会跟踪已定义参数的更新,并逐步更新相关属性。

  • factory参数定义如何创建树节点。这里,它只调用对应的 PathGroup 组件的构造函数。
  • update提供了一种逐步更新已创建实例的属性的方法。在 lambda 内部,Compose 使用辅助函数(例如 fun <T> Updater.set(value: T)fun <T> Updater.update(value: T))对数据进行记忆。 它们仅在提供的值更改时刷新树节点属性,以避免不必要的失效。
  • content参数是将子节点添加到其父节点的方法。这个可组合参数在节点的更新完成后执行,然后所有发出的节点都将作为当前节点的子节点。ComposeNode 还有一个没有 content 参数的重载版本,可以用于叶节点,例如 Path

为了将子节点连接到父节点,Compose 使用 Applier,我们在之前文章简要讨论过。VNode 通过 VectorApplier 组合。

Applier 接口中的大部分方法经常会用于列表操作(插入/移动/删除)。为了避免反复实现它们,AbstractApplier 甚至为MutableList提供了方便的扩展。在 VectorApplier 的情况下,这些列表操作直接在 GroupComponent 中实现。

Compose runtime 篇我们提到过,Applier 提供了两种插入方法:topDownbottomUp,它们按不同的顺序组装树:

  • topDown 首先将节点添加到树中,然后逐个添加其子节点。
  • bottomUp 创建节点,添加所有子节点,然后再将其插入到树中。

其底层原因是出于性能考虑:某些环境下将子节点添加到树中具有相关成本(考虑在经典 Android 系统中添加 View 时的重新布局)。对于矢量图案例,不存在这样的性能成本,因此节点从上向下插入。有关更多信息,请参见 Applier 文档

将 Vector Compostion 集成到 Compose UI 中

有了 Applier,矢量合成几乎可以投入使用了。最后一步就是 VectorPainter 的集成。

集成的第一部分是连接 Compose UI 组合和矢量图像的组合:

  1. RenderVector 接受具有矢量图像描述的 content Composable 内容。Painter实例通常在重新组合之间保持不变(使用remember),但是如果内容已更改,则每次都会调用RenderVector
  2. 创建组合始终需要父上下文,在这里它从 UI 组合中使用rememberCompositionContext获取。它确保两者连接到同一个 Recomposer,并将所有内部值(例如 densityCompositionLocals)也传播到矢量组合中。
  3. 组合通过更新而保留,但应在 RenderVector 每次离开作用域时销毁DisposableEffect 和Compose 中其他类型的订阅以类似的方式管理清理工作。

最后一步是用图像内容填充组合,创建矢量节点树,然后将其用于在画布上绘制矢量图像:

  1. VectorPainter 维护了自己的 Composition,因为 ComposeNode 需要匹配传递给 CompositionApplier,而 UI 上下文使用的 applierVector nodes 不兼容。
  2. 如果VectorPainter 尚未初始化,或已初始化但是其 Composition 已经被Dispose,则创建 Composition。否则就重用 Composition
  3. 创建 Composition 后,通过 setContent 填充它,类似于 ComposeView 中使用的方式。每当使用不同内容调用 RenderVector 时,setContent 就会再次执行以刷新向量结构。content内容将子项添加到根节点,稍后用于绘制 Painter 的内容。

完成了整合,现在VectorPainter可以在屏幕上绘制 @Composable 内容。Painter 内部的组合项也可以访问 UI 组合项中的状态和组合局部值来驱动它们自己的更新。

通过上述内容,您已经知道了如何创建自定义树并将其嵌入到已有的组合中。在下一部分中,我们将介绍如何基于相同的原则创建一个独立的 Kotlin/JS 组合系统。

使用 Compose 管理 DOM

多平台支持对于 Compose 来说仍然是一件新事物,只有运行时编译器可以在 JVM 生态系统之外使用。然而,这两个模块足以让我们创建一个组合并在其中运行某些内容,这会带来更多的体验!

Google 提供的 Compose 编译器依赖支持所有 Kotlin 平台,但是运行时仅分发给 Android。然而,Jetbrains 发布了他们自己的版本的 Compose(大部分没有变化),其中包含 JS 的多平台构件。

Compose 产生魔力的第一步是找出它应该操作的树。值得庆幸的是,浏览器已经具有基于 HTML/CSS 的“视图”系统。我们可以通过 DOM APIJS 操作这些元素,这也是由 Kotlin/JS 标准库提供的。

在开始 JS 之前,让我们先看一下浏览器中的 HTML 表示。


上面的 HTML 显示了一个带有三个 Item 的无序列表。从浏览器的角度来看,这个结构如下所示:

DOM 是一个类似树的结构,由元素构成,这些元素在 Kotlin/JS 中以 org.w3c.dom.Node 的形式公开。对于我们而言,相关的元素包括:

  • html 元素(org.w3c.dom.HTMLElement的子类)表示标记(例如lidiv)。可以使用document.createElement(<tagName>)创建它们,浏览器会自动找到标记的正确实现。
  • 标记之间的文本 Text 元素(例如上面的“Item”)表示为org.w3c.dom.Text。可以使用document.createTextElement(<value>)创建此元素的实例。

使用这些 DOM 元素,从 JS 的角度来看,它会看成是如下的树形结构:

这些元素将为 Compose 管理的树提供基础,类似于在前面的部分中如何使用 VNode 进行矢量图像组合。

标签不能就地更改,例如 <audio> 元素与 <div> 元素在浏览器中的表示完全不同,因此如果标签名称已更改,则应该重新创建该元素。Compose 不会自动处理这个问题,因此重要的是避免在同一个 Composable 中传递不同的标签名称。

最简单的实现节点重新创建的方法是为每个节点都创建一个单独的 Composable(例如为相应元素创建 DivUl)。这样一来,您就可以为每个元素创建不同的编译时组,提示 Compose 应完全替换这些元素,而不仅仅是更新其属性。

但是对于文本元素而言,在结构上是相同的,我们可以使用 ReusableComposeNode 来指示这一点。这样,即使 Compose 在不同的组中找到这些节点,它也会重用实例。为确保正确性,文本节点创建时没有内容,并使用 update 参数设置其值。

为了将元素组合成树形结构,Compose 需要一个操作 DOM 元素的 Applier 实例。其中的逻辑与上文中的 VectorApplier 非常相似,只是用于添加/删除子元素的 DOM 节点方法略有不同。大多数代码都是完全机械化的(将元素移动到正确的索引位置),因此我在此省略了它。如果您需要参考,请查看 Compose for Web 中使用的 Applier

浏览器中的独立组合

为了将我们的新 Composables 组合到 UI 中,Compose 需要一个活动组合。在 Compose UI 中,所有初始化都已在 ComposeView 中完成,但对于浏览器环境,需要从头开始创建。

相同的原则也可以应用于不同的平台,因为下面描述的所有组件都存在于 “common” 的 Kotlin 代码模块中。

renderComposable 隐藏了组合开始的所有实现细节,提供了一种将可组合元素呈现到 DOM 元素的方式。其中大部分设置都涉及使用正确的时钟和协程上下文初始化 Recomposer

  • 首先,快照系统(负责状态更新)被初始化。GlobalSnapshotManager 被有意地排除在运行时之外,如果目标平台没有提供该类的源码 ,你可以从 Android 源码中拷贝一份。这是目前 runtime 没有提供的唯一部分。
  • 接下来,使用 JS 默认值创建 Recomposer 的协程上下文。浏览器的默认 MonotonicClockrequestAnimationFrame 控制(如果使用 JetBrains 实现),Dispatchers.Main 引用了 JS 操作的唯一线程。此上下文用于稍后运行重组。
  • 创建 一个 Composition 组合。它的创建方式与上面的矢量图示例相同,但现在 Recomposer 被用作组合的父项(recomposer 必须始终是最上层组合的父项)。
  • 然后,设置组合内容(setContent)。所有对此组合的更新都应在提供的 Composable 内完成,因为新的 renderComposable 调用会从头开始重新创建所有内容。
  • 最后一部分是启动重组过程,这是通过启动一个协程 Recomposer.runRecomposeAndApplyChanges 来完成的。在 Android 上,此过程通常与 Activity/View 生命周期相关联。可以通过调用 recomposer.cancel() 来停止重组过程。这里,组合的生命周期与页面的生命周期绑定,因此不需要取消。

上面的基本元素现在可以组合在一起来呈现 HTML 页面的内容:

创建静态内容可以通过更简单的方法来实现,而 Compose 最初的目的是为了实现交互性。在大多数情况下,我们希望在点击按钮时发生某些事情,而在 DOM 中,可以通过类似于 Android 视图的点击监听器来实现。

Compose UI 中,许多监听器是通过 Modifier 扩展定义的,但它们的实现对于 LayoutNode 是特定的,因此不适用于这个示例 Web 库。你可以尝试从 Compose UI 中复制Modifier的行为,并调整此处使用的节点以通过修饰符更好地集成事件监听器。。

现在,每个 Tag 都可以定义一个点击监听器作为 lambda 参数,该参数通过为所有 HTMLElement 定义的 onclick 属性传播到 DOM 节点。有了这个补充,可以通过将 onClick 参数传递给 Tag 组合来处理点击:

这里有多种玩法可以扩展这个玩具库,添加对 CSS、更多事件和元素的支持等等。JetBrains 团队目前正在尝试更高级的 Compose for Web 版本。它基于我们在此探索的玩具版本的相同原则,但在许多方面更为先进,可以支持在Web上构建的各种功能。

总结

在本文中,我们探讨了如何使用核心的 Compose 概念来构建 Compose UI 之外的系统。自定义组合在实际中很难遇到,但如果您已经在 Kotlin / Compose 环境中工作,那么它们是非常有用的工具。

矢量图形组合是将自定义可组合树集成到 Compose UI 的很好的例子。相同的原理可以用于创建其他自定义元素,这些元素可以轻松地与 UI 组合中的状态/动画/组合局部进行交互。

在所有Kotlin平台上创建独立的组合也是可能的!我们通过 Kotlin/JS 的强大功能,在浏览器中基于Compose runtime 制作了一个玩具版本的 DOM 管理库。类似地,Compose runtime 已经被用于一些 Android 之外的项目中来操作 UI 树 。

以上是关于Jetpack Compose 深入探索系列六:Compose runtime 高级用例的主要内容,如果未能解决你的问题,请参考以下文章

Jetpack Compose 深入探索系列二:Compose 编译器

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

Jetpack Compose 深入探索系列一:Composable 函数

Jetpack Compose 深入探索系列五:State Snapshot System

深入详解 Jetpack Compose | 优化 UI 构建

Jetpack Compose简单的屏幕适配方案