前言
HarmonyOS NEXT 不再支持 AOSP,仅支持鸿蒙内核和鸿蒙系统的应用,各大 App 也纷纷投入到了原生鸿蒙应用的开发中。在此之前,主要的客户端平台为 Android 和 iOS,现在鸿蒙的加入已经改变了这个局面,开发者需要考虑的平台已经从原来的双端演变为三端。这无疑将增加研发的复杂性和成本,由此可以预见的是未来对于跨端代码复用的诉求将越发强烈。本文将介绍 KMP 在鸿蒙上的接入,并探索 Compose 在鸿蒙上应用的可能性。
KMP 初探
对于 Android 开发者来说最熟悉的技术栈莫过于 Kotlin, 如果可以基于 Kotlin 实现跨端开发,那么可以很大程度的降低学习成本并复用已有知识。Kotlin 本身是支持跨平台开发的,也就是 Kotlin Multiplatform(简称 KMP),本节将简单介绍 KMP 并探索在鸿蒙上的接入。
KMP 介绍
Kotlin Multiplatform 是 Kotlin 推出的跨平台开发方案,官方对它的介绍如下:
The Kotlin Multiplatform technology is designed to simplify the development of cross-platform projects. It reduces time spent writing and maintaining the same code for different platforms while retaining the flexibility and benefits of native programming.
从中可以看出 KMP 的主要优势在于跨平台复用代码的同时,可以保留 Native 开发的性能体验与灵活性,而之所以能够做到这一点主要依赖于 KMP 的实现原理。
目前大部分的跨端方案都是自建一套运行环境(虚拟机)并通过跨语言调用实现与 Native 的交互,如 JVM、Flutter 等。而 KMP 则是直接将 Kotlin 代码编译为目标平台的可执行代码,例如在 Android 平台上编译为 JVM 字节码、Web 上编译为 JS 等,目前 KMP 已经支持的平台如下图所示
得益于这种实现方式,基于 KMP 的代码复用粒度可以控制在非常小的范围,且与 Native 代码的交互也没有额外的开销,这使得我们可以渐进式的在现有项目中复用跨平台代码。
KMP 在鸿蒙上接入
关于 KMP 在鸿蒙上的使用,霍老师已经进行过比较全面的探索并在 B 站发布了讲解视频,本篇文章的内容也是受到该视频启发,推荐大家观看 Kotlin 多平台,但是鸿蒙_哔哩哔哩_bilibili
虽然鸿蒙的主要开发语言是 ArkTS ,但同时也支持使用 TS 和 JS 代码进行开发,并且 ArkTS 最终也会被编译为 TS 代码运行,所以从理论上讲我们可以基于 Kotlin/JS 在鸿蒙上实现 Kotlin 代码开发与复用。本节将借助一个简单的 Logger 工具类展示如何在鸿蒙上使用 KMP,Logger 功能定义如下:
- 提供一个用于打印日志的接口
- 提供一个开关用来控制是否输出日志
- 支持多平台,包括 Android、iOS、鸿蒙等
创建 KMP 项目
首先创建一个 KMP 项目,由于在鸿蒙上我们是基于 Kotlin/JS 进行开发所以在项目中增加 JS target,整体项目结构如图所示。 其中 commonMain 中存放多平台复用的代码,jsMain 中存放鸿蒙独有的代码。
然后在 build.gradle.kts 中配置 JS target
编写 Kotlin 代码
由于 Looger 是支持多平台的工具类,所以首先我们在 commonMain 中定义对外提供的 Looger 类,该类对外提供了三个方法,功能分别为
enable:打开 Logdisable:关闭 Loglog:打印 Log 而各平台输出 Log 的方式都不一样,所以这部分逻辑需要各平台单独实现,这里我们定义一个类PlatformLogger来表示各平台实现的 Logger 能力,并通过expect关键字来要求各平台单独提供实现。
@OptIn(ExperimentalJsExport::class)
@JsExport
object Logger {
private var enable = false
private val realLogger = PlatformLogger()
fun enable() {
this.enable = true
}
fun disable() {
this.enable = false
}
fun log(tag: String, msg: String) {
if (enable) {
realLogger.log(tag, msg)
}
}
}
expect class PlatformLogger {
constructor()
fun log(tag: String, msg: String)
}
接下来就是实现鸿蒙上的 Looger 能力,鸿蒙上是通过 HiLog 打印日志的,所以第一步是定义 HiLog 声明以便在 Kotlin 代码中使用 HiLog
@JsModule("@ohos.hilog")
external class HiLog {
companion object {
fun debug(domain: Number, tag: String, format: String, args: Array<Any>)
}
}
然后我们实现鸿蒙上的 PlatformLogger,通过 actual 关键字来声明 JS target 下 PlatformLogger 类的实现,而内部只是简单的调用了 HiLog 来打印日志
actual class PlatformLogger {
actual fun log(tag: String, msg: String) {
HiLog.debug(0, tag, msg, emptyArray())
}
}
编译成 JS 代码
通过执行 compileDevelopmentExecutableKotlinJs 任务可以将 Kotlin 代码编译为对应的 JS 代码,命令如下
./gradlew compileDevelopmentExecutableKotlinJs
编译的产物在 build/compileSync/js/main/developmentExecutable/kotlin 目录下
可以看到生成了 TS 类型声明文件 Kmp_Harmony.d.ts,这个文件声明了对外提供的接口类型,也就是我们的 Logger 以及它的三个方法
但是对应的 js 代码是以 .mjs 作为后缀的,鸿蒙无法识别这个后缀,所以我们通过下面两步对这些 mjs 文件进行修改
- 将
.mjs文件重命名为.js,将.mjs.map文件重命名为.js.map - 将 js 代码中 import 语句包含的 mjs 路径替换为对应的 js 路径
为了简化这个流程,我开发了一个 Gradle 插件自动对产物进行处理,接入方式如下所示
buildscript {
dependencies {
classpath "io.github.XDMrWu:harmony-plugin:1.0.0"
}
repositories {
mavenCentral()
google()
gradlePluginPortal()
}
}
plugins {
id("io.github.XDMrWu.harmony.js") // 引入插件
}
引入插件后会新增一个任务 compileDevelopmentExecutableHarmonyKotlinJs ,我们执行这个任务后即可在 build/harmony-js 目录下得到处理后的 js 代码
鸿蒙项目接入
将上一步生成的 harmony-js 目录 copy 到鸿蒙项目中即可使用 Logger,我们简单写一个界面来测试 Logger 的能力。
import { Logger } from '../harmony-js/Kmp_Harmony';
import promptAction from '@ohos.promptAction';
@Entry
@Component
struct Index {
build() {
Stack() {
Column() {
Row() {
Button("Enable Log")
.onClick(_ => {
Logger.getInstance().enable()
promptAction.showToast({
message: "Enable Log",
duration: 200
})
})
Button("Disable Log")
.onClick(_ => {
Logger.getInstance().disable()
promptAction.showToast({
message: "Disable Log",
duration: 200
})
})
}
Button("Print Log")
.onClick(_ => {
Logger.getInstance().log("HarmonyLogger", "Hello Kotlin Multiplatform")
})
}、
}
}
}
运行效果如下
Compose 适配
方案选型
对于客户端跨平台开发来说,仅支持逻辑代码复用还不足以满足需求,我们仍需要在 UI 层支持跨平台复用能力。Compose Multiplatform 是 Jetbrains 基于 KMP 和 Jetpack Compose 推出的跨平台响应式 UI 开发框架,支持 Android、iOS、Web 和 Desktop 等平台。它底层基于 Skia 实现跨平台 UI 渲染,而鸿蒙底层同样基于 Skia,所以理论上 Compose Multiplatform 是可以经过改造来支持鸿蒙系统的。
但受限于个人水平,改造 Compose Multiplatform 这条路暂未走通,那么是否还有其他方案可以实现呢?反观市面上的跨平台 UI 框架,从原理上可以分为自渲染和 Native UI 两类。Compose Multiplatform 属于前者也就是自渲染方案,如果自渲染的方式暂时无法实现,那么我们是否可以尝试一下 Compose + Native UI 的方式呢?
在 Compose 的架构设计中,只有上层的 UI 部分涉及到渲染、布局等逻辑,而 Compose Runtime 则与 UI 完全解耦,仅关注内部的状态管理等。得益于这种设计,我们完全可以基于 Compose Runtime 来定制上层的 UI 框架,实现 Compose 与 原生 UI 的结合。而 Redwood 就是这样一个库,下面会详细介绍一下这个库。
Redwood 介绍
PS:由于本文重点不是讲解 Redwood,所以本节只会大致介绍 Redwood 的工作原理,代码部分也采用伪代码形式
Redwood 是 Cash App 开发的跨平台 UI 库,它基于 Compose Compiler 和 Compose Runtime 实现了自定义 UI 树的构建与更新,并将这颗 UI 树在各个平台上映射为平台对应的原生 UI,整体的工作机制如下图所示
名词解释
- UI Schema:用于声明 UI 控件的一个 data class,包含控件的所有属性
- Widget:Redwood Compose 实际管理的 Node 类型
- Composable 方法:一个 @Composable 方法,用于使用方调用
Redwood 在编译期会基于提供的 UI Schema 生成对应的 Widget 类和 Composable 方法,以一个按钮控件为例,我们首先需要定义它的各个属性
data class Button(
val text: String,
val onClick: (() -> Unit)? = null,
)
基于上述定义的 UI Schema,Redwood 会生成以下的 Widget 和 Composable 方法
@Composable
fun Button(
text: String,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) { … }
interface Button<W : Any> : Widget<W> {
fun text(text: String)
fun onClick(onClick: (() -> Unit)?)
}
运行时调用 Button Composable 方法将在 Compose 的 Node Tree 上生成一个 Button Widget,各平台的 Button Widget 内部实现会桥接到平台对应的原生 UI,包含各个属性的变更。以 Android 平台为例,Button 实现如下
class AndroidButton(private val context: Context): Button<View> {
private val innerView = android.widget.Button(context)
override fun text(text: String?) {
innerView.text= text
}
override fun onClick(onClick: (() -> Unit)?) {
innerView.setOnClickListener {
onClick?.invoke()
}
}
}
Redwood 适配
思路
通过上述对 Redwood 的介绍可以发现,我们只需要实现一套鸿蒙平台的 Widget 即可在鸿蒙上使用 Redwood。但 ArkUI 作为一套声明式 UI,本身并不存在类似 DOM 的 UI 节点,我们无法直接将 Widget 桥接到 ArkUI 上。所以我们首先需要定义一套鸿蒙的 DOM,并实现 DOM 到 ArkUI 的转换能力,如下图所示
鸿蒙 DOM
HarmonyDom 已经在 Github 开源,基于 HarmonyDom 可以将 ArkUI 从响应式 UI 变为命令式 UI 项目地址:github.com/Compose-for…
我们采用类似 Android View 的设计,首先定义两个基础 DOM 类 BaseNode 和 BaseNodeGroup,用于表示没有子节点的 UI 节点和带子节点的 UI 节点
export class BaseNode {...}
export class BaseNodeGroup extends BaseNode {...}
还是以一个按钮控件为例,在鸿蒙上定义为一个 ButtonNode
export class ButtonNode extends BaseNode {
text: string = ""
clickBlock?: () => void
setText(text: string): void {
this.text = text
}
onClick(onClick: () => void) {
this.clickBlock = onClick
}
}
为了将 ButtonNode 映射为一个 Component,我们实现一个 ButtonBridgeView,该 Component 接收一个 ButtonNode 并将它的各个属性实现为 UI 属性
@Component
export struct ButtonBridgeView {
@ObjectLink buttonNode: ButtonNode
build() {
Button(this.buttonNode.text)
.onClick(_ => {
if (this.buttonNode.clickBlock) {
this.buttonNode.clickBlock()
}
})
}
}
为了在 ButtonNode 的属性变更时 ButtonBridgeView 可以及时响应,我们通过 @ObjectLink 的方式来接收 ButtonNode,并将 ButtonNode 通过 @Observed 装饰。需要注意的是,所有的方法调用都需要反应到属性变更上,否则无法更新 UI。
@Observed
export class ButtonNode extends BaseNode {...}
这时候我们还缺少一个将 ButtonNode 与 ButtonBridgeView 关联起来的地方,需要定义一个总的 Bridge 方法 createUIFromNode 用于为每个 Node 创建对应的 BridgeView,这样我们就可以通过调用 createNode 来创建 ArkUI,新增 Node 只需要在 createUIFromNode 方法中补充条件分支即可。
@Builder
export function createUIFromNode(node: BaseNode) {
if (node instanceof ButtonNode) {
ButtonBridgeView({buttonNode: node})
} else if (node instanceof XXXNode) {
XXXBridgeView({xxxNode: node})
} else if ....
}
Redwood Schema 实现
完成 HarmonyDom 设计后,为了能够在 KMP 项目中使用,我们需要提供一份 HarmonyDom 的 Kotlin 代码声明
@file:JsModule(DOM_PACKAGE)
package harmony.dom
public open external class BaseNode {
public var parentNode: BaseNodeGroup?
public fun setLayoutWeight(weight: Number)
public fun setWidthString(width: String)
public fun setWidth(width: Number)
public fun setHeightString(height: String)
public fun setHeight(height: Number)
public fun setPadding(left: Number?, top: Number?, right: Number?, bottom: Number?)
}
public open external class BaseNodeGroup: BaseNode {
public fun insert(index: Number, node: BaseNode)
public fun move(fromIndex: Number, toIndex: Number, count: Number)
public fun remove(index: Number, count: Number)
public fun clear()
}
public open external class ButtonNode: BaseNode {
public var text: String
public fun onClick(onClick: (() -> Unit)?)
}
还是以 Button 为例子,我们基于 HarmonyDom 实现鸿蒙平台上的 Button 控件
import harmony.dom.BaseNode
import harmony.dom.ButtonNode
public class HarmonyButton: Button<BaseNode> {
private val innerNode = ButtonNode()
override fun text(text: String?) {
innerNode.text = text ?: ""
}
override fun onClick(onClick: (() -> Unit)?) {
innerNode.onClick {
onClick?.invoke()
}
}
}
其他的控件实现方式和 Button 类似,完成所有控件的定义与各平台实现后就基本上可以实现基于 Redwood 的跨平台 UI 开发。这部分代码已经在 Github 开源,目前支持 Android 和鸿蒙两个平台,项目地址:compose-ez-ui
Demo 演示
为了演示 compose-ez-ui 的效果,本节将会基于该库实现一个仿微博列表的组件,并在鸿蒙和 Android 平台上运行,具体的代码在项目
samples/weibo目录下可以找到。
首先我们基于现有的控件实现 WeiboCard,用于展示一条微博的样式
@Composable
fun WeiboCard(weiboModel: WeiboModel) {
Column {
Row(padding = Padding(10.dp, 10.dp, 10.dp, 10.dp)) {
Image(weiboModel.avatar_url, Length(40.dp), Length(40.dp), circle = true)
Column(padding = Padding(10.dp), modifier = Modifier.weight(1f)) {
Text(weiboModel.user_name, fontSize = 16.dp, fontColor = Color.Orange)
Row {
Text(weiboModel.created_at, fontSize = 10.dp, fontColor = Color.Grey)
Text(" | ", fontSize = 10.dp, fontColor = Color.Grey)
Text(weiboModel.source, fontSize = 10.dp, fontColor = Color.Grey)
}
}
}
Text(weiboModel.text, maxLines = 5, padding = Padding(start = 10.dp, end = 10.dp), spans = weiboModel.createTextSpans())
// TODO Grid
Image(weiboModel.pics.split(",").first(), Length(200.dp), Length(200.dp), padding = Padding(10.dp, 10.dp, 10.dp, 10.dp))
Row(width = Length.Fill, padding = Padding(bottom = 5.dp)) {
Text("转发 ${weiboModel.reposts_count}", fontSize = 15.dp, fontColor = Color.Grey, textCenter = true, modifier = Modifier.weight(1f))
Text("评论 ${weiboModel.comments_count}", fontSize = 15.dp, fontColor = Color.Grey, textCenter = true, modifier = Modifier.weight(1f))
Text("点赞 ${weiboModel.attitudes_count}", fontSize = 15.dp, fontColor = Color.Grey, textCenter = true, modifier = Modifier.weight(1f))
}
Divider(Length.Fill, Length(5.dp), Color.LightGrey)
}
}
接下来实现一个 WeiboVM 负责数据的获取,这里我们手动延迟 1 秒模拟请求耗时
class WeiboVM {
var isLoading by mutableStateOf(true)
var weiboList = mutableStateListOf<WeiboModel>()
private val json = Json { ignoreUnknownKeys = true }
suspend fun fetchWeiboList() {
isLoading = true
delay(1000)
val response = json.decodeFromString<WeiboResponse>(WeiboRepo.fakeData)
response.weibo.forEach {
it.user_name = response.user.screen_name
it.avatar_url = response.user.profile_image_url
}
weiboList.addAll(response.weibo)
isLoading = false
}
}
最后实现 WebiList ,基于 WebiVM 和 WeiboCard 展示 Loading UI 与微博列表
@Composable
fun WeiboList() {
val vm = remember { WeiboVM() }
LaunchedEffect(Unit) {
vm.fetchWeiboList()
}
if (vm.isLoading) {
Text("Loading", width = Length.Fill, height = Length.Fill, textCenter = true)
} else if (vm.weiboList.isEmpty()) {
Text("Empty", width = Length.Fill, height = Length.Fill, textCenter = true)
} else {
com.compose.ez.ui.compose.List {
vm.weiboList.forEach {
WeiboCard(it)
}
}
}
最后我们将 WeiboList 运行在 Android 和鸿蒙平台上,效果如下所示(左Android、右鸿蒙)
结语
本文详细探讨了在鸿蒙系统上接入 KMP 以及使用 Compose 的可能性,并在此基础上产出了两个库:HarmonyDom 和 compose-ez-ui,以实现在鸿蒙系统上使用 Compose。然而,这仍然是一项探索性的工作,对于跨平台 UI 的许多重要方面,如动画、手势、平台能力 API 等,还未进行深入的研究和实践。
本文目的在于抛砖引玉,提供一种可能的方向,希望在鸿蒙的跨平台能力上能够给大家带来一些新的思路,也期待更多的开发者加入到这个探索中来,共同推动鸿蒙跨平台开发的进步。