KMP 又改了编译流程, Separate Compilation 禁止了 `commonMain` 的依赖穿透

最近 JetBrains 又给 KMP 的编译流程加了 separate compilation,比如以前同一段 commonMain 代码,IDE 和真正执行 Gradle 编译的时候,以前可能会产生分歧,比如:

  • IDE 只认公共 API
  • 编译 JVM target 时,编译器又可能看到依赖库的 JVM .jar,

也就是同一行代码可能出现 IDE 报错但能编译通过、IDE 跳到一个函数但最终执行的是另一个函数,甚至 IDE 类型推断正确而 JVM 编译失败之类的问题。

所以 Separate Compilation 的作用就是 commonMain 分析时只能看属于自己的 metadata KLIB,而平台 .jar、平台 KLIB 留给平台阶段处理 。

因为以前 KMP 一直有一个离谱的场景:同一个 commonMain,跨一个 Module 语义居然就变了,比如这个代码在同一个 Module 里面,规则其实一直很正常:

kotlin 复制代码
// jvmMain
fun foo() {}
​
// commonMain
fun test() {
    foo() // IDE 和 compiler 都报 Unresolved reference
}

这里其实很好理解,因为 commonMain 以后还可能编译成 JS、Native,JVM 独有的 foo() 当然不能随便调用,真要这么做就需要用 expect/actual 把公共声明和平台实现明确连起来。

但是问题发生在依赖另一个 KMP Module 之后,比如 library 只有 JVM 代码:

kotlin 复制代码
// 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 也有同样的问题:

kotlin 复制代码
// 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 就应该明确写出来:

kotlin 复制代码
// 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 里故意有两个合法函数:

kotlin 复制代码
// 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 :

kotlin 复制代码
// 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 类型边界

链接

blog.jetbrains.com/kotlin/2026...

相关推荐
吴声子夜歌1 小时前
Nginx应用与运维——Nginx Web服务应用实战(静态文件服务器的搭建)
运维·前端·nginx
y = xⁿ1 小时前
关于Agent工程落地
前端·人工智能·python
执明wa1 小时前
Android RecyclerView 实战:点击频道栏切换对应内容
android·开发语言·windows·microsoft·android studio
m0_738185821 小时前
Flutter 鸿蒙化实战:flutter_quill 适配 OpenHarmony,富文本编辑器
flutter·华为·harmonyos·鸿蒙
m0_738185821 小时前
Flutter 鸿蒙化实战:flutter_quick_video_encoder 适配 OpenHarmony,逐帧编码视频
flutter·华为·音视频·harmonyos·鸿蒙
web打印社区2 小时前
JS 静默打印怎么做:纯前端为什么不行,以及最小可跑通写法
开发语言·前端·javascript·websocket·网络协议·http·pdf
web打印社区2 小时前
HTML5 静默打印:前端页面怎么调用打印机且尽量不弹窗
前端·javascript·vue.js·pdf·html·html5
Python大数据分析2 小时前
开源免费、AI 驱动的 Web 打印设计器 OpenPrint:从拖拽设计到 ERP 对接全流程实战
前端·人工智能·开源
新鲜势力呀2 小时前
PHP 定时数据同步实战:从第三方接口超时到异步同步 + 增量更新架构优化全过程
android