- IDE 只认公共 API
- 编译 JVM target 时,编译器又可能看到依赖库的 JVM
.jar,
也就是同一行代码可能出现 IDE 报错但能编译通过、IDE 跳到一个函数但最终执行的是另一个函数,甚至 IDE 类型推断正确而 JVM 编译失败之类的问题。
所以 Separate Compilation 的作用就是 commonMain 分析时只能看属于自己的 metadata KLIB,而平台 .jar、平台 KLIB 留给平台阶段处理 。
因为以前 KMP 一直有一个离谱的场景:同一个 commonMain,跨一个 Module 语义居然就变了,比如这个代码在同一个 Module 里面,规则其实一直很正常:
// jvmMain
fun foo() {}
// commonMain
fun test() {
foo() // IDE 和 compiler 都报 Unresolved reference
}
这里其实很好理解,因为 commonMain 以后还可能编译成 JS、Native,JVM 独有的 foo() 当然不能随便调用,真要这么做就需要用 expect/actual 把公共声明和平台实现明确连起来。
但是问题发生在依赖另一个 KMP Module 之后,比如 library 只有 JVM 代码:
// lib/jvmMain
class Foo
// app/commonMain
fun main() {
Foo()
}
IntelliJ 会把 Foo() 标红,因为它分析 app/commonMain 时看到的是 library 发布的 metadata KLIB, Foo 只存在于 lib/jvmMain。
但过去真正执行 JVM compilation 的时候,app/commonMain 会和 app/jvmMain 一起面对 library 的 JVM .jar,这个 .jar 里恰好有 Foo,所以 compiler 又觉得它合法,然后就出现很诡异的情况:
IDE 告诉你这段跨平台代码不存在这个 API,Gradle 却能顺利编过去。
这个大家应该在 Android Studio 都经历过类似的场景,就类似这张图:
右边的
app/commonMain在 JVM resolve 的时候,居然可以穿过 metadata KLIB,继续落到下面.jar中的Foo,而 IDE 只看上半层 metadata KLIB,所以就有了一脸懵的 IntelliJ。
其实这里的 metadata KLIB 也不用想复杂了,一个 KMP library 会给 JVM 发布 .jar,也会给 common / intermediate source set 发布 metadata KLIB,后者主要是为了告诉消费者「公共世界里有哪些声明」,没有对应的平台实现 body。
而旧平台编译会把平台 source set 和相关的 common/intermediate source set 一起对着 platform artifacts 编译,这就是依赖穿透出现的根源。
commonTest 也有同样的问题:
// app/jvmMain
class Foo
// app/commonTest
fun main() {
Foo() // IDE 报错,过去 JVM compilation 可以通过
}
所以这意味着你以为自己写的是 common test,但代码可能也偷偷依赖 JVM implementation,然后一旦换到 JS 或 Native,这个假设才暴露出来。
所以这次 Separate Compilation 修改后,每个 common fragment 只能看自己的依赖:
现在 app/commonMain 分析 Foo() 的时候,只能查询 metadata KLIB 那一层,而 library 的 JVM .jar 虽然还是在的,而且后面的 JVM platform compilation 也要用,但它已经不能参与 commonMain 的声明解析了,也就是统一到会同时在 IDE 和 compiler 中报错。
这个也挺好理解,Kotlin 想强制如果 Foo 的确是一个允许 common code 使用的能力,那么 library 就应该明确写出来:
// lib/commonMain
expect class Foo()
// lib/jvmMain
actual class Foo
// app/commonMain
fun main() {
Foo() // OK
}
这样 metadata KLIB 里就真的会有 Foo 这个公共声明,然后 app/commonMain 也有合法依据去调用,等进入 JVM 阶段,再把这个 declaration actualize 到 lib/jvmMain 的实现上。
修复后现在只能通过 expect/actual 建立显式关系,这样也能解决了之前可能存在的编译结果被改了的 Bug ,比如 library 里故意有两个合法函数:
// lib/commonMain
fun foo(x: Any) = "common"
// lib/jvmMain
fun foo(x: String) = "platform"
// app/commonMain
println(foo(""))
IDE 分析 app/commonMain 时只看到 metadata KLIB,所以只有 foo(Any),Go to Declaration 也会跳到这里,但是旧的 JVM compilation 又多看到了 .jar 里的 foo(String),这时候 Kotlin 做 overload resolution 时自然认为 String 更具体,所以最后程序打印 "platform"。
等于是编辑器告诉你调用的是 A,最终 binary 实际调用了 B ,然后现在 Separate Compilation 开启后 common 阶段看不到 JVM overload,IDE、compiler 和 runtime 最终都会落到 foo(Any),输出 "common"。
另外还有类型推断,比如 ClickEvent 和 ScrollEvent 这个例子,common 里的两个 expect class 都只实现 Event,所以 IDE 很自然地把条件表达式的共同类型推成 Event,但是 JVM 的两个 actual class 又额外实现了 Serializable :
// commonMain
interface Event { val name: String }
expect class ClickEvent(...) : Event
expect class ScrollEvent(...) : Event
fun currentEvent() =
if (clicked()) ClickEvent("buy") else ScrollEvent(120)
fun report() = currentEvent().name
这在旧的 JVM compilation 下,common 代码重新对着 JVM JAR 做分析,这时候两个实际类同时拥有 Event 和 Serializable,就会推断结果可能落成更宽的 Any,.name 也随即消失,然后恶心的来了: IDE 完全没问题,只有 JVM compile 报错。
然后现在 Separate Compilation 场景下,currentEvent() 已经在 common dependency world 里完成解析和类型推断,结果就是 Event,之后 JVM 阶段消费这个已经确定的 common 结果,不再拿 actual class 的额外超类型回头改变 common 的推断。
所以这个修改最大的变化是在固定 common source set 的语义:一个 common 函数选哪个 overload、推成什么类型,不能随着最后编译 JVM、JS 还是 Native 而发生变化。
| 旧代码情况 | 新方案 | 处理 |
|---|---|---|
| `commonMain` 偷用了依赖库 `jvmMain` / `iosMain` 声明 | **直接编译失败** | 把公共能力声明成 `expect/actual`,或者把调用下沉到 platform source set |
| `commonTest` 偷用了 `jvmMain` 等平台代码 | **测试代码编译失败** | 用 `expect/actual` 暴露公共接口,或把测试移到 `jvmTest` |
| common 调用存在 common/platform 同名 overload | **最终选择的 overload 可能变化** | 检查以前实际上落到了哪个平台 overload |
| common 的类型推断受 platform `actual` 类型影响 | **推断结果可能变化,反而可能把旧编译错误修掉,也可能暴露依赖这种行为的代码** | 显式声明 common 类型边界 |
对跨平台工程是一次依赖边界的强约束:升级后 common 代码偷用平台 API 会直接报错,需及时用 expect/actual 补齐声明,适合多 target 的 KMP 项目尽早适配。