为Kotlin多平台模块提供更可靠的编译方案

Kotlin 2.5.0-Beta1 为 KMP 引入实验性分离编译,解决 IDE 与编译器分析不一致问题并支持公共源集增量编译。

中文
复制
Kotlin Blog 官方题图:黑底上两块紫到洋红的渐变平行四边形拼成 K 形,中央白字写着 Kotlin Blog

Kotlin Multiplatform 项目目前的编译方式虽然能用,但有时会带来出乎意料、难以预测的行为。例如:

  • IDE 的分析与编译器在重载、类型推断或代码能否编译上意见不一致——而且 IDE 比编译器更严格。
  • 你的 commonTest 代码可能意外调用到平台源集中的内容,打破你的预期。 在 Kotlin 2.5.0-Beta1 中,我们为 KMP 引入了一种可选的“分离编译”方式,同时解决这两个问题:让编译结果与 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)。 在这样的设置下,编译器可以产出以下产物:

  • 每个平台源集对应一个平台产物,例如 JVM 上的 jvmMain – *.jar,其他平台上的 *.klib。
  • 每个公共源集或中间源集对应一个元数据 KLIB(例如 commonMain 或 nativeMain)。元数据 KLIB 包含所编译源集的全部声明,但不含函数体。 那么问题究竟出在哪里?

公共代码针对平台产物编译,IDE 却不这么认为

在编译某个平台源集时,其中的代码(jvmMain)以及所有相关共享源集(commonMain 及其他中间源集)中的代码,都会针对其依赖项的平台产物进行编译。这样一来,commonMain 中的代码就有机会隐式解析到某个 jvmMain 声明。 如果你本来就预期会这样,那大概没问题。但如果 commonMain 只能调用依赖项 commonMain 中的声明(即依赖项元数据 KLIB 中列出的那些),整个行为会更透明、更可预测。 IntelliJ IDEA 的代码分析正是这样假设的。由于 jvmMain 声明不包含在 KLIB 元数据中,当 commonMain 引用了未在公共代码中显式声明的内容时,IntelliJ IDEA 就会报未解析引用错误:

// lib/jvmMain
class Foo

// app/commonMain
fun main() {
 Foo() // Unresolved reference in the IDE, no error during compilation
}

同样的机制也作用于 commonTest 源集,因为测试同样“依赖”主代码:针对 jvmMain 编译的 commonTest 可以成功调用平台代码中的声明,而不是被限制在 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 来解析公共代码,而该 JAR 中是类的 eventsLib/jvmMain 版本。有了这两个父类型,currentEvent() 调用的返回类型推断得出的是 Any,而它没有 .name,于是 JVM 编译失败。

启用分离编译后,app/commonMain 代码只针对 eventsLib/commonMain 声明解析。因此在编译 app 的 JVM 目标时,编译器已经拿到了解析好的公共代码,并已为 currentEvent() 函数推断出 Event 返回类型。JVM 编译单独进行,无需再做任何推断,顺利通过。

如何启用分离编译

分离编译功能目前处于实验阶段,默认关闭。如需启用,请在 gradle.properties 文件中添加以下编译器选项(注意下方列出的已知问题):

kotlin.kmp.separateCompilation=true

已知问题

要让分离编译在 KMP 项目中正常工作,多平台库的作者必须发布 metadata KLIB。这是 KMP Gradle 插件发布任务的默认行为,但新的编译方案让 metadata KLIB 变得不可或缺。

KMP 分离编译目前还有一些已知问题正在修复中:

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

反馈

欢迎就分离编译提出任何反馈:在专门的 YouTrack issue 下留言即可。

  • 分享

上一篇 Kotlin 2.4.20 发布

感谢反馈,我们已收到!

来源: JetBrains Blog← 返回首页