最近几周团队的 KMM 进度推进了不少,已经陆续把几个小业务需求迁移到了 KMM。
其实万事开头难,最初的时候许多公共团队基础类库都没有桥接到 KMM,导致好像啥业务都没法着手开始。确定了临时的方案之后,经过前面一周的集中攻克,目前公共的团队最常用的诸如:AB 实验、增量数据、网络、日期、本地存储等等核心 API 都已经桥接完成。
目前基本的开发方式是,主要由我来编写 KMM 工程的代码,包括需要主工程实现的接口、以及通过桥接过来的对象实现的各种基础 API,最后是基于这些 API 编写的真正的业务逻辑。然后我在 Android 的主工程中编写这些桥接接口的实现,以及对 KMM 中业务逻辑的调用。在 Android 上完成基本的测试,能跑通之后,由我的一位同事(也是小组 leader)在 iOS 主工程中编写类似我在 Android 主工程中编写的代码(Objective-C)。
其实在 Android 上基本没什么问题,因为本质就是用 Kotlin 写了些代码,打成 aar 给主工程调用而已,所以主要的问题还是出在 iOS(Kotlin/Native)上。
一. Kotlin 类的根级超类与 Objective-C 的根级超类不兼容
Kotlin 中有一个类 Any,它是所有类的根级超类。Java 所有类的根级父类是 Object,但是在 Kotlin/JVM 中,这两者是统一的,也就是说如果一段 Java 代码接收的参数类型是 Object,那我们仍然可以将任意一个 Kotlin 对象作为参数传入。
但是情况到了 Kotlin/Native 中则完全不同。如果我们打开一个依赖了由 Kotlin/Native 编译出来的 Framework 的 XCode 工程,在该工程中我们会发现所有的 Kotlin 类都继承自一个叫做 KotlinBase 的类,声明如下:
代码语言:javascript复制open class KotlinBase : NSObject {
open class func initialize()
}
这是一段 Swift 代码,只要继续浏览这个声明文件我们会发现我们所有的 Kotlin 类都继承自 KotlinBase。
但是到了 Kotlin 工程中情况就变的完全不同了,所有 Kotlin 类继承自 Any,而 Any 和 NSObject 之间没有任何类型关系。
上述差异导致的最严重问题就是 Kotlin/Native 类在 Kotlin 工程中拿不到 class 对象。在 Java 中所有类都有类型为 Class<?> 的 class 对象,通过类名或该类的对象都可以直接获取。在 Kotlin/JVM 中,Kotlin 有自己的 KClass<*> 类型,它与 Java 的 Class<?> 类型不同,但是我们可以用 Any::class.java 的方式拿到一个 Kotlin 类的 Java class 对象。而在 Kotlin/Native 中,KClass 无法获取一个类的 Objective-C 的 class 对象,这最直接的结果就是许多现有的 Objectice-C 库,可能含有需要传入一个 class 对象的 API,通常的左右是来生成一个对象(和 Java 中使用 class 的方式相似),那么这样的 API 可能对 Kotlin 类不兼容。
但奇怪的是,在 Kotlin 工程中如果直接声明一个类继承自 NSObject,可以用 class() 函数来获取自身的 class 对象,但普通的 Kotlin 类则没有这个函数。
二.object 定义的作用域内如果存在可变状态,则必须添加 @ThreadLocal 注解
如果我们用 object 定义了一个单例(其实更多的时候我们只是想要一个 name space),其内部存在可变状态,一旦对其进行更改(无论是否在别的线程进行),都会抛出 InvalidMutabilityException 异常。例如考虑如下代码:
代码语言:javascript复制object MyObject {
var index = 0
}
即使不运行,编译器也会抛出警告:“Variable in singleton without @ThreadLocal can't be changed after initialization”。如果我们在运行中对其进行修改,会直接抛出 InvalidMutabilityException 异常并 crash。由于警告的存在,上面这段代码很容易让开发者发现问题。但是再考虑一下下面的代码:
代码语言:javascript复制object MyObject {
val hashMap = HashMap<String, String>()
}
由于 hashMap 是用 val 定义的变量,所以编译器不会抛出警告,但一旦我们对 hashMap 进行 put 等操作,程序一样会因为 InvalidMutabilityException 而 crash。
以上说明在 Kotlin/Native 的开发中还有一条不成文的规定:除非你的 object 作用域内仅存在常量、纯函数,否则一定要加上 @ThreadLocal 注解。但你可能会说,加了 @ThreadLocal 注解全局可变状态该怎么定义?那我只能告诉你别想了,Kotlin/Native 的世界里不存在这东西。
三. iOS 平台的 size 增长较大
Android 平台以 aar 的形式集成,许多依赖的 Kotlin 基础库,例如 kotlinx.coroutines 以及 kotlinx.serialization 等等都没有打进这个 aar 里,再加上编译产物又是字节码,总 size 增长只有 0.03 MB。但是 iOS 有所不同,编译产物是二进制码,再加上整个 Kotlin/Native 的基础库、Runtime 等等通通打进了这个 Framework,总 size 增长为 1.5 MB,当然后续再持续集成业务代码的话,增长幅度不会再这么惊人。
结语
KMM 代码发布上线在即,如果它能在线上稳定跑一到两个版本(主要担心的就是 iOS 平台),就至少可以说明 Kotlin/Native 的编译器、 runtime 以及标准库没有太大问题,当然 runtime 的坑之前不是没遇到过,例如 Kotlin/Native 没有 JVM 上的虚方法调用动态分派。如果能证明语言层面上问题,后续 Model 层的业务逻辑就可以大规模迁移到 KMM。
后面我们可能会把精力花在研究一下 cinterop 这个工具以及 iOS 的构建系统上。如果能用 cinterop 搞定对已有的 iOS Framework 或 .a 文件的依赖,我们可以基于许多已有的 Objective-C 库和 Java 库封装出许多实用的 KMM 库,而暂时不必用 Kotlin 重写许多基础组件代码。
上面提到的东西够我们做一阵子了,如果再往后,就可以考虑完善一些平台统一的上层建筑,例如一些和 UI 生命周期绑定的 VM 层框架,像 Jetpack 的 ViewModel 和 LiveData 这种,可能要在双平台生命周期对齐封装方面下一番功夫。
最近 Compose-jb 动态频频,Skiko 这个库更新的也很频繁(Compose-jb 的底层依赖),社区对于 Compose-jb 支持 Native 平台呼声很高,关于 iOS 平台的相关代码也已经有社区大佬开始提交,长远来看我觉得可以期待一下。