Kotlin
A concise multiplatform language developed by JetBrains
Kotlin Multiplatform 模块:更可靠的编译方案
Kotlin Multiplatform 项目当前的编译方式能够正常工作,但有时会出现意外或难以预测的行为。例如:
- 在重载选择、类型推断,甚至代码能否通过编译等问题上,IDE 分析与编译器的结果可能不一致,而且 IDE 的检查更严格。
commonTest中的代码可能意外调用平台源集中的声明,打破你的预期。
在 Kotlin 2.5.0-Beta1 中,我们为 KMP 引入了可选的“独立编译”(separate compilation)方案,以解决这两个问题:让编译结果与 IDE 分析保持一致,并更一致地指出公共源集中存在问题的库代码调用。此外,这一方案还让我们能够为公共源集实现增量编译。
独立编译目前是一项实验性功能,默认禁用。要启用此功能,请在 gradle.properties 文件中添加以下编译器选项(请留意已知问题):
kotlin.kmp.separateCompilation=true
下面我们详细了解这些问题及其解决方案。
模块内如何解析多平台声明
在同一模块内,多平台代码的行为符合预期:
// jvmMain
fun foo() {}
// commonMain
fun test() {
foo() // Unresolved reference in IDE and during compilation
}
编译器与 IDE 的判断一致:commonMain 中的代码也会编译到其他平台,例如 Kotlin/JS,而 jsMain 中可能并没有 fun foo() 声明。expect/actual 声明正是为了解决这一问题而设计的:将平台特有声明与公共声明显式关联起来。
如果 foo() 函数声明在依赖项中(同一项目中的另一个模块,或 Kotlin 标准库、kotlinx-coroutines 等二进制制品),你可能也会认为这个引用应被标记为无法解析。遗憾的是,在当前编译方案下,IDE 与编译器恰恰会在这里产生分歧。
模块之间如何解析多平台声明
要理解问题的根源,我们先仔细看看当前的编译配置。
Kotlin Multiplatform 的编译配置
在 KMP 项目中,包含公共代码的模块通常由多个源集组成:在上面的示例中,包括一个共享源集(commonMain),以及为已声明目标配置的平台源集(Kotlin/JVM 对应 jvmMain)。
在这种配置下,编译器可以生成以下制品:
- 为
jvmMain这样的每个平台源集生成平台制品:JVM 上为*.jar,其他平台上为*.klib。 - 为每个公共源集或中间源集(例如
commonMain或nativeMain)生成元数据 KLIB。元数据 KLIB 包含被编译源集中的所有声明,但不包含实现体。
那么,问题出在哪里?
公共代码依据平台制品编译,与 IDE 的判断不一致
编译平台源集时,该源集(jvmMain)中的代码以及所有相关共享源集(commonMain 和其他中间源集)中的代码,都会依据依赖项的平台制品进行编译。此时,commonMain 中的代码可能会隐式解析到 jvmMain 中的声明。
如果这正是你预期的行为,通常不会有什么问题。不过,如果 commonMain 只能调用依赖项的 commonMain 中的声明(即依赖项元数据 KLIB 中列出的声明),行为就会更加透明、可预测。
IntelliJ IDEA 的代码分析目前正是基于这一假设。由于 KLIB 元数据不包含 jvmMain 中的声明,当 commonMain 引用公共代码中未显式声明的内容时,IntelliJ IDEA 会报告无法解析引用的错误:
// lib/jvmMain
class Foo
// app/commonMain
fun main() {
Foo() // Unresolved reference in the IDE, no error during compilation
}

同样的机制也适用于 commonTest 源集,因为测试也“依赖”主代码:当 commonTest 依据 jvmMain 编译时,它可以成功调用平台代码中的声明,而不只限于 commonMain 中的声明。IDE 则会报告同样的无法解析引用的问题:
// app/jvmMain
class Foo
// app/commonTest
fun main() {
Foo() // "Unresolved reference" in the IDE, no error during compilation
}
下面看看独立编译如何解决这些问题。
独立编译如何解决问题
KMP 独立编译让编译器在编译 KMP 项目的公共源集时执行更严格的检查,与 IDE 的预期保持一致。这让整体行为更可预测,不过你可能需要调整现有代码,以满足更严格的编译时检查。

我们通过具体代码示例,看看切换编译方案后会发生哪些变化。
IDE 中代码标红,编译却不报错
启用独立编译后,以下代码将无法通过编译,因为编译器现在会严格依据元数据 KLIB 中的声明来解析公共代码中的调用:
// lib/jvmMain
class Foo
// app/commonMain
fun main() {
Foo() // Unresolved reference in IDE, now also a compilation error
}
解决方法是建立显式关联:在 lib/commonMain 中声明一个 expect class,并将平台上的 Foo 类改为 actual class:
// lib/commonMain
expect class Foo()
// lib/jvmMain
actual class Foo
// app/commonMain
fun main() {
Foo() // ok
}

commonTest 也是如此:启用独立编译后,测试代码中的调用将不会解析到 jvmMain 中的平台声明。如果你希望测试的正是这些平台代码,请在 commonMain 中添加相应的 expect 声明:
// app/jvmMain
actual class Foo
// app/commonMain
expect class Foo
// app/commonTest
fun main() {
Foo() // Ok, calls Foo from jvmMain
}
IDE 与编译器选择了不同的重载
当 lib/commonMain 和 lib/jvmMain 中声明了多个重载时,IDE 目前对 app/commonMain 中调用的解析与编译器不同:
// lib/commonMain
fun foo(x: Any) = "common"
// lib/jvmMain
fun foo(x: String) = "platform"
// app/commonMain
fun main() {
// "Go to declaration" on foo() jumps to lib/commonMain,
// but at runtime "platform" is printed
println(foo(""))
}
IDE 假定公共代码只能依赖公共声明,因此会将 foo("") 调用解析到 lib/commonMain 中的 fun foo(x: Any) 声明。但在当前的实际编译过程中,app/commonMain 可以看到两个声明,并选择更具体的 fun foo(x: String) 重载。运行时会输出“platform”。
使用新的编译方案后,该调用会解析到 lib/commonMain 中的 fun foo(x: Any),运行时会输出“common”。
意外的类型推断结果
还有一种更棘手的情况:错误不是在 app/commonMain 的调用位置触发,而是在平台代码中触发。在下面的示例中,IDE 并未发现问题,但 JVM 编译却会失败。
actual 类可以具有与对应 expect 类不同的超类型,因此出现以下代码结构是合理的:
// eventsLib/commonMain
interface Event { val name: String }
expect class ClickEvent(target: String) : Event { override val name: String }
expect class ScrollEvent(offset: Int) : Event { override val name: String }
// eventsLib/jvmMain — serialized events get queued or stored in a session
actual class ClickEvent actual constructor(target: String) : Event, Serializable { /* ... */ }
actual class ScrollEvent actual constructor(offset: Int) : Event, Serializable { /* ... */ }
// app/commonMain
fun currentEvent() = if (clicked()) ClickEvent("buy") else ScrollEvent(120)
fun report() = currentEvent().name // e: Unresolved reference 'name' only on the JVM
IDE 只匹配 commonMain 中的声明,因此能够正确地将 currentEvent() 的返回类型推断为 Event,并解析 .name 引用。
然而在 JVM 上,该库为每个 actual 类额外声明了一个超类型。当前,为 JVM 编译 app 时,编译器会依据 eventsLib 的 JAR 解析公共代码,其中包含的是这些类的 eventsLib/jvmMain 版本。由于存在两个超类型,currentEvent() 调用的返回类型会被推断为 Any,而它没有 .name,因此 JVM 编译失败。
启用独立编译后,app/commonMain 中的代码只会依据 eventsLib/commonMain 中的声明进行解析。因此,在编译 app 的 JVM 目标时,编译器已经完成了公共代码的解析,并将 currentEvent() 函数的返回类型推断为 Event。JVM 编译单独进行,无需再做类型推断,因而能够顺利完成。
如何启用独立编译
独立编译目前是一项实验性功能,默认禁用。要启用此功能,请在 gradle.properties 文件中添加以下编译器选项(请留意下文中的已知问题):
kotlin.kmp.separateCompilation=true
已知问题
要让独立编译在 KMP 项目中正常工作,多平台库作者必须发布元数据 KLIB。虽然这已经是 KMP Gradle 插件发布任务的默认行为,但在新的编译方案下,元数据 KLIB 成为了必不可少的部分。
KMP 独立编译仍存在一些已知问题,我们正在修复:
- cinterop 公共化存在一些问题(例如 KT-88178 或 KT-41509)。这些问题通常不会影响一般的 KMP 项目,但如果你使用了 C 互操作,请谨慎启用独立编译模式。
- 对于包含公共源集、但只声明了一个目标的 KMP 模块,独立编译目前不会产生影响。
- 还有一些较小的问题,例如 KT-88148。
本博文英文原作者:Aleksey Zamulla
本文由 AI 辅助翻译,并经人工审校。如发现任何问题,欢迎在评论区留言指正。