Compose 为什么可以跨平台?

简介: Compose 为什么可以跨平台?

前言

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

Compose Architecture Layers

image.png

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

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

Compose Compiler

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

image.png

左边的源码是一个非常简单的 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 节点上的状态。


image.png

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。

image.png

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 负责完成。

image.png

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++
                }
            )
        }
    }
}

执行效果如下:

image.png

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

Compose for Desktop & Web

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

image.png

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…

image.png

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


image.png

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。

image.png

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

image.png

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

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

image.png

我们先在 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

最后

image.png

最后我们来思考一下 Compose for MultiplatformCompose Multiplatform 这两个词有什么区别?

Compose Multiplatform 强调了 Compose 的跨平台特性,但是通过本文的介绍,我们了解了 Compose 并非为跨平台所生,现阶段也不追求 Flutter 那种不同平台上渲染效果和开发体验得完全一致。Compose 的跨平台更多的是 Kotlin 语言带来的 Bonus。

Compose for Multiplatfom 表示 Compose 具备服务更多平台的能力,借助其 Compiler 和 Runtime 层平台无关的设计让声明式 UI 惠及到更多地方。希望今后再提到 Compose 跨平台的时候,大家可以多从 For Multiplatform 的角度去思考它的价值。

目录
相关文章
|
6月前
|
开发者 Docker Python
深入浅出:使用Docker容器化部署Python Web应用
在当今快速发展的软件开发领域,Docker作为一个开放平台,为开发者提供了将应用打包在轻量级、可移植的容器中的能力,从而简化了部署和管理应用程序的复杂性。本文将通过一个简单的Python Web应用示例,引导读者理解Docker的基本概念、容器化的优势以及如何使用Docker来容器化部署Python Web应用。我们将从零开始,逐步探索创建Dockerfile、构建镜像、运行容器等关键步骤,旨在为读者提供一个清晰、易于理解的指南,帮助他们掌握使用Docker容器化部署应用的技能。
|
6月前
|
存储 虚拟化 数据中心
Docker容器化应用程序的入门指南
【4月更文挑战第28天】
475 0
|
3月前
|
Kubernetes 开发者 Docker
Docker技术概论(8):Docker Desktop原生图形化管理(二)
Docker技术概论(8):Docker Desktop原生图形化管理(二)
102 2
|
4月前
|
安全 关系型数据库 开发者
Docker Compose凭借其简单易用的特性,已经成为开发者在构建和管理多容器应用时不可或缺的工具。
Docker Compose是容器编排利器,简化多容器应用管理。通过YAML文件定义服务、网络和卷,一键启动应用环境。核心概念包括服务(组件集合)、网络(灵活通信)、卷(数据持久化)。实战中,编写docker-compose.yml,如设置Nginx和Postgres服务,用`docker-compose up -d`启动。高级特性涉及依赖、环境变量、健康检查和数据持久化。最佳实践涵盖环境隔离、CI/CD、资源管理和安全措施。案例分析展示如何构建微服务应用栈,实现一键部署。Docker Compose助力开发者高效驾驭复杂容器场景。
70 1
|
5月前
|
缓存 Linux Docker
docker 跨平台构建镜像
docker 跨平台构建镜像
123 0
|
Kubernetes 虚拟化 开发者
入门指南:使用Docker轻松管理应用程序部署
在现代软件开发领域,应用程序的部署和管理变得越来越复杂。不同的运行环境、依赖关系以及配置差异,都可能导致部署过程变得棘手。幸运的是,Docker这一强大的容器化技术,为解决这些挑战提供了一种简单而高效的方法。本文将介绍Docker的基本概念、优势,以及如何使用它来轻松管理应用程序部署。
437 0
|
6月前
|
NoSQL Redis Docker
深入浅出:使用Docker容器化改进Python应用部署
在快速演进的软件开发领域,持续集成和持续部署(CI/CD)已成为加速产品上市的关键。本文将探索如何利用Docker,一种流行的容器化技术,来容器化Python应用,实现高效、可靠的部署流程。我们将从Docker的基本概念入手,详细讨论如何创建轻量级、可移植的Python应用容器,并展示如何通过Docker Compose管理多容器应用。此外,文章还将介绍使用Docker的最佳实践,帮助开发者避免常见陷阱,优化部署策略。无论是初学者还是有经验的开发人员,本文都将提供有价值的见解,助力读者在自己的项目中实现容器化部署的转型。
|
存储 API Docker
【Docker】使用 Docker 和 Streamlit 构建和部署 LangChain 支持的聊天应用程序
【Docker】使用 Docker 和 Streamlit 构建和部署 LangChain 支持的聊天应用程序
|
前端开发 JavaScript 安全
WebAssembly 能否取代 Docker?
“如果 WebAssembly(Wasm)在几年前出现,Docker 可能就不会出现了。因为它是一项非常强大的跨平台技术,可以让我们使用不同的编程语言来编写跨平台应用程序。Docker 的原始动力之一就是提供一个跨平台部署和应用的方法。” -- Solomon Hykes ( Docker 的创始人之一)
436 0
|
缓存 Kubernetes 前端开发
使用 buildx 构建跨平台镜像
使用 buildx 构建跨平台镜像
958 0