KMP 项目里的 expect/actual 是怎么工作的
KMP 项目里的 expect/actual 是怎么工作的
去年把团队里的核心网络层从纯 Android 迁到 Kotlin Multiplatform 时,我碰到的第一个硬钉子不是协程在 Darwin 上的内存模型,也不是 CocoaPods 的集成,而是一个看起来毫无技术含量的问题:我在 commonMain 里定义了一个 expect class PlatformHttpEngine,然后在 androidMain 里补上了 actual class PlatformHttpEngine,编译时 Gradle 却抛出一个冷冰冰的错误 —— Actual class 'PlatformHttpEngine' has no corresponding expected declaration。检查了命名、包名、泛型参数,甚至怀疑是中文路径导致的编码问题,折腾了将近两个小时才发现,根源是我把 androidMain 里的文件放进了 androidMain/kotlin/com/example/ 下面,而 commonMain 里的文件在 commonMain/kotlin/ 的同级目录,但 Gradle 的 source set 在解析 hierarchical structure 时,因为某个中间 source set 的依赖顺序问题,导致编译器在查找 actual 时跳过了正确的路径。这个经历让我意识到,expect/actual 不是简单的"头文件声明 + 实现"模式,它的工作方式远比表面看起来要依赖编译器和构建系统的紧密配合。
那个编译错误背后的 source set 查找链
要理解为什么编译器会找不到 actual,得先看 KMP 的编译任务是怎么组织的。以 Kotlin 1.9.20 之后的 hierarchical project structure(HMPP)为例,一个典型的 KMP 模块在 Gradle 里会生成多个编译任务:编译 commonMain 的 compileKotlinMetadata,编译 androidMain 的 compileDebugKotlinAndroid,编译 iosMain 的 compileKotlinIosArm64 等等。关键在于,编译器在编译某个平台代码时,并不是简单地把 common 代码和 platform 代码合并在一起编译,而是把 common 代码先编译成 .klib,然后平台编译任务把这个 .klib 当作依赖来解析。
这意味着,当你在 commonMain 里写 expect class PlatformHttpEngine 时,这个声明会被打包进 common 的 .klib,并带有特殊的 @Expect 元数据标记。然后 androidMain 的编译器前端在解析时,会遍历当前 compilation 的所有 source set 以及它们依赖的 .klib,寻找带有 @Actual 标记且签名匹配的声明。当时我遇到的陷阱在于,项目里为了复用一些中间层的代码,手动配置了 androidMain 依赖了一个 jvmCommonMain source set,而这个中间 source set 又依赖了 commonMain。结果编译器在查找 PlatformHttpEngine 的 actual 时,实际上是在 jvmCommonMain 的范围内查找,但我的 actual 定义在 androidMain,由于某些 Gradle 配置导致 androidMain 没有被正确纳入到该编译任务的可见域里,于是报了找不到 actual 的错误。
这个行为跟 Kotlin/JVM 里找类实现的逻辑完全不同。它不是运行时的类加载,也不是接口实现的多态,而是编译期前端的符号解析。你甚至可以在两个完全不相关的 source set 里分别有 expect 和 actual,只要 Gradle 的 compilation 配置把它们关联到了同一个编译任务里。这种设计的好处是零运行时开销,代价就是任何 source set 边界的配置错误都会以极其晦涩的方式暴露出来。
expect 声明在字节码层面真的存在吗
搞清楚查找链之后,我第二个好奇的问题是:common 代码编译出来的 .klib 里,expect class 到底长什么样?它会不会生成一个类似抽象类或者接口的骨架,然后由平台代码去继承?
答案是:不会。至少不是你想象的那种传统继承。
我用 Kotlin 1.9.22 写了个最小复现,在 commonMain 里定义:
expect class PlatformConfig() {
fun read(key: String): String?
}然后在 androidMain 里:
actual class PlatformConfig actual constructor() {
actual fun read(key: String): String? = TODO()
}把 common 代码编译成 .klib 后,用 klib 工具解压查看 IR。你会发现 PlatformConfig 确实存在于 common 的 IR 中,但它被标记为 expect 声明。更重要的是,当你把项目 assemble 出来,去看 Android 的 APK 中的 .class 文件时,你只会找到 androidMain 里的那个 PlatformConfig.class,common 里的 expect 声明根本不会生成任何独立的 JVM 类文件。也就是说,expect/actual 是一种编译期的"符号替换"机制,而不是运行时的多态分发。
这跟 C++ 的头文件有本质区别。C++ 的 header 会在每个 translation unit 里展开,而 KMP 的 expect 是在 IR(Intermediate Representation)层面被处理的。编译器在链接阶段会把所有对 expect 符号的引用直接重定向到对应的 actual 实现上。对于 JVM 目标平台,这表现为字节码里直接 new 或者 invokevirtual 到实际的类;对于 Native 目标,则是在 LLVM IR 生成之前就完成了符号替换。因此,你不可能在运行时用反射拿到一个"expect 类型"的 Class 对象,因为它在最终产物里就不存在。
但这个机制有个反直觉的副作用:如果你在 commonMain 里给 expect class 写了一个扩展函数,这个扩展函数编译后会出现在哪里?它编译后会生成一个顶层函数, receiver 类型被替换为 actual 类型。我曾在一次代码审查里看到同事试图在 Android 侧写一个 PlatformConfig 的子类来"继承" expect 的扩展函数,这在概念上就是行不通的,因为扩展函数在编译后根本不属于任何类。
typealias actual 的隐藏限制与 K2 编译器的变化
在实际项目里,最常见的 expect/actual 用法之一是用 typealias 来做 actual,尤其是 Android 侧。比如你可能在 commonMain 里定义:
expect class AppContext然后在 androidMain 里:
actual typealias AppContext = android.content.Context这看起来很优雅,避免了一个 wrapper class 带来的额外分配和调用开销。但在 Kotlin 1.9.x 的 K1 编译器下,这个写法有一个极其隐蔽的限制:如果 expect class 有成员函数,typealias actual 是无法直接满足的。因为 typealias 本质上只是别名,它不能添加新的 actual 成员实现。当时我的做法是,如果 expect 有成员函数,就必须写一个真正的 actual class 去包装,哪怕内部只是简单的委托。
Kotlin 2.0.0 引入的 K2 编译器对这个行为做了一部分调整。虽然 typealias 依然不能凭空创造成员,但 K2 对 expect/actual 的匹配规则做了更精细的放宽。比如在 K1 里,如果 expect class 有一个 companion object,而你想用 typealias 指向一个已经自带 companion 的现有类,有时会因为 IR 链接阶段的签名匹配过于严格而失败;K2 则改进了 companion 的 actual 匹配逻辑,允许在 typealias 的场景下更灵活地对齐。我去年把项目升级到 Kotlin 2.0.0-Beta3 时测试过这个场景,之前必须用 wrapper class 的 12 个 platform 定义,有 4 个可以改成 typealias,减少了大约 15% 的 platform-specific 代码行数。
不过这里有个坑:如果你用 typealias actual 指向一个第三方库里的类,而这个类在你升级了某个依赖版本后改了构造函数可见性,编译器不会在你改依赖版本时立即报错。因为 expect/actual 的匹配只检查声明签名,不检查实现细节。我曾把 OkHttp 从 4.11.0 升到 4.12.0,某个 Headers 类的内部构造函数变了,但我的 actual typealias PlatformHeaders = Headers 依然编译通过,直到运行时调用才抛 NoSuchMethodError。这种"编译通过,运行时才炸"的特性,在普通的 expect/actual class 里反而更容易被编译器捕捉到,因为 actual class 的构造函数签名必须显式对应。
泛型、构造函数与 IR 的严格对等
说到签名匹配,expect/actual 对泛型的要求比很多人想象的更死板。我在实现一个跨平台的缓存层时,在 common 里定义了:
expect class Cache<K, V>() {
fun get(key: K): V?
fun put(key: K, value: V)
}然后 iOS 侧的 actual 实现一开始用了具体类型去替代