基于 Compose Runtime 做 PPT UI 库?-灵析社区

江江说技术

当谈到 Jetpack Compose 时,你可能会想到:这是个 UI 库。但实际上,Compose 包含多个子库,也就是:


上面的五个基本都和 UI 有关,但今天我们不看它们,我们来玩一玩下面两个:compose.compilercompose.runtime。基于它们,我们可以自己做一个客户端库(Client Library)。

Client Library

在这里,我们要讨论的是 Client Library。大致上,它就是 “用于编写应用程序的API” 。比如,在 Jetpack Compose 中,它的各个组成部分有这样的层级

自上而下, Material 用到了 Foundation,Foundation 用到了 UI,UI 又依赖于 Runtime。前三者都是 Client Library。而今天,我们要做的,就是基于 Runtime 写一个 “compose.ui” 出来

Compose Compiler

让我们先看看 Compose 编译器吧。在这里,我们不会很深入的涉及各项细节,而是给你一个大致的把控。基本上,Compose 编译器是:

  • 一个 Kotlin 编译器插件(Kotlin Compiler Plugin)
  • 处理 @Composable 函数
  • 生成 Compose Runtime 产物

类似于 suspend 函数的处理,Compose 编译器在背后为我们做了许多事情。比如说:

  • 注入 Composer:我们之后看
  • 稳定性检查:用于判定某个 Composable 是否发生变化,是否需要重组
  • 管理 Group:这个和 Slot Table 有关,我们之后也会看到
  • 字面量热重载:AS 提供的功能,大家应该不陌生
  • ……

Compose Runtime

Compose Runtime 可以说是 Compose 的大脑,没有它,就不会有 Jetpack Compose。它负责管理各种状态,是整个 Compose 编程的基石。

我们将会从下面四个方面简要介绍它。

Composer

Composer 是 Composable 和 Slot Table 间的桥梁。它由 Compose 编译器注入,所有与它相关的代码都由 Compose 编译器处理好了。

Composer 负责:

  • 插入、更新或者结束 Groups(组) & Nodes(节点)
  • Remember values:Composer 帮我们把各种需要 remember 的值放到 slot table 里去
  • 记录各种变化,以便 UI 更新

到现在为止,我们的 Composable 经历了这么些流程:


我们的 Composable 经过 Compose 编译器,现在多了个 Composer 作为参数。当我们开始运行时,又发生了什么呢?我们上面说,Composer 是 Composable 和 Slot Table 间的桥梁,现在就是和 Slot Table 交互的时机了

Slot Table

什么是 Slot Table?简单地说,它是一种线性的数据结构,能够高效地处理 “插入” 的过程。它存储着 Composition 的当前状态,也就是说,哪个 Composition 需要重新执行,哪个不需要,这个是记录在 Slot Table 里面的。

Slot Table 的工作依赖于两个概念:GroupsSlots

Groups 用于将内存中上实际存储的线性结构转换为 UI 所需的树形结构。比方说,对于数组 array = [ComposableA, ComposableB, ComposableC],要能变成

- ComposableA
  - ComposableB
    - ComposableC

Slots 则负责存储 Group 中的真实数据,比如说 rememebr { "FunnySaltyFish" },里面的 FunnySaltyFish 就实际存储在 Slots 中。

如需详细了解 Slot Table,可参阅 fundroid 大佬写的 探索 Jetpack Compose 内核:深入 SlotTable 系统 或者由 Google 工程师写的 Under the hood of Jetpack Compose — part 2 of 2

现在,我们把目光重新转回去。我们现在知道,Composable 在 runtime 中被调用,它和 Slot Table 交互,而 Slot Table 中又包含着来自 Composable 的数据。但好像这都和用户没关系诶?目前的这些都是 Compose 内部在互相玩耍。接下来会发生什么呢?

我们已经提到过,Composer 会记录状态的改变,比如说首次的 Composition,或者后来的 Re-composotion,它记录到了这些变化,然后,这些变化会被交给 Applier 来使用。来看看 Applier 吧

Applier

Applier 负责应用那些 Composition 中被提交的基于树的操作。也就是说,Composer 提交了这些变化,Applier 就负责在 Node Tree 上执行它们。这些操作主要包含:插入、移动、删除(或者甚至清空整棵树)

有趣的是,如果你查看 Applier 的源码,你会发现它是个接口:

/**
 * An Applier is responsible for applying the tree-based operations that get emitted during a
 * composition. Every [Composer] has an [Applier] which it uses to emit a [ComposeNode].
 *
 * A custom [Applier] implementation will be needed in order to utilize Compose to build and
 * maintain a tree of a novel type.

 */
@JvmDefaultWithCompatibility
interface Applier<N> {
    xxx
}

而它有个实现的抽象类 AbstractApplier


/**
 * An abstract [Applier] implementation.

 */
abstract class AbstractApplier<T>(val root: T) : Applier<T> {
    xxx
}

因此,如果我们需要写自定义的 Applier 的话,就需要继承这个抽象类。

好嘞,现在我们有了 Applier,我们知道它会把 change 作用到 Compose Node Tree 上。整个流程现在变成了这样:

那么新的问题来了,这个 Compose Node Tree 是怎样被创建的呢?

Compose Node Tree

假设我们现在有两个 Composable,HiDev

现在,这两个 Composable 都会提交一个 Node 给 Applier,Applier 获取到了这个节点,就可以按情况创建或者更新 Compose Node Tree。

那么,Compose Node 又是怎么被提交的呢?我们说 “提交一个节点”,大致上就是在说 “创建一个你自己的节点,让它代表一个 Composable”。为了让 Compose 知道我们想要提交什么类型的节点,我们需要选择合适的 Node 类型,它们包括:ComposableNode 和 ReusableComposeNode

从字面上可以看到,ReusableComposeNode 就是 “可以被重新使用的 Compose Node”,即:避免重新创建一个新 Node,而是直接复用某个节点。它们的代码差异如下:

/**
 * Emits a node into the composition of type [T].
 *
 * This function will throw a runtime exception if [E] is not a subtype of the applier of the
 * [currentComposer].
 *
 * @param factory A function which will create a new instance of [T]. This function is NOT
 * guaranteed to be called in place.
 * @param update A function to perform updates on the node. This will run every time emit is
 * executed. This function is called in place and will be inlined.
 *
 */
// ComposeNode is a special case of readonly composable and handles creating its own groups, so
// it is okay to use.
@Suppress("NONREADONLY_CALL_IN_READONLY_COMPOSABLE", "UnnecessaryLambdaCreation")
@Composable inline fun <T : Any, reified E : Applier<*>> ComposeNode(
    noinline factory: () -> T,
    update: @DisallowComposableCalls Updater<T>.() -> Unit
) {
    if (currentComposer.applier !is E) invalidApplier()
    // 注意看,这里是 startNode
    currentComposer.startNode()
    if (currentComposer.inserting) {
        currentComposer.createNode { factory() }
    } else {
        currentComposer.useNode()
    }
    Updater<T>(currentComposer).update()
    currentComposer.endNode()
}

/**
 * Emits a recyclable node into the composition of type [T].
 *
 * This function will throw a runtime exception if [E] is not a subtype of the applier of the
 * [currentComposer].
 
 * @param factory A function which will create a new instance of [T]. This function is NOT
 * guaranteed to be called in place.
 * @param update A function to perform updates on the node. This will run every time emit is
 * executed. This function is called in place and will be inlined.
 */
// ComposeNode is a special case of readonly composable and handles creating its own groups, so
// it is okay to use.
@Suppress("NONREADONLY_CALL_IN_READONLY_COMPOSABLE", "UnnecessaryLambdaCreation")
@Composable inline fun <T : Any, reified E : Applier<*>> ReusableComposeNode(
    noinline factory: () -> T,
    update: @DisallowComposableCalls Updater<T>.() -> Unit
) {
    if (currentComposer.applier !is E) invalidApplier()
    // 而这里是 startReusableNode
    currentComposer.startReusableNode()
    if (currentComposer.inserting) {
        currentComposer.createNode { factory() }
    } else {
        currentComposer.useNode()
    }
    Updater<T>(currentComposer).update()
    currentComposer.endNode()
}

上面两个函数都是泛型函数,也就是说,你需要自己指定类型,告诉 Compose 你需要提交什么类型的 Node。

例如,在刚刚的示例中,我们提交一个自定义的 TextNode,它们会交给 Applier,根据情况创建或更新 Compose Node Tree。

现在我们已经知道 Compose Node Tree 是怎么创建或更新的了。但现在还差一步:我们还没画 UI,还没显示任何东西。到这里,Compose Runtime 已经都给你准备好了,最后的一步就是让它显示出来而已。这也就是我们今天的主题:做一个显示在 PPT 里的 UI 库。

ComposePPT

我们要做的最后一步叫做 Client Intergration(客户端集成),具体来说,它包括:

  • 获取到 Composable 的 content:你需要知道要显示啥
  • 创建 Frame Clock 和 Recomposer:Recomposer 负责管理 Recomposition,监听状态变化,让 Composable 知道:我需要 Recomposition 了
  • 创建最初的 Composition
  • 观察并应用 Snapshot(快照) objects 的变化
  • 刷新 Frame

我们选择了 PowerPoint 作为客户端,其实你也可以选其他的,用 View 啊、用 Canvas 啊、用 HTML 啊或者什么乱七八糟的,但我们这选 PPT 做演示。For Fun!

我们希望能呈现这样一个效果:一页幻灯片(Slide),里面包含一行文本

我们需要啥?

首先,我们需要定义一些节点,比如 TextNodeSlideNode,用来表示文本和幻灯片(当然也可以有其他的,比如 Video、Image,但我们这先演示这俩);其次,我们需要自定义的 Applier,让 Compose 知道如何去创建/插入我们的节点,如何更新之类的;再然后,我们需要 Composable,这里也是两个, TextSlide;最后,我们需要做 Client Integration

让我们开始吧~

自定义 Node

我们先定义一个抽象类,表示咱们的 PPTNode

它有一个成员 children, 表示子节点;一个方法 render 用于渲染,在我们这,就是渲染成 PPT。

SlideNode

SlideNode 表示一张幻灯片,有点类似于 布局。它的代码就简单了,render 方法就循环调用一遍 children.render

TextNode

TextNode 略有不同,我们希望它有一些数据(text),并把它渲染出来。它的代码如下:

这里用的是 Apache POI 实现的渲染,我们不会深入细节。如果你感兴趣,可以查看对应源代码、

自定义 Applier

下一步是自定义 Applier,也就是继承 AbstractApplier。实际上,AbstractApplier 已经实现了一些逻辑,因此我们需要重写一些方法。它们主要包括:

AbstractApplier 提供了两个 insert 方法,分别是自顶向下和自底向上,我们只需要按要求实现即可。这里我们只用自顶向下的 insert 就行

其他操作 包括 remove/move/clear 之类的

创建 Composable

下一步就是创建 Composable。听起来很简单,但它实际上和你平时写的有点不一样。因为,现在我们现在需要提交我们刚刚创建的 Node。我们调用 ComposeNode 函数,并传入刚刚写好的 NodeApplier。我们先从 Slide 开始吧

这里的几个参数,

  • factory 传入了 SlideNode 的构造函数
  • update 会在每次 Composable 被重新调用时触发,但这里由于没有任何数据,这里就不写了

Text 的代码类似,如下:

不同的是,由于 Text 持有数据(text),因此需要更新一下。

客户端集成

好,现在最后一步。我们在这儿要给 Composable 一个入口(类似于 setContent {})。在这儿,我们用 runBlocking 执行一些逻辑

我们首先需要创建一个 frameClock,这里调用的是 Compose 已经给出的实现;然后是 RecomposerrootNode(在这里,也就是 SlideNode

然后是创建 Composition、设置 content 为我们传入的 content

接下来我们需要观察快照的变更、运行 Recomposer、向 clock 不断发送帧……最后调用 rootNode.render() 完成渲染

好耶,我们现在就完成啦~

怎么用呢?

比如说,在 main 函数调用我们的入口函数,即可

当你运行时,你就发现生成了对应的 PPT

最后

本项目的源代码在:fgiris/composePPT: An experimental UI toolkit for generating PowerPoint presentation files using Compose,欢迎建议和 PR。我们最后做个总结吧:

  • Compose 编译器转换 Composable 函数
  • Composer 是 Composable 和 Slot Table 之间的桥梁
  • Slot Table 持有当前 Composition 的状态
  • Applier 负责对提交的 Node 做基于树的操作
  • 如果想基于 Compose 创建一个客户端库

这就是今天的内容了,感谢大家的观看(在这里是阅读哈哈)。

阅读量:1233

点赞量:0

收藏量:0