KMP 项目里的 expect/actual 是怎么工作的
KMP 项目里的 expect/actual 是怎么工作的
从一个编译报错开始
去年下半年我在把一个 Android 项目往 KMP 迁移时,碰到了一个挺典型的编译错误。代码结构大概是这样:
// commonMain
expect class PlatformDatabase() {
fun query(sql: String): List<String>
}
// androidMain
actual class PlatformDatabase actual constructor() {
fun query(sql: String): List<String> { ... }
}看起来没问题对吧?但编译器报的是 Expected declaration must not have explicit constructor delegation call,指向的是 expect 声明里的 ()。我盯着这个报错看了十分钟,因为直觉上 expect class PlatformDatabase() 这个写法在 Android Studio 里高亮显示一切正常,甚至 IDE 的代码补全还主动提示我这么写。
真正的问题在于,Kotlin 的 expect 类声明里,那个 () 不是"允许存在"的语法,而是被解释成了主构造函数的显式委托调用——也就是 constructor() : super() 那种形式。但 expect 声明不允许有构造函数体,所以它其实应该写成 expect class PlatformDatabase 不带括号,或者如果确实需要带参数,参数列表直接跟在类名后面而不加 () 的调用语义。
这个细节在 Kotlin 1.9.20 之前的文档里说得比较模糊,1.9.20 之后官方文档加了一段说明,但措辞依然很技术化。我当时是在一个 Kotlin/Native 的 GitHub issue 里翻到有人提了同样的问题,维护者回复说 "parentheses after expect class name are parsed as constructor delegation call, not as parameter list"。
这个经历让我意识到,expect/actual 机制虽然表面上看起来就是"声明一套,平台实现一套",但编译器在背后处理的方式远比想象中复杂。我开始系统地去看它的编译产物和中间表示,发现了不少有意思的东西。
编译器视角:expect 到底留下了什么
KMP 的编译流程里,common 代码会先被编译成一种中间状态,然后再和目标平台的 actual 实现链接。关键点在于:expect 声明在编译后的产物中并不是简单地被替换掉,而是会生成一种特殊的标记符号。
以 Kotlin/JVM 为例,如果你用 javap 去反编译一个包含 actual 实现的 class,会发现 Kotlin 编译器给 actual 类加了一个 @kotlin.jvm.JvmActual 注解(在 Kotlin 1.8.0 之前这个注解是内部的,1.8.0 之后在某些场景下可见)。但这个注解并不是给运行时用的,而是给编译器的链接阶段做校验。
更有意思的是 expect 在 common 编译产物里的残留。Kotlin 编译器会把 expect 声明编译成一种"预期"的元数据,存储在 .kotlin_metadata 文件里。当编译平台代码时,编译器会读取这些元数据,检查 actual 实现是否匹配。这个匹配规则在 Kotlin 1.7.0 到 1.9.0 之间有过几次微妙的变化。
比如 Kotlin 1.7.20 引入了一个变化:actual 类可以实现 expect 类没有显式声明的接口,只要这个接口在 common 代码里通过类型推断能关联到。这个改动导致我们项目里一个原本编译通过的模块在升级后报错了,因为有个 actual 类偷偷实现了一个 expect 里没有的 Closeable 接口,而 1.7.20 之前的编译器会忽略这种"额外实现",1.7.20 之后变成了严格检查。
我当时的 workaround 是在 expect 声明里显式加上 : AutoCloseable(我们用的是 Kotlin 的 AutoCloseable expect 声明),然后所有平台的 actual 都统一实现。这个改动的波及面比预想的大,因为 iOS 端的 actual 实现需要额外处理 Objective-C 的 dealloc 时机问题。
链接阶段的严格匹配规则
expect 和 actual 的匹配不是简单的"同名就行"。Kotlin 编译器在链接阶段会检查一系列属性,我列几个容易踩坑的:
构造函数参数的名字和默认值。这个很多人不知道。如果你的 expect 声明是 expect class Config(timeout: Long = 5000),那么 actual 实现里参数名必须是 timeout,默认值也必须存在且数值相等。Kotlin 1.8.0 之前默认值不匹配只会给 warning,1.8.0 之后变成了 error。我们有个模块的 iOS 实现里写成了 timeoutMs: Long = 5000,升级 Kotlin 版本后直接编译失败,而 Android 端因为参数名一致所以没发现问题,这种平台间的不对称报错很烦人。
类型参数的约束。expect 声明里的泛型约束必须被 actual 完全满足,但反过来 actual 可以有更宽松的约束。比如:
// commonMain
expect class Container<T : Number>
// androidMain
actual class Container<T> // 编译错误,约束变宽松了
// 但如果反过来
// commonMain
expect class Container<T>
// androidMain
actual class Container<T : Number> // 可以编译通过这个不对称性在官方文档里叫 "covariance of expect-actual matching",但实际理解起来就是:actual 必须能被当成 expect 用,所以约束只能更紧不能更松。
伴生对象的匹配。如果 expect 类有 companion object,actual 实现也必须有,而且成员要一一对应。但这里有个坑:如果 expect 里的伴生对象是空的(只有 companion object 没有内容),actual 里可以省略不写吗?答案是 Kotlin 1.9.0 之前可以,1.9.0 之后不行。这个变化没有出现在 release note 的显著位置,我是在升级后批量修复编译错误时才发现的。
平台特定注解的处理
一个实际项目中经常遇到的问题是:actual 实现需要加平台特定的注解,比如 Android 的 @WorkerThread 或者 iOS 的 @Throws。这些注解在 expect 声明里没法写(因为 common 代码看不到平台注解),但写在 actual 里又会不会影响匹配?
测试下来,Kotlin 1.9.0 之后的编译器会把注解纳入匹配检查,但只检查"Kotlin 原生注解"和"跨平台注解"。平台特定的注解会被忽略,不影响 expect/actual 的链接。但这里有个边界情况:如果你用 Kotlin 的 @OptIn 注解在 expect 上标记了一个实验性 API,actual 实现也必须标记同样的 @OptIn,否则编译器会认为"可见性不一致"。
我们项目里遇到过更诡异的情况:一个 expect fun 声明在 common 里加了 @Deprecated,Android 的 actual 实现也加了 @Deprecated 但 level 设成了 DeprecationLevel.ERROR(common 里是 WARNING),结果 iOS 编译通过但 Android 编译失败,报错信息是 "actual declaration has different deprecation level than expected"。最后把两边统一成 WARNING 才解决,因为 DeprecationLevel 被编译器视为函数签名的一部分参与匹配。
多平台库的二进制兼容
当你把 KMP 代码打包成多平台库(比如发布到 Maven)时,expect/actual 的解析方式又不一样了。消费者的编译器需要读取你发布的 .kotlin_metadata 来验证他们自己的 actual 实现(如果他们选择提供的话),或者直接使用你预编译好的平台产物。
这里有个性能相关的细节:Kotlin 1.9.20 引入了新的 KLIB 格式,对 metadata 的存储做了压缩。我们测试过一个包含约 200 个 expect/actual 对的库,旧格式下 KLIB 大小是 2.3MB,新格式降到 1.1MB。但编译时的 metadata 解析时间并没有显著改善,因为瓶颈主要在 IO 而不是解压。
更实际的问题是版本兼容性。如果你的库用 Kotlin 1.9.20 编译,消费者用 1.8.10 编译,metadata 格式不兼容会导致编译器直接报错,而不是 graceful fallback。Gradle 的 kotlin-native-compiler-embeddable 和 kotlin-compiler-embeddable 版本必须严格对齐,这个约束比纯 JVM 库要严格得多。我们内部维护了一个 KMP 库矩阵,记录每个版本组合是否兼容,目前覆盖到 Kotlin 1.7.0 到 2.0.0 的 6 个主要版本。
一个具体的性能测试
我做过一个不算严谨但有一定参考价值的测试:比较 expect/actual 函数调用和普通函数调用的运行时开销。测试场景是定义一个 expect fun calculateHash(data: ByteArray): String,然后在 Android 端用 actual 实现委托给 MessageDigest.getInstance("SHA-256")。
测试方法是用 Kotlin 1.9.20 编译,在 Pixel 7 上跑 100 万次调用,对比三种写法:
1. expect/actual 直接调用
2. 把 actual 实现内联(加 actual inline fun)
3. 不用 expect/actual,直接在 common 里写 JVM 特定代码(作为 baseline)
结果有点意外:方案 1 和方案 3 的运行时间几乎完全一样,都是平均 2.1ms 每次调用(数据量 1KB)。方案 2 内联后降到 1.8ms,但这个提升主要来自避免了函数调用开销,而不是 expect/actual 本身有什么额外成本。
用 Android Studio 的 CPU Profiler 看汇编级 trace,expect/actual 的调用点被编译成了直接的 INVOKESTATIC,和直接调用没有区别。这说明编译器在链接阶段已经彻底把 expect 的间接层消除了,不会留下运行时多态的开销。
但 iOS 端的情况不同。Kotlin/Native 编译的 expect/actual 调用在 debug 构建里会经过一层 objc_retain/objc_release 的桥接,因为 Kotlin/Native 的内存模型需要和 Objective-C 的对象生命周期交互。release 构建带优化后这个开销会消失,但我们在 debug 测试时一度以为 expect/actual 有性能问题,其实是 Kotlin/Native 的调试编译策略导致的假象。
与 Java 的互操作边界
如果你的 KMP 库需要被纯 Java 代码调用,expect/actual 的可见性会变得复杂。Kotlin 编译器默认会把 actual 实现暴露给 Java,但 expect 声明不会生成独立的 Java 可见符号。
具体例子:假设你有一个 expect class Logger 和 Android 端的 actual class Logger,从 Java 代码里你只能看到 actual 的 Logger,看不到 expect 的。这通常没问题,但如果你的 expect 和 actual 有不同的可见性修饰符,比如 expect 是 internal 而 actual 是 public,Java 端会看到 public 的版本,而 Kotlin 端在 common 代码里只能以 internal 访问。
Kotlin 2.0.0 之前这个行为是不一致的:JVM 后端允许 actual 比 expect 更可见,但 Native 后端要求严格一致。2.0.0 之后统一成了必须一致,否则编译错误。我们有个库的 Android 实现里把 internal expect 做成了 public actual,升级 2.0.0 时批量报错了十几个文件。
另一个 Java 互操作的坑是 expect 接口的默认实现。Kotlin 允许 expect interface 带默认方法(用 expect interface { fun foo() = defaultImpl } 这种写法),但 actual 实现类在 Java 里调用时,默认方法的桥接生成规则和普通 Kotlin 接口不同。具体来说,expect 接口的默认方法不会生成 -DefaultImpls 桥接类,而是直接内联到 actual 实现里。这导致一个我们调试了很久的 bug:Java 代码通过反射检查接口方法时,找不到预期的合成方法签名。
Gradle 源码集的配置陷阱
expect/actual 能工作的一个前提是 Gradle 的 kotlin.sourceSets 配置正确。KMP 插件的源码集依赖关系是:__PLACEHOLDER_ITALIC_0__Test 依赖 commonTest 和对应平台的 *Main。
一个不太直观的限制是:你不能在 commonMain 里直接引用平台源码集的类型,即使你知道某个平台会提供 actual。这是设计上的,但新手很容易误解。比如有人在 commonMain 里写:
// 错误示范
expect class PlatformFile
fun readFile(): PlatformFile? {
return if (isAndroid) androidSpecificFile else iosSpecificFile // 编译错误
}commonMain 的编译完全不感知平台源码集的存在,它只能看到 expect 声明。平台相关的条件逻辑必须通过 expect/actual 的函数来封装,或者使用 kotlinx.coroutines 那种 expect 的 Dispatchers 模式。
我们项目早期有个反模式:在 commonMain 里定义 expect val isAndroid: Boolean,然后各平台 actual 实现。这看起来无害,但实际上破坏了 common 代码的平台无关性语义。后来重构成了 expect object Platform 封装所有平台判断,至少把平台相关逻辑收敛到了一个入口。
Gradle 配置还有个细节:KMP 插件 1.9.20 之前,androidTarget() 和 jvm() 同时存在时,commonMain 的编译会走 JVM 后端,这导致某些 Kotlin/Native 特有的语法(比如 @SharedImmutable)在 commonMain 里即使只是 expect 声明也会报错,因为编译器前端需要解析所有语法。1.9.20 之后改了行为,commonMain 用更宽松的"通用前端"编译,但实验性注解的启用状态依然需要各平台一致配置。
Kotlin 2.0 编译器的新变化
K2 编译器对 expect/actual 的处理有底层重构。最明显的一个变化是错误报告:在 K1 编译器里,expect 声明找不到对应 actual 的错误通常只报在 expect 声明的位置;K2 编译器会同时在 expect 和所有相关平台源码集里提示,并且错误信息会指明是哪个平台缺失了实现。
我们迁移到 K2 的过程中,发现 K2 对 actual 类型的推断更严格。比如:
// commonMain
expect fun createBuffer(): Buffer
// androidMain
actual fun createBuffer() = ByteBuffer.allocate(1024) // K2 报错:类型推断不匹配K1 里这个能过,因为 ByteBuffer 是 Buffer 的子类型,编译器做了协变推断。K2 里要求 actual 的返回类型显式声明为 Buffer,或者 actual 的实现用 return 语句而不是单表达式形式。这个变化在 Kotlin 2.0.0 的 release note 里提到了,叫 "stricter type inference for actual declarations",但实际迁移时影响面比预期大。
K2 还改进了 expect/actual 的 IDE 导航。Android Studio Iguana 之后,点击 expect 声明的 gutter icon 会显示所有平台的 actual 实现,并且能检测到过时的 actual(比如 expect 加了新参数但某个平台没更新)。这个功能依赖 K2 的 FIR 中间表示,所以在 K1 模式下不可用。
一个实际的模块化建议
基于这些踩坑经验,我对 KMP 项目里的 expect/actual 使用有一个比较保守的建议:尽量把 expect/actual 的声明集中在少数几个"平台适配层"模块里,而不是分散在各个业务模块。
我们的项目结构现在是这样:
core-platform 模块:包含所有 expect 声明和 actual 实现,对外暴露的是普通 Kotlin 接口core-platform,只使用普通接口,不写任何 expect/actual这个结构的好处是隔离了编译器版本的敏感区域。当我们升级 Kotlin 版本时,只需要确保 core-platform 模块编译通过,业务模块基本不受影响。代价是多了一层接口包装,以及 core-platform 模块本身会变得比较厚。
具体实现上,core-platform 里的 expect 声明通常不是直接暴露的,而是包级私有(internal),然后由一个公开的 object 或 class 来代理。比如:
// core-platform/commonMain,internal
internal expect class PlatformFileSystemImpl() : FileSystem
// 对外暴露
object PlatformFileSystem : FileSystem by PlatformFileSystemImpl()这样业务代码看到的是 PlatformFileSystem,一个普通的单例对象,完全感知不到 expect/actual 的存在。测试时也可以 mock FileSystem 接口,不需要处理 actual 的替换问题。
这个模式在 Kotlin/Native 上有个额外好处:Objective-C 导出时,PlatformFileSystem 会生成干净的 ObjC 头文件,而 PlatformFileSystemImpl 因为是 internal 不会导出,避免了实现细节暴露给 Swift 代码。
关于 `expect` 对象和 `actual` 对象的一个边界情况
Kotlin 允许 expect object 和 actual object,但它们的初始化时机在不同平台有微妙差异。JVM 上 actual object 遵循普通的 singleton 初始化规则,首次访问时初始化。Kotlin/Native 上 object 的初始化在 1.9.0 之前是编译期确定的,1.9.0 之后改成了懒初始化以支持新的内存模型,但 expect/actual 的链接方式没变。
我们遇到过一个 bug:iOS 端的 actual object 包含一个 init 块,里面访问了 Kotlin/Native 的 Platform 对象来获取系统版本。在 debug 构建里这个 init 块执行了两次,因为编译器生成了两个不同的访问路径:一个是 expect 声明的桥接,一个是 actual 实现的直接引用。这个行为在 Kotlin 1.9.20 被修复,但 release note 里没提,我是在 YouTrack 上翻到 KT-58291 这个 ticket 才知道的。
workaround 也很简单:把 expect object 改成 expect class 配 companion object,或者把初始化逻辑移到 actual 里的一个显式 initialize() 函数中,由调用方控制时机。
最后说一个编译缓存的问题
KMP 的编译缓存(build cache)对 expect/actual 的处理在 Gradle 8.0 之前有 bug。如果修改了一个 actual 实现但没有改 expect 声明,有时候增量编译不会重新链接 common 代码,导致运行时的行为是旧的 actual 实现和新的 common 逻辑组合,可能产生诡异的不一致。
这个 bug 的触发条件是:actual 文件的时间戳更新但内容 hash 没变(比如 touch 了文件),Gradle 的 Kotlin 编译 task 会跳过,但 KMP 的链接 task 依赖的是另一个输入集合。Gradle 7.6 的 Kotlin 插件 1.8.20 版本里,这个问题表现为 compileKotlinMetadata task 的 UP-TO-DATE 判断错误。
我们的 workaround 是在 CI 里禁用 KMP 的编译缓存,本地开发时遇到可疑行为就 ./gradlew clean。Gradle 8.4 + Kotlin 1.9.20 之后这个 bug 被修复,因为 KMP 插件重新梳理了 task 的输入输出关系,把 actual 实现的内容 hash 纳入了链接 task 的输入。
本地开发的另一个经验:--rerun-tasks 对 KMP 项目来说开销很大,因为每个平台都要重新编译。如果只是调试单个平台的 actual 实现,可以用 -Pandroidx.disableCompileSdkChecks 类似的属性跳过非目标平台,但这需要项目配置支持。我们写了一个 Gradle 插件来自动根据 org.gradle.project.target 属性过滤源码集,把 KMP 的多平台编译降级成单平台,调试效率提升不少。
这些就是我在实际项目里和 expect/actual 机制打交道积累的一些细节。它工作起来确实就是"声明一套、平台实现一套",但编译器的严格检查、版本演进中的行为变化、以及和 Gradle 构建系统的交互,让这个过程比表面看起来复杂得多。如果你正在迁移 KMP 或者设计多平台库的 API 边界,建议把 expect/actual 的集中度和编译器版本兼容性作为优先考虑的约束条件,而不是等到各个业务模块都散落着平台相关代码时再来重构。