一、 背景
在 2023 年的华为开发者大会(HDC)上,华为预告了一个全新的鸿蒙系统 Harmony Next 版本。与之前的鸿蒙系统不同,Harmony Next完全摒弃了对 AOSP 的兼容,彻底基于 OpenHarmony 开源鸿蒙实现。这意味着该系统将仅支持鸿蒙原生应用,Android 应用将不再允许在其上运行。
华为用户在哔哩哔哩的用户生态中一直占据着较大的比例。为了提供更好的用户体验,支持更多的应用生态,哔哩哔哩在去年年底启动了哔哩哔哩鸿蒙原生应用的开发。在对 Harmony Next 系统进行初步调研后,我们发现其从开发语言到运行环境到开发方式,都与 Android 平台完全不同。适配 Harmony Next 就意味着重新开发一个独立的 App 端,无论是短期开发还是长期迭代,这都是一件成本极高的事情。于是我们面临一个问题:是否有跨平台开发的手段来复用现有生态的代码,从而减少开发成本?
二、 方案选择
移动端的跨平台技术一直以来都是热门话题,原因在于移动端的原生开发存在着以下的几个问题:
-
编码成本高:相同的业务逻辑需要在多端(iOS,Android 以及未来的 HarmonyOS)用不同的语言各自实现一遍。受限于各端编程语言及系统 API 的差异,业务逻辑逻辑难以做到完全一致。同样的业务逻辑仅靠口头上或文档上的方案对齐,导致最终代码实现上可能会有较大差别。这种业务逻辑实现的差异也会使多端体验上存在较大差别,数据不一致性导致难以解释其合理性(交互体验、收益回顾)。
-
后期迭代维护、测试成本高:UI 代码没有很好地和业务逻辑代码解耦合,导致业务逻辑代码复用困难,不方便做单元测试,组件间的循环依赖增多。业务逻辑变更需要需要各端研发对齐并各自实现,这无意间也增加了大量隐性的沟通成本。测试的回归验证也需要在各端分别完成。
从研发成本的角度看,研发希望以更低的成本在多个平台上发布应用程序;采用一套代码可以减少多端业务逻辑的差异,提升多端体验的一致性。
2.1、什么是 Kotlin Multiplatform?
Kotlin Multiplatform(以下简称 KMP) 是由 JetBrains 开发的基于 Kotlin 语言的跨平台开发解决方案。KMP 允许开发者使用一套 Kotlin 代码来构建适用于多个平台的应用程序,包括移动端应用、前端、后端服务和嵌入式系统等。
2.2、KMP 实现原理
KMP 基于 Kotlin K2 编译器,采用多阶段编译架构,其核心包括编译前端和编译后端两个部分:
- 编译前端:
-
语法解析:将 Kotlin 源代码解析成抽象语法树(AST)。
-
语义分析:对 AST 进行语义检查,确保代码符合 Kotlin 语言规范,并进行类型推断和检查。
-
中间表示(IR)生成:将 AST 转换为中间表示,便于不同编译后端进行进一步处理。
- 编译后端:
-
JVM 后端:将中间表示转换为 JVM 字节码,以运行在 JVM 平台上(如 Android)。
-
Native 后端:将中间表示转换为 LLVM bitcode,再通过 LLVM 工具链生成适用于不同平台(如 iOS、Linux、Windows 等)的本地二进制代码。
-
JavaScript 后端:将中间表示转换为 JavaScript 代码,以运行在浏览器或 Node.js 环境中。
-
WebAssembly 后端:将中间表示转换为 WebAssembly 代码,以在浏览器或其他支持 WebAssembly 的环境中运行。
KMP 通过多平台模块(Multiplatform Modules)来组织跨平台代码。多平台模块由以下三种部分组成:
- Common Module:
-
包含跨平台的共享代码和 API 定义。
-
使用 expect 关键字声明平台特定的 API 接口。
- Platform-specific Module:
-
实现各个平台特定的代码和 API。
-
使用 actual 关键字实现 expect 声明的接口。
- Intermediate Module(可选):
- 提供中间层,包含部分跨平台代码和部分平台特定代码,用于简化平台特定模块的实现。
expect / actual 机制是 KMP 实现平台特定代码的关键。它允许在 common 模块中声明平台特定的接口,并在各个平台特定模块中提供具体实现。
-
expect:在 common 模块中声明需要在不同平台实现的接口或类。
// commonMain
expect val Platform: String -
actual:在平台特定模块中提供具体实现。
// androidMain
actual val Platform: String
get() = "Android"// iosMain
actual val Platform: String
get() = "iOS"
KMP 支持在 common 模块中编写跨平台通用代码,并在平台特定模块中复用这些代码。通过 expect / actual 机制,开发者可以在 common 模块中定义接口和公共逻辑,在平台特定模块中实现特定平台的功能。
2.3、为什么选择 KMP?
跨平台开发一直以来存在以下几个问题:
-
平台差异性:不同平台的接口、UI 界面的开发方式和工程架构存在极大差异,开发者需要处理这些差异以保证代码在不同平台上正常运行。
-
学习及集成难度:开发者需要学习理解不同的开发环境和相关工具链,学习曲线较为陡峭,快速上手难度较大。
目前行业内使用率最高的两个跨平台方案是 React Native 和 Flutter。虽然它们在设计及原理上有很大区别,但为了减小平台差异性带来的问题,它们在设计思想上都是在平台框架之上搭建一个自己的 Runtime 环境,并采用非原生开发语言开发,与原生开发语言交互时需要编写特殊的桥接层来实现。当所依附平台框架发生变更时,需要双方模块架构共同协调处理。
而 KMP 则与这种设计思路不同,它并不会创建一个自己的运行环境,而是将 Kotlin 代码编译为平台对应语言的代码或机器码,让平台可以直接执行。从这个角度看,React Native 和 Flutter 更适合包含 UI 层的跨平台开发,而 KMP 则更适合纯逻辑层的跨平台开发。
对于鸿蒙 App 来说,我们的需求是尽量复用逻辑层的代码,UI 层更多的跟随原生,以提供更好的性能和设备适配性,较低的上手学习成本。因此,我们最终选择了 KMP 来进行跨平台尝试。
三、 KMP 在鸿蒙 App 中的应用
我们先来看一下鸿蒙支持哪些语言进行原生开发。鸿蒙官方推荐的开发方式使用的主要开发语言是 ArkTS,同时也支持 JavaScript、TypeScript 开发代码逻辑,并且以 napi 的形式提供了与 C 的互操作能力。所以理论上只要能通过 Kotlin 生成 JS 或者 C 的编译产物,都可以实现使用 Kotlin 代码开发鸿蒙。
前面已经提到过,本着给用户带来最优的交互体验,哔哩哔哩鸿蒙原生应用使用官方推荐的 ArkUI 来实现,而 ArkUI 使用的是 ArkTS 作为开发语言。ArkTS 支持与 JS / TS 高效的互操作,这使我们很容易想到将 Kotlin 代码编译成为 JS,无缝的衔接到整个 ArkTS 的生态中。
3.1、Kotlin 与 JavaScript 的数据结构
下面的表格是 Kotlin 类型在 JS 中的映射。
我们可以看到,在 Kotlin 和 JS 里,大部分常用的类型都能找到对应的映射关系。
但是,Kotlin 作为一门静态类型的语言,光有上面的这些类型,还是难以满足其与一些动态类型语言的互操作能力。
3.2、Dynamic 类型
Kotlin 中的 dynamic 类型主要用于与动态语言(尤其是 JavaScript)进行互操作。使用 dynamic 类型,Kotlin 代码可以与没有静态类型定义的 JavaScript 代码交互。这在使用没有类型定义的 JavaScript 库和 API 时尤其有用。
主要特点:
-
无类型检查:使用 dynamic 时,Kotlin 在编译时不会进行类型检查。所有类型检查都推迟到运行时。
-
互操作性:dynamic 类型主要用于 Kotlin/JS,允许将 Kotlin 编译成 JavaScript 并在 JavaScript 环境中运行。
-
灵活性:可以在 dynamic 对象上调用方法和访问属性,而无需编译时的类型安全。
fun whatIsDynamic(dyn: dynamic) {
dyn.doSomething()
println(dyn.someProp)
}
在上述示例中:
-
在 dyn(类型为 dynamic)上调用 doSomething()。
-
访问 dyn 的 someProp 属性,无编译时类型检查。
注意事项:
-
安全性:由于 dynamic 绕过了 Kotlin 的类型系统,因此应谨慎使用。不当使用可能导致运行时错误。
-
性能:由于类型检查在运行时进行,与静态类型的代码相比,使用 dynamic 会影响性能。
-
代码维护:使用 dynamic 的代码由于缺少类型信息,可能更难维护和理解。
再看下面这个例子:
@JsExport
fun isDynamic() {
val dyn: dynamic = js("{}")
dyn["a"] = "A"
console.log(JSON.stringify(dyn.a))
}
这是 Kotlin 代码。
function isDynamic() {
var dyn = {};
dyn['a'] = 'A';
console.log(JSON.stringify(dyn.a));
}
这是编译生成的 JS 代码。
可以看到通过 dynamic 类型,Kotlin 可以很轻松的与 JavaScript进行交互。
3.3、Kotlin 协程
Kotlin 协程是一种用于简化异步编程的强大工具。它们能够使异步代码看起来像同步代码,从而提高代码的可读性和可维护性。
使用协程的优势:
-
简化异步代码:将回调和异步任务简化为顺序代码,使代码更加直观。
-
提高可读性:消除回调地狱,代码结构更加清晰。
-
便于错误处理:使用标准的 try/catch 块处理异常,而不是在每个回调中处理错误。
在 JS 中,Promise 是 ES6 引入用于处理异步操作的工具。在 Kotlin/JS 中,Kotlin 协程可以与 JS 的 Promise 无缝操作,这使得无论是 Kotlin 调用 JS 的异步操作,还是 JS 调用 Kotlin 的异步操作都变得非常的简单。
Promise -> suspend fun
通过 Promise 的扩展方法 await,可以很方便的将一个 JS Promise 转化成 Kotlin 的 suspend fun。
suspend fun fetchDataFromJS(): String {
val promise = js("fetchSomeData()") as Promise<String>
return promise.await()
}
上图就是一个简单的从 Kotlin 调用 JS 的 fetchSomeData() 的异步方法,并将其转化为一个 suspend fun 的例子。
suspend fun -> Promise
通过 CoroutineScope 的扩展方法 promise,可以很方便的将一个 suspend fun 转化成 JS Promise。
suspend fun fetchData(): String {
delay(1000) // 模拟异步操作
return "Hello from Kotlin/JS!"
}
@JsExport
fun fetchDataAsPromise(): Promise<String> {
return MainScope().promise {
fetchData()
}
}
上图就是一个简单的例子,Kotlin 中模拟实现一个用协程完成的异步操作,并将其转为 JS Promise,同时将这个 fetchDataAsPromise 导出。
export declare function fetchDataAsPromise(): Promise<string>;
这个就是 Kotlin 编译生成的 .d.ts 声明文件中刚才导出的 fetchDataAsPromise。
function fetchDataAsPromise() { var tmp = MainScope(); return promise(tmp, VOID, VOID, fetchDataAsPromise$slambda_0(null));}
function fetchDataAsPromise$slambda_0(resultContinuation) { var i = new fetchDataAsPromise$slambda(resultContinuation); var l = function ($this$promise, $completion) { return i.invoke_t04clr_k$($this$promise, $completion); }; l.$arity = 1; return l;}
这个就是 Kotlin 编译生成 JS 中 fetchDataAsPromise 的实现,可以看到因为有使用到协程,整个编译出来的 JS 产物还是比较晦涩难懂的,这里就不展开分析了。
3.4、如何在 Kotlin 代码中使用鸿蒙 API
Harmony OS SDK 通过 .d.ts 的声明文件向外提供了系统 API。
如果想要在 Kotlin 代码中直接使用系统 API,需要将 .d.ts 的声明文件转化为 Kotlin 的声明文件。
external关键字
通过使用 external 关键字来声明在外部环境中定义的函数。
我们以 hilog 为例:
这是 hilog 的 .d.ts 的声明。
这是对应转化成的 Kotlin 的声明。
可以看到,通过 @JsModule 来制定这个该 Kotlin 声明对应的是 @ohos.hilog 模块,模块中的 hilog namespace 通过 external object 来声明。
Kotlin 官方提供了 Dukat 工具(*https://github.com/Kotlin/dukat*)帮助我们方便快速的进行 .d.ts 到 Kt 的转换。
同时还有一些三方工具,如 karakum(*https://github.com/karakum-team/karakum*),也提供了将 .d.ts 转换成 Kt 的能力。
在实际的使用过程中,由于目前 Jetbrains 官方团队并没有精力放在 Dukat 上,在 ESModule 的模式下几乎不可用,导致我们最后选择使用 karakum 来进行 Harmony OS API 的自动生成。
karakum 目前对 .d.ts 声明中的一些边界问题处理的比较糟糕,所以当前,我们还需要对生成的 Kt 手动的做一些边界 case 的处理:
-
karakum夹带了一些私货 seskar(*https://github.com/turansky/seskar*) ,因此我们需要对 seskar 中所有的sugar进行注释操作。
-
未知类型的处理,例如 Object, BigInt Readonly等。karakum 使用了私有的这些对象的定义,我们需要擦除类型改为Any。
-
部分类型例如 Uint8Array 的 import 缺失 Kotlin import org.khronos.webgl.*。
-
typealias 的处理,由于 Kotlin/JS Module 并不支持 external 中内嵌 typealias,karakum默认实现是会定义在对应的 .d.ts 的层级里,我们需要把 typealias 提到当前文件顶层。
-
带默认值的范型处理,在 TypeScript 中可以对范型定义默认类型例如 export interface AsyncCallback,但是 Kotlin 中并没有对应的能力,所以我们需要把范型根据范型个数展开,并且在使用的地方进行替换。
-
由于 Kotlin 映射了 Error 和 Throwable 的关系,同时 Kotlin 的 Error 是具体的 type,所以我们需要对 Error 进行特殊处理,擦除 Error 的类型,并实现辅助方法来通过 dynamic 获取到对应的属性。
-
在同时使用 union-types 和 literal-types 时,例如 on(type: 'connect' | 'close'): void; 由于 Kotlin 没有联合类型会导致生成 duplicate 的方法定义。
-
与kotlin built-in 冲突,例如 fun toString(): String 需要修改成 override toString(): String。
最终的流程如下:
3.5、如何在鸿蒙 App 使用 Kotlin 代码
KMP 在 鸿蒙版 bilibili 的开发流程大致如下图所示:
-
首先找到需要使用的 API 的声明文件,并通过 karakum将其转化为 .kt 的声明,导入 KMP 工程。
-
编写 Kotlin 逻辑代码,并通过 @JsExport 暴露鸿蒙项目所以要使用的接口。
-
通过 Kotlin/JS IR compiler 将 KMP 项目中的 kotlin 代码编译成为 .js + .d.ts。
-
将编译产物 .js + .d.ts 导入鸿蒙项目中。
-
在鸿蒙项目中就可以愉快的使用 @JsExport 导出的相关代码了。
目前鸿蒙版 bilibili 架构大致如下图:
其中 Framework 层大部分代码均使用 KMP 实现,包括但不限于上图列出的模块。通过使用 KMP 使得大量的基础框架逻辑代码得以在多端复用,从而提升了大量开发效率,并降低了后期的维护成本。
3.6、遇到的一些问题
调试成本有所上升
虽然 DevEco Studio 支持对 js 文件的断点调试功能,但是由于没有办法将 js 代码直接映射到 kt 代码上,这就会增加一些调试的成本。同时,在使用了 kotlin 协程后,KMP 生成的 js 代码会变得异常的"难懂"。
目前更多的只能通过日志大法,和 js 断点来进行调试,虽然没有直接调试 kt 代码来的直观,但是也能够满足日常的开发需要。
三方库的一些坑
在使用一些三方库(诸如 Ktor 等)的时候,发现大部分的 KMP 项目中的 jsMain 默认是被运行在 Node 或者 Browser 环境中的,这就导致了一些三方库的实现代码中,会去访问一些诸如 Window、TextEncoder、TextDecoder 等只有在 Web 或 Node 环境特有的 API。而这些 API 在鸿蒙环境中是不存在的。直接在鸿蒙项目中引入这些三方库,可能在运行时会产生一些非预期的错误。
对于这个问题目前的解决办法是检查三方库的 jsMain 中是否使用了一些鸿蒙环境中不支持的 API,看看有没有办法可以尽可能的不使用这些 API,或者将这些 API 替换成为鸿蒙支持的 API。
编译出的 .js 文件过大
目前 KMP 项目中所有的代码都会导出到一个 .js 文件中,这就使得整个 .js 产物变得比较大,整个产物有 10m 多。
而目前 Ark runtime 在执行超大 js 文件时,可能存在一些性能偏慢的情况。
Kotlin 代码难以使用 ArkTS 提供的多线程能力
Ark runtime 的多线程主要是通过 Actor 并发模型实现的,线程间内存不共享,通过序列化和反序列化消息的方式进行线程间的同步。
ArkTS 虽然也提供了基于 Sendable 协议的可共享对象体系,但也仅能在 .ets 文件中使用,难以在 KMP 中实现基于 Sendable 协议的数据类型。
这就导致 KMP 代码在处理一些 CPU 密集的逻辑时,会产生一定的卡顿。
3.7、有没有更好的办法
在上文中提到了,虽然鸿蒙官方推荐使用 ArkTS 作为鸿蒙 APP 的开发语言,但本身鸿蒙也支持使用 Native (C / C++)进行开发。如果将 KMP 代码直接编译生成为 Native 的产物,是不是上面提到的几个问题就会变得比较容易解决。
Native 代码的运行不依赖 Ark runtime,这也就减少了由 Ark runtime 所带来的一些额外的性能开销。
同时在前文提到的多线程问题,在 Native 的世界里也将变得非常容易处理,无论是使用鸿蒙 NDK 中提供的 libuv、ffrt,亦或是直接使用 posix 的 pthread,都可以很轻易的实现多线程异步处理逻辑。
关于 Kotlin Native 这部分的进展,目前已经实现了基于 Kotlin 2.0 的 KN Compiler 对 Harmony Target 的支持。同时也完成了部分 kotlinx 中部分 library (如 coroutines 等)在鸿蒙平台上的适配。相信在不久的将来就可以正式投入实际生产过程中了。
四、 总结
到目前为止,我们已完成了视频内容生态基础体验相关的开发,并发布至鸿蒙原生应用 Beta 市场。欢迎有资格的同学下载体验。
通过这次实践,我们验证了 Kotlin Multiplatform 在鸿蒙应用开发中的可行性。它不仅实现了代码的跨平台复用,降低了开发成本,还提升了开发效率,为我们提供了一个高效的开发工具链。未来,我们将继续探索和优化这一方案,为用户带来更优质的应用体验。
-End-
作者丨Vicky的饲养员、狒狒