Compose runtime vs Compose UI
在深入讨论之前,非常重要的一点是要区分 Compose UI
和 Compose runtime
。Compose UI
是 Android
的新 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
块中的函数(特别是 Group
和 Path
)也是组合函数,但是是不同类型的。它们不像 Compose UI
中的其他组合函数那样创建 LayoutNode
,而是创建特定于矢量图的元素。组合这些元素会产生一个矢量树,然后将其绘制到画布上。
Group
和 Path
存在于一个不同的 Composition
之中,与其他 UI 组件分开。这个 Composition
被包含在 VectorPainter
之中,只允许使用描述矢量图像的元素,而普通的 UI 组件则是被禁止的。
构建矢量图像树
矢量图像是由比 LayoutNode 更简单的元素创建的,以更好地适应矢量图形的需求。
上面的节点定义了一个树结构,类似于经典的 vector drawable XML中使用的树结构。树本身由两种主要类型的节点构建:
- GroupComponent:组合其子节点并对它们应用共享的变换;
- PathComponent:叶子节点(没有子节点),用于绘制
pathData
。
函数 DrawScope.draw()
提供了一种绘制节点及其子节点内容的方法。这个函数的签名与 Painter
接口相同,后者与此树的根集成。
相同的
VectorPainter
也被用于显示来自经典 Android 系统的 XMLvector drawable
资源。XML 解析器创建了类似的结构,这些结构转换为一系列Composable
调用,从而为看似不同类型的资源提供了相同的实现。
上面的树节点被声明为 internal
,而创建它们的唯一方式是通过相应的 @Composable
声明。这些函数是与 rememberVectorPainter
相关的函数。
ComposeNode 调用将节点发射到组合 Composition 中,创建树形元素。在此之外,@Composable
函数不需要与树交互。在初始插入时(创建节点元素时),Compose会跟踪已定义参数的更新,并逐步更新相关属性。
factory
参数定义如何创建树节点。这里,它只调用对应的Path
或Group
组件的构造函数。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 提供了两种插入方法:topDown 和 bottomUp,它们按不同的顺序组装树:
topDown
首先将节点添加到树中,然后逐个添加其子节点。bottomUp
创建节点,添加所有子节点,然后再将其插入到树中。
其底层原因是出于性能考虑:某些环境下将子节点添加到树中具有相关成本(考虑在经典 Android 系统中添加 View 时的重新布局)。对于矢量图案例,不存在这样的性能成本,因此节点从上向下插入。有关更多信息,请参见 Applier 文档。
将 Vector Compostion 集成到 Compose UI 中
有了 Applier,矢量合成几乎可以投入使用了。最后一步就是 VectorPainter 的集成。
集成的第一部分是连接 Compose UI 组合和矢量图像的组合:
- RenderVector 接受具有矢量图像描述的
content
Composable 内容。Painter实例通常在重新组合之间保持不变(使用remember
),但是如果内容已更改,则每次都会调用RenderVector。 - 创建组合始终需要父上下文,在这里它从 UI 组合中使用
rememberCompositionContext
获取。它确保两者连接到同一个 Recomposer,并将所有内部值(例如density
的CompositionLocals
)也传播到矢量组合中。 - 组合通过更新而保留,但应在 RenderVector 每次离开作用域时销毁。DisposableEffect 和Compose 中其他类型的订阅以类似的方式管理清理工作。
最后一步是用图像内容填充组合,创建矢量节点树,然后将其用于在画布上绘制矢量图像:
- VectorPainter 维护了自己的 Composition,因为 ComposeNode 需要匹配传递给 Composition 的 Applier,而 UI 上下文使用的 applier 与 Vector nodes 不兼容。
- 如果VectorPainter 尚未初始化,或已初始化但是其 Composition 已经被Dispose,则创建 Composition。否则就重用 Composition 。
- 创建 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 API 从 JS 操作这些元素,这也是由 Kotlin/JS 标准库提供的。
在开始 JS 之前,让我们先看一下浏览器中的 HTML 表示。
上面的 HTML 显示了一个带有三个 Item 的无序列表。从浏览器的角度来看,这个结构如下所示:
DOM 是一个类似树的结构,由元素构成,这些元素在 Kotlin/JS 中以 org.w3c.dom.Node
的形式公开。对于我们而言,相关的元素包括:
HTML
元素(org.w3c.dom.HTMLElement
的子类)表示标记(例如li
或div
)。可以使用document.createElement(<tagName>)
创建它们,浏览器会自动找到标记的正确实现。- 标记之间的文本
Text
元素(例如上面的“Item”)表示为org.w3c.dom.Text
。可以使用document.createTextElement(<value>)
创建此元素的实例。
使用这些 DOM 元素,从 JS 的角度来看,它会看成是如下的树形结构:
这些元素将为 Compose 管理的树提供基础,类似于在前面的部分中如何使用 VNode 进行矢量图像组合。
标签不能就地更改,例如 <audio>
元素与 <div>
元素在浏览器中的表示完全不同,因此如果标签名称已更改,则应该重新创建该元素。Compose 不会自动处理这个问题,因此重要的是避免在同一个 Composable 中传递不同的标签名称。
最简单的实现节点重新创建的方法是为每个节点都创建一个单独的 Composable(例如为相应元素创建
Div
和Ul
)。这样一来,您就可以为每个元素创建不同的编译时组,提示 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 的协程上下文。浏览器的默认 MonotonicClock 由 requestAnimationFrame 控制(如果使用 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 树 。