查看原文
其他

使用KMP & Compose开发鸿蒙应用

XDMrWu 郭霖
2024-07-19



/   今日科技快讯   /


受益于人工智能(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 路径

为了简化这个流程,我开发了一个 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 介绍

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。项目地址:
https://github.com/Compose-for-OpenHarmony/HarmonyDom

我们采用类似 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 和鸿蒙两个平台,项目地址:
https://github.com/Compose-for-OpenHarmony/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 等,还未进行深入的研究和实践。

本文目的在于抛砖引玉,提供一种可能的方向,希望在鸿蒙的跨平台能力上能够给大家带来一些新的思路,也期待更多的开发者加入到这个探索中来,共同推动鸿蒙跨平台开发的进步。

推荐阅读:
我的新书,《第一行代码 第3版》已出版!
Android 14新特性,选择性照片和视频访问授权
简洁高效:Android视频列表设计思路

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注
继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存