Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

Kotlin 2.5.0-Beta1 为 KMP 引入独立编译方案,提升编译一致性与可预测性

#kotlin multiplatform#独立编译#编译器#元数据 klib#增量编译

当前的 Kotlin Multiplatform 项目编译方式可以正常工作,但有时会导致意外或难以预测的行为。例如:

  • IDE 分析在重载、类型推断,或代码究竟能否编译方面与编译器不一致——其中 IDE 比编译器更严格。
  • 你的 commonTest 代码可能会意外调用平台源集中的某个内容,从而打破你的假设。

在 Kotlin 2.5.0-Beta1 中,我们为 KMP 引入了一种可选的“分离编译”方式,它解决了这两个问题:使编译结果与 IDE 分析保持一致,并更一致地指向从公共源集调用库代码时出现问题的调用。额外好处是,这种方式使我们能够为公共源集实现增量编译。

分离编译功能处于实验性阶段,默认禁用。要选择启用,请将以下编译器选项添加到 gradle.properties 文件中(请注意已知问题):

$ properties
kotlin.kmp.separateCompilation=true

让我们进一步了解该问题及其解决方案。

多平台声明如何在模块内解析

在模块内,多平台代码的行为是可预测的:

$ kotlin
// jvmMain
fun foo() {}

// commonMain
fun test() {
    foo() // IDE 中和编译期间均报告未解析引用
}

编译器和 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 中的代码分析已经假定的行为。由于 jvmMain 声明不包含在 KLIB 元数据中,当 commonMain 引用未在公共代码中显式声明的内容时,IntelliJ IDEA 会报告未解析引用错误:

$ kotlin
// lib/jvmMain
class Foo

// app/commonMain
fun main() {
    Foo() // IDE 中为未解析引用,编译期间没有错误
}

[LOADING...]

同样的机制也作用于 commonTest 源集,因为测试也“依赖”主代码:针对 jvmMain 编译的 commonTest 可以成功调用平台代码中的声明,而不是被限制在 commonMain 中的声明。IDE 会报告同样的未解析引用问题:

$ kotlin
// app/jvmMain
class Foo

// app/commonTest
fun main() {
    Foo() // IDE 中为“未解析引用”,编译期间没有错误
}

让我们看看分离编译如何解决这些问题。

分离编译如何解决该问题

KMP 分离编译在编译 KMP 项目的公共源集时,会让编译器表现得更严格(与 IDE 的预期保持一致)。这会让整体体验更可预测,不过你可能需要调整旧代码,以通过更严格的编译期检查。

[LOADING...]

让我们看看具体代码示例,以及在不同编译方案切换时它们会发生什么变化。

IDE 中标红的代码,编译期间却没有错误

当你开启分离编译后,这段代码将不再能编译,因为编译器现在会严格使用元数据 KLIB 声明来解析公共代码调用:

$ kotlin
// lib/jvmMain
class Foo

// app/commonMain
fun main() {
    Foo() // IDE 中为未解析引用,现在也是编译错误
}

修复方法是显式展示这种联系:在 lib/commonMain 中声明一个 expect class,并将平台上的 Foo 类改为 actual class:

$ kotlin
// lib/commonMain
expect class Foo()

// lib/jvmMain
actual class Foo

// app/commonMain
fun main() {
    Foo() // 没问题
}

[LOADING...]

commonTest 同理:使用分离编译后,测试代码将无法解析到 jvmMain 中的平台调用。如果这正是你想测试的内容,请在 commonMain 中声明相应的 expect:

$ kotlin
// app/jvmMain
actual class Foo

// app/commonMain
expect class Foo

// app/commonTest
fun main() {
    Foo() // 可以,调用 jvmMain 中的 Foo
}

IDE 与编译器对所选重载不一致

当 lib/commonMain 和 lib/jvmMain 中声明了多个重载时,IDE 目前对 app/commonMain 中调用的解释与编译器不同:

$ kotlin
// lib/commonMain
fun foo(x: Any) = "common"

// lib/jvmMain
fun foo(x: String) = "platform"

// app/commonMain
fun main() {
    // 对 foo() 执行“转到声明”会跳转到 lib/commonMain,
    // 但运行时打印的是 "platform"
    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 对应项拥有不同的超类型,因此你可能合理地得到以下代码结构:

$ kotlin
// 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 — 序列化事件会在会话中排队或存储
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: 未解析引用 'name',仅在 JVM 上出现

IDE 只匹配 commonMain 声明,这使它能够(正确地)将 currentEvent() 的返回类型推断为 Event,并解析 .name 引用。

然而在 JVM 上,该库为每个 actual 类声明了一个额外的超类型。为 JVM 编译 app 时,编译器目前会针对 eventsLib JAR 解析公共代码,而该 JAR 中具有 eventsLib/jvmMain 版本的类。由于有两个超类型,currentEvent() 调用的返回类型推断结果为 Any,而 Any 没有 .name,于是 JVM 编译失败。

启用分离编译后,app/commonMain 代码只会针对 eventsLib/commonMain 声明进行解析。因此,在为 app 编译 JVM 目标时,编译器已经拥有解析后的公共代码,并已为 currentEvent() 函数推断出 Event 返回类型。JVM 编译单独运行,无需推断任何内容,并能成功完成。

如何启用分离编译

分离编译功能处于实验性阶段,默认禁用。要选择启用,请将以下编译器选项添加到 gradle.properties 文件中(请注意下面的已知问题):

$ properties
kotlin.kmp.separateCompilation=true

已知问题

为了让分离编译在 KMP 项目中正确工作,多平台库的作者发布元数据 KLIB 很重要。这是 KMP Gradle 插件发布任务的默认行为,但新的编译方案使元数据 KLIB 变得必不可少。

KMP 分离编译存在一些已知问题,目前仍在修复中:

  • cinterop 的 commonization 存在一些问题(例如 KT-88178 或 KT-41509)。这些问题不太可能影响普通 KMP 项目,但如果你使用 C 互操作,请谨慎启用分离编译模式。
  • 包含公共源集且只声明了一个目标的 KMP 模块目前不受分离编译影响。
  • 还有一些较小的问题,例如 KT-88148。

发送反馈

我们非常感谢你对分离编译的任何反馈:请在专门的 YouTrack 问题中留言。