使用KMP & Compose开发鸿蒙应用
/ 今日科技快讯 /
受益于人工智能(AI)和高性能计算(HPC)前所未有的市场需求,英伟达过去几个季度的营收保持了持续的大幅度成长。其中,数据中心业务成为了最大的亮点,以往并驾齐驱的游戏业务在营收上被越抛越远。
根据市场研究机构TechInsights的最新数据显示,在2023年第四季度,英伟达的半导体销售金额增长了23%。这样的表现,使得英伟达超过了台积电,成为了全球最大的半导体供应商。
/ 作者简介 /
本篇文章来自XDMrWu的投稿,文章主要分享了鸿蒙 KMP & Compose 探索,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。
XDMrWu的博客地址:
https://juejin.cn/user/395479914649933/posts
/ 前言 /
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。视频地址:
https://www.bilibili.com/video/BV16Q4y1g7Yp
虽然鸿蒙的主要开发语言是 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:打开 Log
disable:关闭 Log
log:打印 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 路径
dependencies {
classpath "io.github.XDMrWu:harmony-plugin:1.0.0"
}
repositories {
mavenCentral()
google()
gradlePluginPortal()
}
}
plugins {
id("io.github.XDMrWu.harmony.js") // 引入插件
}
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")
})
}、
}
}
}
对于客户端跨平台开发来说,仅支持逻辑代码复用还不足以满足需求,我们仍需要在 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 就是这样一个库,下面会详细介绍一下这个库。
UI Schema:用于声明 UI 控件的一个 data class,包含控件的所有属性 Widget:Redwood Compose 实际管理的 Node 类型 Composable 方法:一个 @Composable 方法,用于使用方调用
val text: String,
val onClick: (() -> Unit)? = null,
)
fun Button(
text: String,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) { … }
interface Button<W : Any> : Widget<W> {
fun text(text: String)
fun onClick(onClick: (() -> Unit)?)
}
private val innerView = android.widget.Button(context)
override fun text(text: String?) {
innerView.text= text
}
override fun onClick(onClick: (() -> Unit)?) {
innerView.setOnClickListener {
onClick?.invoke()
}
}
}
https://github.com/Compose-for-OpenHarmony/HarmonyDom
export class BaseNodeGroup extends BaseNode {...}
text: string = ""
clickBlock?: () => void
setText(text: string): void {
this.text = text
}
onClick(onClick: () => void) {
this.clickBlock = onClick
}
}
export struct ButtonBridgeView {
@ObjectLink buttonNode: ButtonNode
build() {
Button(this.buttonNode.text)
.onClick(_ => {
if (this.buttonNode.clickBlock) {
this.buttonNode.clickBlock()
}
})
}
}
export class ButtonNode extends BaseNode {...}
export function createUIFromNode(node: BaseNode) {
if (node instanceof ButtonNode) {
ButtonBridgeView({buttonNode: node})
} else if (node instanceof XXXNode) {
XXXBridgeView({xxxNode: node})
} else if ....
}
完成 HarmonyDom 设计后,为了能够在 KMP 项目中使用,我们需要提供一份 HarmonyDom 的 Kotlin 代码声明。
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)?)
}
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()
}
}
}
https://github.com/Compose-for-OpenHarmony/compose-ez-ui
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)
}
}
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
}
}
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)
}
}
}
本文目的在于抛砖引玉,提供一种可能的方向,希望在鸿蒙的跨平台能力上能够给大家带来一些新的思路,也期待更多的开发者加入到这个探索中来,共同推动鸿蒙跨平台开发的进步。