作者简介
禹昂,携程移动端资深工程师,Kotlin 中文社区核心成员,图书《Kotlin 编程实践》译者。
Derek,携程资深研发经理,专注于移动端开发,热衷于各种跨端技术的研究和实践。
一. 背景与选型
移动端跨平台技术自移动开发诞生以来一直是个热门话题,一是持续关注研发效率,降本提效;二是一套代码多端运行可以提升多端业务逻辑的一致性;三是跨端技术方案通常意味着更佳的高效运维和缺陷修复。
跨平台开发框架经过多年的发展,目前被行业采用率最广的应属 Facebook 的 React Native,而当前最被大家寄与厚望的则是 Google 的 Flutter。这两者虽然在设计及原理上区别很大,但设计思想上都是采用非原生开发语言在 Android 与 iOS 系统框架之上搭建的“阁楼”上运行,每个采用这些框架的 App 在打包时需要集成语言的 Runtime、框架的底层组件等许多重量级的包与库。并且 JavaScript 或 Dart 与原生开发语言(Java/Kotlin、Objective-C/Swift)之间的交互需要通过“桥接通讯”实现,导致每当需要系统框架层面的改动支持时,必须双方模块架构上共同协调处理。
作为移动端开发人员,我们希望找到一种性能与原生代码相媲美、与原生代码互操作能力强、开发思想与原生开发接近的跨平台开发框架。
JetBrains 提出了不同于 RN 与 Flutter 的跨端解决方案,即使用不同的编译器编译同一份代码生成各端的不同产物来达到跨平台的目的,这就是 Kotlin Multiplatform。Kotlin 依据其运行的平台不同拥有不同的名字,例如编译为 class 字节码运行于 JVM 及 Android 平台的称为 Kotlin/JVM,编译为原生二进制码无虚拟机环境直接运行于操作系统上的则称为 Kotlin/Native,此外还有 Kotlin/JS 等等(关于 Kotlin Multiplatform 的官方介绍请详见参考链接 1)。
Kotlin 在不同平台均可与该平台的原生开发语言直接相互调用,在 Android 平台 Kotlin 是官方支持的一等开发语言,与 Java 的互操作自不用说。而在 Kotlin/Native 中 Kotlin 也可以像与 Java 互操作般在 iOS 平台直接与 C 以及 Objective-C 代码互操作(函数、类、接口互相可见、基本类型与集合类型等可互相映射)。不过其他语言如 Swift 与 Kotlin/Native 的互操作能力较为受限,官方正逐步改进。
Kotlin 在移动端的跨平台框架子集叫做 Kotlin Multiplatform Mobile,简称为 KMM。KMM 的架构设计理念如下图所示:
开发人员编写的代码主要分为三个 source set(源集),其中与平台直接交互的代码位于以平台命名的 source set 中,例如在 Android source set 中的 Kotlin 代码可以调用 JDK、Android SDK、以及其他 Android/Java 开源库,而在 iOS source set 中的 Kotlin 代码则可以直接调用 iOS 平台支持的 Posix C API、Foundation、以及其他 C/Objective-C 开源库。两端通用代码则位于 Common source set。
整个工程的构建由 Gradle 驱动,在编译打包时,通过将 Common 与 Android 两个 source set 的 Kotlin 代码合并编译打包为 Android 平台产物(aar 文件)。而将 Common 与 iOS 两个 source set 的 Kotlin 代码合并编译打包为 iOS 平台的产物(framework 文件)。
与 RN 及 Flutter 等跨平台框架相比,KMM 的主要优势有:
1)移动端原生技术栈开发人员上手更快。
2)无额外的运行时环境,性能与原生代码基本持平。
3)可无缝对接现有原生基础库,基础架构改造成本较小。
4)可沿用现有的原生插件化、内存监控、崩溃/卡顿监控等基础技术,无需额外开发支持。
不过 KMM 是语言层面跨平台的技术与框架,且当前处于 alpha 阶段,所以仍有一些缺点,包括:
1)Kotlin/JVM 与 Kotlin/Native 的异步并发模型不同。
2)KMM 社区生态环境仍在建设中,没有成熟的 UI 框架,因此无法用于编写 UI。Kotlin 编译器仍然处于快速迭代升级阶段,因此元编程相关的 API 不稳定。
2020 年携程机票 Android 团队将核心业务的历史 Java 代码迁移至 Kotlin + Coroutines + Jetpack AAC 技术栈获得了不错的成效,详见《携程机票 Android Jetpack 与 Kotlin Coroutines 实践》。Kotlin、Coroutines、MVVM 等新型架构模式在 Android 平台经受住了千万量级访问量的生产考验,因此我们决定于 2021 年初开始尝试 KMM,将 Kotlin 的应用范围逐步扩大至 iOS 平台。
二. 总体设计与集成
由于 KMM 尚处于 alpha 阶段,初期主要定位是——实现业务逻辑代码的跨平台共享,包括:数据模型、网络请求、本地数据存储、业务逻辑处理。
如果要从零搭建一个 KMM 工程,IntelliJ IDEA 或 Android Studio 的 KMM 模版插件可以辅助创建,整体工程就是一个常规 Gradle 工程,内部包含两个 Gradle module 子模块,分别是 Android app 与 KMM module。Android app 通过工程依赖直接引用 KMM module,此外还包含一个 iOS Xcode 工程。
但我们的场景是在现有且彼此独立的携程 Android 与 iOS App工程中引入 KMM,所以我们需要将 KMM 作为一个独立子工程模块进行集成。携程的 Android 与 iOS App 工程结构大体相似,底层是公共基础团队负责的公共库及框架,上层是依赖公共框架层的各个业务团队的 bundle。KMM 作为一个独立的工程需要依赖基础库,且机票业务 bundle 依赖 KMM 跨端共享业务逻辑工程。
机票业务工程集合的 KMM、Android、iOS 三个子工程的简化版依赖关系如下图:
Android 工程依赖机票 KMM 工程,通过 Gradle 构建并发布至公司内部 Maven 源的 aar 文件;iOS 工程通过本地集成 KMM 构建生成的 framework(目前正在调研迁移至 CocoaPods 集成方案)。
我们希望复用并扩展之前 Android Jetpack AAC 的优化升级成果,因此业务代码架构继续使用 MVVM 模式,整体分为三部分:View、ViewModel、Model。KMM 目前尚缺成熟可靠的 UI 框架,UI 层暂且保留原生开发方式,由平台各自实现,Model 层与 ViewModel 层由 KMM 工程承载。
2.1 Android 集成
KMM Android 端集成非常简单,与普通的 Android 第三方库集成无异。使用 IntelliJ IDEA 或 Android Studio 的 KMM 插件创建的 KMM 工程默认生成 Android source set,Gradle Build Task 执行生成 AAR 文件。当然,如果想创建一个泛 JVM 平台共享库(不涉及调用任何 Android SDK 和第三方库 API),我们可以把 Android source set 修改为 JVM source set,Gradle Build Task 就会生成 JAR 文件。
无论是新建独立 KMM App工程,还是基于现有 App工程集成 KMM 模块,KMM 子工程模块生成的 AAR 或 JAR 文件产物,均可发布上传至指定的 Maven 源仓库,进行集中依赖管理。调用方通过 Gradle/Maven 的 api 或 implementation等语句添加依赖。这对于 Java/Kotlin 开发人员非常友好,没有增加额外学习认知成本。当然对于小型个人项目,也可以使用简单的 Local Module Project 本地直接依赖方式。
KMM Module 工程集成与常规 Android Libraray Module 工程集成一脉相承,整理实践过程中遇到的若干常见问题:
1)在设置 KMM 工程的 target Java 版本时,尽量与需要集成的主工程保持一致,否则 KMM 的 target Java 版本如果过高可能会导致主工程构建失败。
2)主工程在集成 KMM 工程之后,注意设置混淆策略,否则运行时容易触发 NoClassDefFoundError 异常。
3)在使用新版 Gradle 构建时注意正确设置 duplicates strategy,否则主工程可能会集成失败。
2.2 iOS 集成
iOS 集成相比 Android 稍显复杂。iOS 开发者需要首先学习 Gradle 配置以及 Intellij IDEA 或 Android Studio IDE的基础知识。
iOS 集成的两点关键:
1)配置 KMM 工程依赖所需的 Objective-C 工程,使得 Kotlin 代码可以访问调用 Objective-C 代码,正确编译打包。
2)配置 KMM 工程编译打包生成的产物导入至 Xcode 工程,使得 Objective-C 代码可以访问调用 Kotlin 代码。
Kotlin Native SDK 已经预先内置了 iOS 系统所有的 API,开发人员需要手工处理的是将 Kotlin 代码与自行编写的 Objective-C 代码或其他第三方库代码进行桥接。这部分工作并不复杂,因为 KMM 的最终产物文件都是 iOS 系统常规的 .framework/.a 文件,原理遵循 iOS 平台开发常识,学习曲线对于 iOS 开发人员较为友好。
这里仅列举 iOS 集成过程中的若干场景问题:
2.2.1 cinterop
官方提供的 cinterop 工具可以将指定的 C/Objective-C 库的所有公开 API 封装转译为 Kotlin API,生成 klib 文件格式,供 KMM 工程调用。处理之后,开发人员就可以在 KMM Project 的 iOS source set 代码中实现调用这些 API。
简述为,通过定义一个 def 描述文件,声明被依赖的 .h,.a 工程配置,并配置 Gradle 工程依赖。
def 文件示例:
language = Objective-C
headers = AAA.h
libraryPaths = /Users/xxx/extFramework
staticLibraries = FA.framework
compilerOpts = -I/Users/xxx/extFramework/FA.framework/Headers
Gradle iOS Target 配置示例:
target.compilations["main"].cinterops.create(name) {
defFile = project.file("src/nativeInterop/cinterop/xxx.def")
packageName = "xxx"
}
def 文件示例中 libraryPaths 和 compilerOpts 参数涉及到跨工程模块的文件路径引用,因此当大型项目多人协作和自动化构建集成时,需要定制适配引用路径。
基于 Git SubModule 特性,我们先把被依赖的 iOS 原生工程仓库设置为引用方 KMM 工程仓库的 SubModule,然后增加一个动态获取引用路径的自定义 Gradle Task,通过 Gradle API 获取绝对路径后,写入 def 文件,该 Task 的触发时机需要设置为 build task 运行之前。
2.2.2 双指令集合并问题
KMM Module 编译生成的 framework 文件最终是运行在真机设备上,即 arm64 格式,而开发阶段需要支持模拟器设备,即 x84_64 格式。官方版本(1.4.x)最初并未支持同时编译和运行 arm64 与 x86_64 两套指令集,只能手工切换,分别单独构建。官方版本1.5.21开始,KMM plugin 通过生成 fat-framework 的 Gradle task 解决指令集合并问题。
当 KMM Module 仅包含 Koltin 代码,或者所依赖的 iOS ObjC 库文件是单指令集格式时,官方 fat-framework 方案可以正确构建。但是当所依赖的 iOS ObjC 库文件是多指令集格式时,官方方案就会报错异常。因此我们屏蔽了官方方案 Task,使用自定义指令集合并 Task 实现。
屏蔽默认 fat-framework 配置如下:
gradle.taskGraph.whenReady {
tasks.forEach { task ->
if (task.name.endsWith("fat", true)) {
task.enabled = false
}
}
}
总结 iOS ObjC 原生库,KMM 库,桥接和双指令集的流程如图所示。
2.2.3 代码注释
KMM 低版本,Kotlin 代码文件的注释不能自动导出到 *.framework,无法在 Xcode IDE中查看。Kotlin 1.5.20 起,官方已经支持注释导出,配置示例:
targets.withType<KotlinNativeTarget> {
compilations["main"].kotlinOptions.freeCompilerArgs += "-Xexport-kdoc"
}
2.3 基础框架的 KMM 化搭建
在编写业务代码之前,KMM 工程需要一些底层基础框架的支持。我们首先选择了两个官方库:kotlinx.coroutines 与 kotlinx.serialization,当前 Kotlin 生态中的绝大部分第三方库都只能支持 Kotlin/JVM,能用于 KMM 的极少。而这两者是目前为数不多可用的 Kotlin 多平台库。kotlinx.coroutines 我们选用了 multi-thread 分支版本而不是默认主线版本,原因是主线版本在 native target 下是单线程实现,即所有异步协程任务均运行在主线程中,而我们希望其真正运行在多线程环境,避免对 UI 主线程造成影响。
kotlinx.serialization 包含两部分,分别是 kotlinx.serialization-json 与 kotlinx.serialization-protobuf,其中 kotlinx.serialization-json 已经是 release 状态,是目前极少数能用于 KMM 的 JSON 序列化库,但 kotlinx.serialization-protobuf 目前还处于 beta 阶段,使用时需加强自动化测试场景覆盖,性能评测,以及线上监控。
携程 App 包含公共框架团队提供的众多自研框架、协议,例如:网络服务、ABTest、增量配置读取、埋点上报系统、日期时间系统、用户账户系统等等。这些基础库通常是由 Android 与 iOS 两端分别实现,编程语言不同,但 API 的设计、命名、参数数量与类型定义都高度相似。我们需要将这些已有的基础库通过桥接、封装后包装出 KMM API,提供给 Kotlin Common source set 调用,而这些库本身的相似设计给我们提供了极大的封装便利。
目前携程 App 中采用腾讯微信团队开源的 MMKV(详见参考链接 2)用于本地键值对存储,它使用 C++ 编写核心代码,并分别提供 Java 与 Objective-C 等多种语言的上层 API,携程的公共基础团队基于 MMKV 原本的 API 又进行了一层封装,可以使业务团队无缝的从 SharedPreference 与 NSUserDefaults 迁移至 MMKV,不过由于要兼容旧代码导致两端的 API 设计有所不同。机票 KMM 工程作为一个无需兼容旧代码的新工程,决定直接封装 MMKV API 来作为工程的底层存储框架,这里作为一个简单的 demo 来说明如何桥接封装现有的 Android、iOS 库。
我们先在 common source set 中定义抽象的 MMKV 类型:
expect class MMKV
当然它是待实现的,我们希望它在 Android 平台直接表示 Java 的 MMKV 类型,在 iOS 平台直接表示 Objective-C 的 MMKV 类型。
在 Android 平台如下:
actual typealias MMKV = com.tencent.mmkv.MMKV
直接使用类型别名即可桥接,无论是在编译期还是运行时,它们都是同一种类型。
在 iOS 平台如下:
actual typealias MMKV = xxx.xxx.ios.MMKV
iOS 上没有包名的概念,xxx.xxx.ios 是使用 cinterop 等工具生成 Kotlin wrapper 时自定义的包名。
接着使用一些顶层函数来桥接 MMKV 的静态函数,用扩展函数来桥接 MMKV 在不同平台的成员函数,Android 如下:
internal actual fun defaultMMKV(): MMKV = MMKV.defaultMMKV()
internal actual fun getMMKVByDomain(domain: String): MMKV = MMKV.mmkvWithID(domain)
internal actual fun MMKV.closeMMKV() = close()
internal actual fun MMKV.set(key: String, value: String): Boolean = encode(key, value)
internal actual fun MMKV.set(key: String, value: Boolean): Boolean = encode(key, value)
// ......
internal actual fun MMKV.takeString(key: String, default: String): String = decodeString(key, default) ?: default
internal actual fun MMKV.takeBoolean(key: String, default: Boolean): Boolean = decodeBool(key ,default)
// ......
iOS 如下:
internal actual fun defaultMMKV(): MMKV = MMKV.defaultMMKV()!!
internal actual fun getMMKVByDomain(domain: String): MMKV = MMKV.mmkvWithID(domain)!!
internal actual fun MMKV.closeMMKV() = close()
internal actual fun MMKV.set(key: String, value: String): Boolean = setString(value, key)
internal actual fun MMKV.set(key: String, value: Boolean): Boolean = setBool(value, key)
// ......
internal actual fun MMKV.takeString(key: String, default: String): String = getStringForKey(key, default) ?: default
internal actual fun MMKV.takeBoolean(key: String, default: Boolean): Boolean = getBoolForKey(key, default)
// ......
封装桥接的基础理念是,在 common source set 中定义它的抽象,然后在平台相关的 source set 中编写实现直接调用需要被桥接的库函数。我们可以看到,Android 与 iOS 两个版本的 MMKV 的部分 API 命名是有区别的,例如在 Android 中 set 一个值,函数的命名是 encode,而在 iOS 中则是 setXXX, 且在 Android 中参数通常是 key 在前 value 在后,而 iOS 的习惯则是 value 在前 key 在后,但它们的设计没有根本性的区别,小差异基本都可以在我们的封装中抹平,从而在 common source set 提供统一的 API。
上面关于 MMKV 的封装是一种常规且较为简单的封装,在我们的实际工作内容中,对网络框架的封装与改造值得一提。
携程自研的网络框架并非标准的 HTTP 协议,底层有大量定制的协议等内容。框架上层分别以 Java 以及 Objective-C 实现,不仅仅包含网络请求本身,还封装了对包括 Protobuf2 在内的各类数据的序列化与反序列化代码。原网络框架的设计对于业务团队的使用十分便捷,请求时只需要将 request entity 以及 response entity 类的 class 对象(Java 与 Objective-C 都有 class 对象)作为参数传入,然后在回调中拿到 response entity 即可处理网络返回结果。
由于框架是根据 class 对象来生成 Java 对象或 Objective-C 对象,而在 KMM 工程中我们无法拿到 Kotlin 类的 class 对象(问题的根源将在3.3 小节讨论),因此当前的网络框架无法支持生成 Kotlin 定义的 request 或 response entity。我们将原有的网络框架做微小的改动,提供一个不进行序列化与反序列化的选项,框架用户可直接将序列化好的 request entity 二进制数据传递给框架,而框架也会将反序列化前的 response entity 二进制数据返回给框架用户,这样我们就可以在 KMM 工程内使用 kotlinx.serialization 进行序列化或反序列化。此外 Kotlin 中表示二进制数据的 ByteArray 与 Java 中的 byte[] 是完全等价的,但与 Objective-C 的 NSData不兼容,在 iOS 端的处理上还需要对 ByteArray 与 NSData 通过手动声明内存区域进行互相转换。
KMM 的网络框架设计如图下图所示:
解决了序列化与反序列化的问题,我们还要将原先的回调式 API 封装成 Kotlin suspend API,以便将其更好的纳入协程结构化并发体系:
// 原 Java API
public static String sendJavaRequest(BaseRequestEntity requestEntity, NetworkCallback callback) {
// ......
}
Kotlin suspend 化封装:
// Kotlin 封装
suspend fun sendRequest(requestEntity: BaseRequestEntity): BaseResponseEntity = suspendCancellableCoroutine { continuation -> // 调用库函数,挂起协程
val requestID = NetworkUtil.sendSOTPRequest(requestEntity) { baseResponseEntity, error ->
if (baseResponseEntity?.isSuccess == true && error == null) {
// 请求成功,恢复协程并将结果带回
continuation.resume(baseResponseEntity)
} else {
// 请求失败,以异常的方式恢复协程并将异常带回
continuation.resumeWithException(NetworkException(error.message))
}
}
// 取消逻辑
continuation.invokeOnCancellation {
NetworkUtil.cancelTask(requestID)
}
}
通过调用协程库函数 suspendCancellableCoroutine 在请求发出后将协程挂起,根据网络请求的成功或失败以不同的方式恢复协程,并且同时处理了当外部协程被取消时,一并取消网络请求的逻辑。
2.4 业务 Model 模块
根据由下至上的开发顺序,在基础底层架构都搭建完毕后,在 KMM 工程业务层代码的编写中应该首先要规范 MVVM 模式中 Model 层代码的编写。广义的 Model 层代码包括:各种 data class、工具函数与工具类、业务处理逻辑等等。总之 Model 层尽量不存在可变状态只提供纯函数给外界及上层调用。举例来说,我们希望提供一个 City 相关的业务模块给 ViewModel 层使用,大致就会声明如下形式的 API:
data class CityModel(
val cityName: String,
val cityId: Int,
)
object CityManager {
// 获取所有城市
suspend fun getAllCity(): Map<String, CityModel> {
// ......
}
// 查询单个城市
suspend fun queryCity(cityId: Int) {
// ......
}
}
每一块相关的业务都对应一个 object 单例(其实多数情况下它只起到了 namespace 的作用)。
2.5 跨端的架构模式组件尝试——MVIKotlin
我们希望找到一款能用于 KMM 上的类似于 Jetpack AAC 的架构模式组件框架来实现 MVVM 模式,但是开源社区内暂时还没有这样一款成熟可用的框架,之后由于官方的推荐,我们把目光集中在了 MVIKotlin 身上(官方网站详见参考链接 3)。
MVIKotlin 的功能是用于实现 MVI 模式,MVI 模式简单来说是改进版本的 MVVM。在 MVVM 中,View 通过监听 ViewModel 内的数据变化(LiveData/StateFlow 等)来完成更新,而用户对 View 的操作则通过对 ViewModel 的直接调用来触发数据状态的变更。而在 MVI 中则是把 View 触发数据状态的变更改进为发送“意图(Intent)”,从而进一步解耦。
经过初步调研,我们认为 MVIKotlin 还有以下优点:
1)已经进入 release 版本,对于 KMM 社区中各色 dev、alpha、beta 的版本号来说,已经 release 的开源库颇为难得。
2)计相对完善,MVIKotlin 还提供了针对 Reaktive 与 Coroutines 的绑定。
我们将 MVIKotlin 引入了 KMM 工程,并选取了一块业务进行了试验性的开发与接入,最后在生产环境上连续运行了一个月之后,它的稳定性也暂时经受住了考验。但是在开发阶段我们发现了它的一个明显缺点——使用不便。
MVIKotlin 内有一个与 Jetpack ViewModel 功能相似的组件——Store,而 Store 的设计极为复杂,参照如下官方简介图:
Store 仅仅是 MVI 模式中的一环,但其内部却拥有大量各司其职的组件,像 Bootstrapper 这种每次启动只会使用一次的组件也需要单独定义,在每次开发的过程中即使是很简单的业务开发者也需要编写大量样板代码。更让人头疼的是,数据在 Store 内流转时每经过一个组件就会变一个名字,这丛概念上讲确实没有什么问题,因为数据在不同的组件间流转时从概念上来说会有区别,但在 MVIKotlin 的设计中每种概念都由一个 sealed class 及其大量的子类表示,且各个组件在判断数据的种类时都用 when 表达式逐个判断对象的类型是其父 sealed class 的哪一个子类。这种设计导致的问题包括:工程内 class 的数量激增、在 JVM 中每一次简单的业务调用都进行多次 instanceof 判断从理论上来说并不高效。
综上所述,最终我们决定弃用 MVIKotlin。我们想象的理想替代方案是,自己动手将 Jetpack AAC 移植到 KMM,目前 StateFlow 可以代替 LiveData,所以我们仅需要移植 Lifecycle 与 ViewModel 即可,不过这是个需要耗费大量时间的工作,我们目前仍处于调研阶段,同时我们也会持续关注开源社区中的其他架构组件框架。
有关 MVIKotlin 的更多讨论可以参考我本人写的文章(参考链接 4)。
三. 挑战与对策
KMM 的探索过程并非一帆风顺,KMM 与 Kotlin/Native 作为 alpha 阶段的新技术给我们提出了不小的挑战。在近一年的开发过程中我们遇到了许多 bug、问题以及所谓的“坑”。作为开发者用户,我们与其他参与社区建设的开发者一样会积极向官方提问或上报 bug。官方虽然非常热心的给予解答,并将修复或改进计划列入 roadmap,但官方处理一些大问题的周期以年为单位,因此我们只能尽量以最小的代价暂时处理或规避这些问题,下面会大概介绍一下我们遇到的主要问题以及相对应的解决策略。
不过由于篇幅所限,每个问题大概都是一笔带过,但会附上一些参考链接。
3.1 Kotlin/JVM 与 Kotlin/Native 异步并发模型不兼容
Kotlin/Native 的异步并发模型受对象子图机制的约束,这与 Kotlin/JVM 可以自由的编写异步并发代码完全不同,对象子图机制可以总结为以下几点:
1)每个对象都与其诞生时所在的线程绑定,一旦在其他线程访问该对象,即监测到该对象的对象子图中记录的线程 id 与当前线程不一致,程序立刻 crash。
2)要在多线程中访问同一个对象,只能将该对象做对象子图分离与重新绑定。
3)冻结对象,冻结对象可以在任意线程访问,但冻结对象不可进行“写”操作,一但进行“写”操作立刻 crash,且冻结对象不可解冻。
关于 Kotlin/Native 的异步并发模型,我早先发布过文章进行详解,请见参考链接 5。
这个问题导致的直接结果就是同一份代码在能通过编译的情况下,在 Android 端可以正常运行,但在 iOS 端则会 crash。除此之外它还产生了一系列的连带或相关问题包括:
1)协程在 Kotlin/Native 上没有调度器 Dispatchers.IO。
2)协程调度器 Dispatchers.Default 在 Kotlin/JVM 上是线程池实现,而在 Kotlin/Native 上是单后台线程实现(multi-thread 版本除外)。
3)我们在 Kotlin/Native 上也无法自己编写基于池化技术的协程调度器,因为它可能会因为挂起时与恢复时所在线程不同而 crash。
4)此前协程挂起锁 Mutex 在 Kotlin/Native 上有 bug,无法正常生效(kotlinx.coroutines 1.4.2 版本后已修复)。
这个问题是否解决将决定 KMM 能否用于生产环境,经过我们的研究和评估后制定了一系列的解决方案。首先,在 KMM 工程中,所有的协程只能在主线程开启;其次,在执行需要后台线程执行的任务时,通过专门编写的高阶函数 API 来执行;最后,所有的可变状态(通常是成员变量)必须在主线程更新值。
我们编写了一套自己的高阶函数 API 执行异步任务,它的设计图如下所示:
在 common source set 这套 API 有着统一的抽象与定义,而在 Android 与 iOS source set 的实现中则有着不同的处理流程。在 Android 的实现中只需用库函数 withContext直接切换至 Dispatchers.Default 即可。而在 iOS source set 的实现中则是先使用协程标准库函数 suspendCoroutine 将协程挂起,然后将传入的参数全部做对象子图分离,接着使用系统提供的 GCD 执行异步任务,在 GCD 执行的异步任务的回调中将对象子图重新绑定,最终再使用 GCD 重新切换回主线程后(同样要做对象子图分离与绑定)恢复协程。不过这套 API 的使用也有着相当严格的条件, 即它的 lambda 表达式参数不允许捕捉任何外部变量(包括局部变量、成员变量、顶层变量等),否则都会有运行时 crash 的风险。
这里给出 iOS 端实现代码,供直观理解:
// 无参数版本
@OptIn(ExperimentalUnsignedTypes::class)
actual suspend inline fun <reified R> calculateOnBackground(crossinline block: () -> R): R = suspendCoroutine { continuation ->
val queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND.toLong() ,0)
val continuationGraph = continuation.wrapToDetachedObjectGraph()
dispatch_async(queue, { // 该 lambda 运行在子线程
val resultGraph = block().wrapToDetachedObjectGraph()
val tempContinuationGraph = continuationGraph.attach().wrapToDetachedObjectGraph()
dispatch_async(dispatch_get_main_queue(), { // 该 lambda 运行在主线程,用于在主线程内调用 resume 函数恢复协程,避免 IncorrectDereferenceException
tempContinuationGraph.attach().resume(resultGraph.attach())
}.freeze())
}.freeze())
}
// 单参数版本
actual suspend inline fun
<reified P0, reified R>
calculateOnBackground(p0: P0, crossinline block: (P0) -> R): R {
val p0Graph = p0.wrapToDetachedObjectGraph()
return calculateOnBackground {
block(p0Graph.attach())
}
}
// 更多参数版本依此类推……
这套 API 保证了无论是传参还是返回值都在主线程进行,仅有计算过程在后台线程执行。成功将两端异步并发模型的差异屏蔽封装在了各自的 source set 中,而 common 层程序的编写者仅需要按照规则使用这套 API 即可,无需担心两端的差异。
我们的方案虽然能暂时解决这个问题,但问题的源头还是在于官方的设计上,它自 Kotlin/Native 诞生之初便由来已久,也一直为人所诟病。官方最初的回答是采用锁的方式来保证并发安全容易出错,因此想把对象跨线程访问的操作全部显式的暴露在编译期,但这造成了几个问题:
1)传统的移动开发人员一时间无法适应。
2)Kotlin 并非纯函数式编程语言,完全抛弃可变状态将导致编程风格非常别扭,且不适用于 UI 编程。
3)与 Kotlin/JVM 差异过大,导致代码复用受阻。
社区并不认可官方这套为何设计对象子图机制的说辞,社区普遍认为 Kotlin/Native 仓促发布,研发团队一时间造不出一个能在多线程环境下高效稳定不内存泄漏的 GC 系统才出此下策。不过好消息是,自从 Roman Elizarov 担任 Kotlin 团队的 leader 之后致力于改变这一现状,Kotlin 目前的 roadmap 中,新的 GC 将在 1.6 版本进入预览版,1.6.20 版本后进入 stable 状态,届时 Kotlin/Native 的对象子图机制将提供开关以进行关闭,而开发者将通过协程的 Mutex 等机制来保障并发安全,未来可期。
3.2 Kotlin/Native 调用非虚函数使用静态分派
JVM 上为了实现多态调用非虚函数的机制称为动态分派,即仅在运行时才知道被调用的函数到底是哪一个版本(继承类、实现接口会覆盖函数)。但在 Kotlin/Native 中情况有所不同,我们用 Intellij IDEA 创建一个 Native Console Application(macOS 11.6)并编写以下代码,来看看运行后的结果:
fun main() {
val data = Data<A>(B())
val a = data.getSomething()
a?.print()
}
interface Base {
fun print()
}
class A : Base {
override fun print() = println("123")
}
class B : Base {
override fun print() = println("456")
}
class Data<T : Base>(val b: Base) {
fun getSomething(): T? = b as T
}
这段代码本应该直接 crash,但运行后却奇迹般的打印出了“123”,也就是说,我们居然用 B 类型的对象调用了 A 类型的成员函数。出现这种离奇现象的唯一解释就是 a?.print() 这行代码在编译期就已经确定了到底调用的是哪个版本的 print 函数,即 Kotlin/Native 调用非虚函数使用的是静态分派。静态分派的实现本身不会导致这个问题,但 Kotlin/Native 同 Kotlin/JVM 一样会对泛型擦除,这两个方案一碰面就产生了这种令人困惑的 bug,我在 YouTrack 上向官方提问后,官方的回复总结起来就是:“I know, but this is a feature :)”(详见参考链接 6)。
3.3 Kotlin 类的根级超类与 Objective-C 类的根级超类不兼容
在 Kotlin 中,所有类的根级超类是 Any。当我们把 KMM 工程打包生成 iOS 端的 framework 之后,我们查看其内部的头文件可知,所有 Kotlin 类的跟级超类叫做KotlinBase,KotlinBase 在 KMM 工程中不可见,而它在 framework 中的定义是:
open class KotlinBase : NSObject {
open class func initialize()
}
这是一段 Swift 代码,而 NSObject 是所有 Objective-C 类的根级超类,看起来所有 Kotlin 类也应该都是 NSObject 的子类,但到了 Kotlin 工程中,奇怪的事情就发生了,Any 类与 NSObject 类无任何子类型化关系。也就是说,一个函数(无论是 Kotlin 函数还是 Objective-C 函数)它接收的参数为 NSObject 类型,那么在 Kotlin 工程中调用这个函数,传入任何 Kotlin 对象(除非显式声明该 Kotlin 类继承自NSObject)都无法通过编译,但是在 Xcode 工程中却可以。
这个问题带来的另一个问题是,在 Kotlin 工程中,所有的 Kotlin 对象都无法获取自身的 class 对象。每个 Objective-C 对象都能获取自身的class 对象,类似于 Java 中的 Class<?> 类型或 Kotlin 中的 KClass<?> 类型,但由于在 Kotlin 工程中 Kotlin 类都不是 NSObject类的子类,这个操作无法完成。
这个问题带来的可能影响是有一些 Objective-C API 需要使用 class 对象来生成其对应的类的实例。目前来说这个问题带来的影响请详见 2.2 小节,不过被我们用其他设计方案规避。它算是官方在设计上的一种自相矛盾,希望以后会被解决。
3.4 Kotlin/Native object 定义的作用域内的隐式可变状态会在运行时抛出 InvalidMutabilityException
3.1 小节我们提到了 Kotlin/Native 独特的异步并发机制,因此在 Kotlin/Native 中对可变与不可变有着极为严苛的限制。在 Kotlin/Native 中,object 只能存在两种情况,要么是冻结的(即内部所有成员都是不可变的),要么必须加 @ThreadLocal 注解,但这样的话它就会变成线程私有的,来看以下代码:
object MyObject {
var index = 0
}
编译器会抛出警告:“Variable in singleton without @ThreadLocal can't be changed after initialization”。如果在运行时对 index 进行修改,会直接抛出InvalidMutabilityException 异常并 crash。但我们再考虑以下代码:
object MyObject {
val hashMap = HashMap<String, String>()
}
编译器不会抛出任何警告,但一旦我们在运行时对 hashMap 进行 put 操作,程序立刻抛出异常后 crash。因此,冻结通常都是冻结整颗引用树,在编译器无法提醒的下层引用树中进行变更也会产生开发者无法在编译期发现的潜在 crash 隐患,需要尤为小心。这个问题的解决方案是:要么 object 内部只包含常量与纯函数,要么只能添加 @ThreadLocal 注解,别无他法。
3.5 协程异常处理器抛出 NoClassDefFoundError
该问题是 Kotlin 协程在 JVM 平台出现的问题。问题现象为:在 Kotlin 协程内部发生异常后,协程会通过异常处理器进行处理,但在加载异常处理器的时候会报 kotlinx.coroutines.CoroutineExceptionHandlerImplKt这个 class 无法找到的 NoClassDefFoundError。通过阅读源码,我们知道 kotlinx.coroutines 内部加载异常处理器使用的是 ServiceLoader。我们起初尝试复现该问题但是并不成功。
之后在 JetBrains 的报障网站 YouTrack 上,我们看到有人提供了一个类似的 case(参考链接 7),提问者提供了声称可必现该问题的 demo 工程,这是一个 Intellij IDEA plugin 工程,理论上来说它可以用于验证 Kotlin/JVM 协程的问题,但我们按 README 运行工程后仍然无法复现。之后再次通过搜索,发现在 Github kotlinx.coroutines 仓库的 issues 中有人提过类似的问题,官方的回复这是 JDK 的 bug(参考链接 8)。目前我们猜测与具体的 JDK 版本有关。因此在使用协程时此问题值得监控与关注。
四. 生态环境
Kotlin 最初的口号是:“Better Java”,在 1.3.x 版本迭代完毕后 Kotlin 已经完成了这个目标。从 1.4.x 版本开始 JetBrains 将 Kotlin 迭代的重点放在了多平台领域。在 3.1 小节提到过为解决 Kotlin/Native 独特的异步并发机制带来的困扰,官方设计的新内存管理系统已经在 1.6.0-M1 中提供预览(详见参考链接 7),并将在后续 1.6.x 的正式版本中 release。Ktor 与 kotlinx 库等官方库是目前 Kotlin 跨平台的中坚力量,Ktor 目前可以在多平台环境提供稳定的 HTTP 请求、数据序列化/反序列化功能,是相当强大的 Kotlin 多平台网络库;在 kotlinx 库方面,除了上述 kotlinx.coroutines 与 kotlinx.serialization,官方之前又新启动了 kotlinx.datetime 项目,用于在全平台的 Kotlin 上提供统一的日期时间 API。
在探索 KMM 的过程中,我们切实感受到了 Kotlin 与原生语言交互能力的提升,从 Kotlin 的泛型支持映射到 Objective-C,再到 Objective-C/Swift 可以调用 Kotlin suspend 函数等等,Kotlin 与 iOS 的平台的“原住民”们的友好关系也在逐渐提升,而开发者们期待的与 Swift 的互操作能力的提升也在官方的计划列表中(参考链接 8)。
在 Kotlin 的生态环境发展中当然不止有 JetBrains 官方的功劳。Android 开源界的先锋 Square 团队开源了第一款用于 KMP 的数据库框架 SQLDelight(参考链接 9);也正在积极将包括 Okio 在内的自家许多 Android 库迁移至 KMP,此外他们也正在进行 UI 跨平台的调研(详见参考链接 10,workflow-kotlin)。Touchlab 也贡献了许多 KMP 上好用的工具。除了携程机票之外,阿里巴巴、腾讯、美团、快手等大厂也在积极进行 KMM 的尝试。我们的团队在之后的工作中将会进行更多的技术探索与输出,使自身的贡献及影响力在 Kotlin 技术社区内占有一席之地。
五. 参考链接
【1】Kotlin 多平台官方介绍
https://kotlinlang.org/docs/mpp-intro.html
【2】MMKV
https://github.com/Tencent/MMKV
【3】MVIKotlin
https://arkivanov.github.io/MVIKotlin/
【4】《KMM 求生日记二:跨端的 MVI 框架 —— MVIKotlin》
https://juejin.cn/user/3844312374718221
【5】《Kotlin/Native 异步并发模型初探》
https://mp.weixin.qq.com/s/JDYixvkoaJLBac6CaEw09Q
【6】Kotlin/Native 非虚函数静态分派调用的 bug 在 YouTrack 上的讨论(KT-42903)
https://youtrack.jetbrains.com/issue/KT-42903
【7】YouTrack 上关于协程异常处理器 NoClassDefFoundError 的报障
https://youtrack.jetbrains.com/issue/IDEA-277886
【8】Github kotlinx.coroutines 仓库关于 NoClassDefFoundError 的 issues
https://github.com/Kotlin/kotlinx.coroutines/issues/1300
【9】JetBrains 官方博客《Try the New Kotlin/Native Memory Manager Development Preview》
https://blog.jetbrains.com/kotlin/2021/08/try-the-new-kotlin-native-memory-manager-development-preview/
【10】Kotlin Roadmap
https://kotlinlang.org/docs/roadmap.html
【11】SQLDelight
https://cashapp.github.io/sqldelight/
【12】workflow-kotlin
https://github.com/square/workflow-kotlin
团队招聘信息
我们是携程机票研发团队,负责携程APP/PC端机票业务开发及创新。机票研发在搜索引擎、数据库、深度学习、高并发等方向持续不断地深入探索,持续优化用户体验,提高效率。
在机票研发,你可以和众多技术顶尖大牛一起,真实的让亿万用户享受你的产品和代码,提升全球旅行者的出行体验和幸福指数。
如果你热爱技术,并渴望不断成长,携程机票研发团队期待与你一起腾飞。目前我们前端/后台/数据/测试开发等领域均有开放职位。
简历投递邮箱:tech@trip.com,邮件标题:【姓名】-【携程机票】-【投递职位】。
【推荐阅读】
携程APP Native/RN内嵌Flutter UI混合开发实践和探索
Trip.com APP 启动优化实践
携程机票 Android Jetpack 与 Kotlin Coroutines 实践
携程旅行App iOS工程编译优化实践
“携程技术”公众号
分享,交流,成长