这是我在 ​​2022 Kotlin 中文开发者大会​​ 中带来的一个分享,会后有网友反馈希望将 PPT 内容整理成文字方便阅读,所以就有了本篇文章。大家如果要了解本次大会更多精彩内容,也可以去 JetBrains 官方视频号查看大会的直播回放。

前言

Compose 不止能用于 Android 应用开发,借助其分层的架构设计以及 Kotlin 的跨平台优势,也是一个极具潜力的 Kotlin 跨平台框架。本文让我们从 Compose Runtime 的视角出发,看看 Compose 实现跨平台开发的基本原理。

Compose Architecture Layers

Compose 为什么可以跨平台?_UI

Compose 作为一个框架,在架构上从下到上分成多层:

  • Compose Compiler:Kotlin 编译器插件,负责对 Composable 函数的静态检查以及代码生成等。
  • Compose Runtime:负责 Composable 函数的状态管理,以及执行后的渲染树生成和更新
  • Compose UI: 基于渲染树进行 UI 的布局、绘制等 UI 渲染工作
  • Compose Foundation: 提供用于布局的基础 Composable 组件,例如 ​​Column​​​,​​Row​​ 等。
  • Compose Material:提供上层的面向 Material 设计风格的 Composable 组件。 各层的职责明确,其中 Compose Compiler 和 Runtime 是支撑整个声明式 UI 运转的基石。

Compose Compiler

我们先看一下 Compose Compiler 的作用:

Compose 为什么可以跨平台?_Android_02

左边的源码是一个非常简单的 Composable 函数,定义了个一大带有状态的 Button,点击按钮,Button 中显示的 count 数增加。

源码经 Compose Compiler 编译后变成右边这样,生成了很多代码。首先函数签名上多了几个参数,特别是多了 %composer 参数。然后函数体中插入了很多对 %composer 的调用,例如 startRestartGroup/endRestartGroup,startReplaceGroup/endReplaceGroup 等。这些生成代码用来完成 Compose Runtime 这一层的工作。接下来我们分析一下 Runtime 具体在做什么

Group & SlotTable

Composable 函数虽然没有返回值,但是执行过程中需要生成服务于 UI 渲染的产物,我们称之为 Composition。参数 %composer 就是 Composition 的维护者,用来创建和更新 Composition。Composition 中包含两棵树,一棵状态树和一棵渲染树。

关于两棵树:如果你了解 React,可以将这两棵树的关系类比成 React 中的 VIrtual DOM Tree 与 Real DOM Tree。Compose 中的这棵 “Virtual DOM” 用来记录 UI 显示所需要的状态信息, 所以我们称之为状态树。

状态树上的节点单元是 Group,编译器生成的 startXXXGroup 本质上就是在创建 Group 单元, startXXXGroup 与 endXXXGroup 之间产生的数据状态都归属当前 Group;产生的 Group 就成为子 Group,因此随着 Composable 的执行,基于 Group 的树型结构就被构建出来了。

关于 Group:Group 都是一些功能单元,比如 RestartGroup 是一个可重组的最小单元,ReplaceableGroup 是可以被动态插入的最小单元等,以 Group 为单位组织状态,可以更灵活的更新状态树。代码中什么位置插入什么样的 startXXXGroup 完全由 Compose Compiler 智能的帮我们生成,我们在写代码时不必付出这方面的思考。

状态树实际是使用一个被称作 Slot Table 的线性数据结构实现的,可以把他理解为一个数组,存储着状态树深度遍历的结果,数组的各个区间存储着对应 UI 节点上的状态。

Compose 为什么可以跨平台?_JetBrains_03

Comopsable 首次执行时,产生的 Group 以及所瞎的状态会以此填充到 Slot Table 中,填充时会附带一个编译时给予代码位置生成的不重复的 key,所以 Slot Table 中的记录也被称作基于代码位置的存储(Positional Memoization)。当重组发生时, Composable 会再次遍历 SlotTable,并在 startXXXGroup 中根据 key 访问当前代码所需的状态,比如 count 就可以通过 remember 在重组中获取最近的值。

Applier & Node Tree

Slot Table 中的状态不能直接用来渲染,UI 的渲染依赖 Composition 中的另一棵树 - 渲染树。Slot Table 通过 Applier 转换成渲染树。渲染树是真真正的树形结构体 Node Tree。

Compose 为什么可以跨平台?_JetBrains_04

Applier 是一个接口,从接口定义不难看出,它用于对一棵 Node 类型节点树进行增删改等维护工作。以一个 UI 的插入为例,我们在 Compoable 中的一段 if 语句就可以实现一个 UI 片段的插入。if 代码块在编译期会生成一个 ReplaceGroup,当重组中命中 if 条件执行到 startReplaceGroup 时,发现 Slot Table 中缺少 Group 对应 key 的信息,因此可以识别出是一个插入操作,然后插入新的 Group 以及所辖的 Node 信息,并通过 Applier 转换成 Node Tree 中新插入的节点。

SlotTable 中插入新元素后,后续元素会通过 Gap Buffer 机制进行后移,而不是直接删除。这样可以保证后续元素在 Node Tree 中的对应节点的保留,实现 Node Tree 的增量更新,实现局部刷新,提升性能。

Compose Phases

我们结合前面的介绍,整体看一下 Compose 从源码到上屏的全过程:

  • Composable 源码经 Compiler 处理后插入了用于更新 Composition 的代码。这部分工作由 Compose Compiler 完成。
  • 当 Compose 框架接收到系统侧发送的帧信号后,从顶层开始执行 Composable 函数,执行过程中依次更新 Composition 中的状态树和渲染树,这个过程即所谓的“组合”。这部分工作由 Compose Runtime 完成。
  • Compose 在 Android 平台的容器是 AndroidComposeView,当接收到系统发送的 disptachDraw 时,便开始驱动 Composition 的渲染树以及进行 Measure,Lyaout,Drawing 完成 UI 的渲染。这部分工作由 Compose UI 负责完成。

Compose 为什么可以跨平台?_Group_05

Comopse 渲染一帧的三个阶段 : Composition -> Layout -> Drawing。 传统视图开发中,渲染树(View Tree)的维护需要我们在代码逻辑中完成;Compose 渲染树的维护则交给了框架,所以多了 Composition 这一阶段。这也是 Compose 相对于自定义 View 代码更简单的根本原因。

把这整个过程从中间一分为二来看,Compose Compiler 与 Compose Runtime 负责驱动一棵节点树的更新,这部分与平台无关,节点树也可以是任意类型的节点树甚至是一颗渲染无关的树。不同平台的渲染机制不同,所以 Compose UI 与平台相关。 我们只要在 Compoe UI 这一层,针对不同平台实现自己的 Node Tree 和对应的 Applier,就可以在 Compose Runtime 的驱动下实现 UI 的声明式开发。

Compose for Android View

基于这一结论,我们做一个实验:使用 Compose Runtime 驱动 Android 原生 View 的渲染。

我们首先定义一个基于 View 类型节点的 Applier :ViewApplier

class ViewApplier(val view: FrameLayout) : AbstractApplier<View>(view) {
override fun onClear() {
(view as? ViewGroup)?.removeAllViews()
}

override fun insertBottomUp(index: Int, instance: View) {
(current as? ViewGroup)?.addView(instance, index)
}

override fun insertTopDown(index: Int, instance: View) {
}

override fun move(from: Int, to: Int, count: Int) {
// NOT Supported
TODO()
}

override fun remove(index: Int, count: Int) {
(view as? ViewGroup)?.removeViews(index, count)
}
}

然后,我们创建两个 Android View 对应的 Composable,TextView 和 LinearLayout:

@Composable
fun TextView(
text: String,
onClick: () -> Unit = {}
) {
val context = localContext.current
ComposeNode<TextView, ViewApplier>(
factory = {
TextView(context)
},
update = {
set(text) {
this.text = text
}
set(onClick) {
setOnClickListener { onClick() }
}
},
)
}

@Composable
fun LinearLayout(children: @Composable () -> Unit) {
val context = localContext.current
ComposeNode<LinearLayout, ViewApplier>(
factory = {
LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
},
update = {},
content = children,
)
}

ComposeNode 是 Compose Runtime 提供的 API,用来像 Slot Table 添加一个 Node 信息。Slot Tabl 通过 Applier 创建基于 View 的节点树时,会通过 Node 的 factory 创建对应的 View 节点。

有了上述实验,我们就可以使用 Compose 构建 Android View 了,同时可以通过 Compose 的 SnapshotState 驱动 View 的更新:

@Composable
fun AndroidViewApp() {

var count by remember { mutableStateOf(1) }

LinearLayout {
TextView(
text = "This is the Android TextView!!",
)
repeat(count) {
TextView(
text = "Android View!!TextView:$it $count",
onClick = {
count++
}
)
}

}
}

执行效果如下:

Compose 为什么可以跨平台?_Group_06

同样,我们也可以基于 Compose Runtime 为任意平台打造基于 Compose 的声明式 UI 框架。

Compose for Desktop & Web

JetBrains 在 Compose 多平台应用方面进行了很多尝试,并做出了很多成果。JetBrains 基于谷歌 Jetpack Compose 的 fork 相继发布了 Compose for Desktop 以及 Compose for Web。

Compose 为什么可以跨平台?_Android_07

Compose Desktop 与 Android 同样基于 LayoutNode 的渲染树,通过 Skia 引擎完成跨平台渲染。所以它们在渲染效果以及开发体验上都保持高度一致。Compose Desktop 依靠 Kotlin/JVM 编译成字节码产物,并使用 Jpackage 和 Jlink 打包成不同桌面系统的( Linux/Mac/Windows)的安装包,可以在脱离 JVM 的环境下直接运行。

Compose Web 使用了基于 W3C 标准的 DomNode 作为渲染树节点,在 Compose Runtime 驱动下生成 DOM Tree 。Compose Web 通过 Kotlin/JS 编译成 JavaScript 最终在浏览器中运行和渲染。Compose Web 中预制了更贴近 HTML 风格的 Composable API,所以 UI 代码上与 Android/Desktop 无法直接复用。

通过 compose-jb 官方的例子,感受一下 Desktop & Web 的不同

​​github.com/JetBrains/c…​​

Compose 为什么可以跨平台?_Kotlin_08

上面使用 Compose 在各个平台实现的页面效果,Desktop 和 Android 的渲染效果完全一致,Web 与前两者在现实效果上不同,他们的代码分别如下所示:

Compose 为什么可以跨平台?_JetBrains_09

Compose Desktop 与 Jetpack Compose 在代码上没有区别,而 Compose Web 使用 Div,Ul 这样与 HTML 标签同名的 Composable,而且使用 style { ...} 这样面向 CSS 的 DSL 替代 Modifier,开发体验更符合前端的习惯。虽然 UI 部分的代码在不同平台有差异,但是在逻辑部分,可以实现完全复用,各平台的 Comopse UI 都使用 component.models.subscribeAsState() 监听状态变化。

Compose for Multiplatform

JetBrains 将 Android,Desktop,Web 三个平台的 Compose 整合成统一 Group Id 的 Kotlin Multiplatform 库,便诞生了 Comopse Multiplatform。

Compose 为什么可以跨平台?_Group_10

Compose Mutiplatform 作为一个 KM 库,让一个 KMP (Kotlin Multiplatform Project) 中可共享的代码从 Data 层上升到 UI 层以及 UI 相关的 Logic 层。

Compose 为什么可以跨平台?_JetBrains_11

使用 IntelliJ IDEA 可以创建一个 Compose Multiplatform 工程模版,在结构上与一个普通的 KMP 无异。

  • android/desktop/web 文件夹是各个平台的工程文件,基于 gradle 编译成目标平台的产物。
  • common 文件夹是 KMP 的核心。commonMain 中是完全共享的 Kt 代码,通过 expect/actual 关键字实现平台差异化开发。

Compose 为什么可以跨平台?_Kotlin_12

我们先在 gradle 中依赖 Comopse Multiplatform 库,之后就可以在 commonMain 中开发共享基于 Compose 的 UI 代码了。Comopse Multiplatform 的各个组件将 Jetpack Compose 对应组件的 Group Id 中的 androidx 前缀替换为 org.jertbrains 前缀:

androidx.compose.runtime -> org.jetbrains.compose.runtime
androidx.compose.material -> org.jetbrains.compose.material
androidx.compose.foundation -> org.jetbrains.compose.foundation

最后

Compose 为什么可以跨平台?_Android_13

最后,我们来思考一下 Compose for MultiplatformCompose Multiplatform 这两个词的区别?在我看来,Compose Multiplatform 会让家将焦点放在 Multiplatform 上面,自然会拿来与 Flutter 等同类框架作对比。但是通过本文的介绍,大家已经知道了 Compose 并非一个专门为跨平台打造的框架,现阶段它并不追求渲染效果和开发体验完全一致,它的出现更像是 Kotlin 带来的增值服务。

而 Compose for Multiplatfom 的焦点则更应该放在 Compose 上,它表示 Compose 可以服务于更多平台,依托强大的 Compiler 和 Runtime 层,我们可以为更多平台打造声明式框架。扩大 Kotlin 的应用场景和 Kotlin 开发者的能力边界。希望今后再提到 Compose 跨平台式,大家可以多从 Compose for Multiplatform 的角度去看待他的意义和价值。