发布于 2026-01-06 1 阅读
0

Kotlin编译器插件和多平台二进制文件

Kotlin编译器插件和多平台二进制文件

过去半年,我一直在为 Compose 编译器插件开发多平台支持。虽然它之前已经支持 Kotlin IR,但有些限制条件并不适用于 JVM,却适用于其他 Kotlin 目标平台。很多新的限制条件都出乎我的意料,所以,我自然而然地想和大家分享一下,把这些想法整理出来。

友情提示:这些描述大多源于我个人的理解,因此可能存在一些我遗漏或误解的技术细节。如果您发现了此类问题,请与我联系,我会进行更正!

接下来是第一个话题!

串行红外

Kotlin 依赖项通常以两种格式分发:.jarJVM 版本和.klib其他平台版本:

  • .jar文件包含 JVM 字节码,每个模块的字节码都是单独编译的。Kotlin 编译器除了初始编译之外,不会对它们进行任何后处理。
  • .klib文件包含序列化的中间表示(IR),这些IR会在单独的编译步骤中作为平台特定的二进制文件连接起来。因此,当您发布.klib库时,Kotlin编译器只负责一部分工作,即将源代码处理成更紧凑的格式。所有的优化、内联以及编译成JS/汇编代码等操作都由依赖项使用者在最后一步完成。

多平台模块的额外步骤也被定义为不同的 Gradle 任务。在平台特定compileKotlin任务(生成.klib文件)之后,Kotlin 会执行另一个编译步骤(例如compile*ExecutableKotlinJs针对 JS 或link*ExecutableMacOSmacOS)。然而,JVM 目标平台则没有此步骤。

有人可能会说,.jar 文件通常在编译后还会经过框架特定的处理(例如桌面打包工具或 Android 转换)。然而,Kotlin 编译器并不直接参与这些处理,因此编译器插件也不涉及其中。

因此,非 JVM 目标平台的二进制兼容性规则由 IR 序列化定义,并且在某些方面比 JVM 字节码的兼容性规则更为严格。此外,编译器插件直接操作 IR,这使得我们自定义的转换更容易意外地破坏这种兼容性。

库中的所有元素.klib都通过签名相互关联IdSignature。签名标识了中间表示树中的几乎所有声明,这意味着不仅函数和类有签名,值/类型参数也有签名(泛型擦除发生在编译后期)。每个模块中的元素也通过这些签名相互关联,因此签名错误会导致最终编译步骤中的断言失败。

// Example:
// calculating signature of the function
fun <T> test(value: T, param: String): T

// First, calculate string representation of function signature
JvmManglerIr.run { function.signatureString }
// Result: test(0:0;kotlin.String){0§<kotlin.Any?>}
//              ^^^                ^
//              note type params indexed here

// From string representation, create hash
JvmManglerIr.run { function.signatureString.hashMangle }
// Result: -3871701131444211271

// Finally, use signature hash to create IdSignature
IdSignature.PublicSignature(
    packageFqName = "my.test",
    declarationFqName = "test",
    id = -3871701131444211271,
    mask = 0
)
// Result: public my.test/test|-3871701131444211271[0]

// Similarly, signature of the type parameter
IdSignature.FileLocalDeclaration(
    container = functionSignature,
    id = 1
)
// Result: private my.test/test|-3871701131444211271[0]:1
Enter fullscreen mode Exit fullscreen mode

签名用于重新创建IrSymbol符号并从中重建中间表示 (IR) 引用。符号的使用方式有两种:声明符号(函数或类总是使用相应的签名声明自身的符号)或引用符号(例如,函数调用总是引用被调用函数的符号)。在正确的 IR 树中,每个符号应该只有一个声明。如果某个符号没有在任何地方声明,编译器将崩溃并抛出“未绑定符号”错误。

符号声明 - 引用 - ID签名 - 反序列化

解释够了,到底哪里出了问题?

即使是很小的事情也可能导致符号相关的错误。泛型类型是这方面的常见罪魁祸首:

  • 想象一下,一个编译器插件会添加对以下函数的调用fun <T> test(value: T): T
  • 实现这种调用的最简单方法是使用irCall(symbol)辅助函数。但是这个辅助函数会返回哪种类型的值呢?
  • 类型取自函数签名,因此它被定义为参数类型<T>,而参数并非公共 API 的一部分。(类型参数始终被序列化为私有元素)
  • 反序列化此类调用时,可以找到该函数,但找不到类型参数定义。
  • 繁荣!

这个过程的有趣之处在于,它在 JVM 上编译完全没问题,因为字节码层面上不存在泛型,这种类型相关的不一致性不会影响中间表示到字节码的转换。我之前修复过 Compose 的一个类似 bug 。


当克隆/替换定义类型参数的IR元素时,也存在类似的问题:例如克隆函数。如果要添加新的类型/值参数,通常会复制一个函数(以避免冲突)。但是,务必同时更新函数体中对类型和参数的引用!否则,可能会出现类型本身技术上正确,但却使用了从IR树中移除的错误副本中的类型参数的情况。

以下是一个签名示例,其中包含树中不存在的类型参数:

test(-1:0;kotlin.String){0§<kotlin.Any?>}
     ^^^^            
Enter fullscreen mode Exit fullscreen mode

第一个索引表示父级在文件中的深度。0 表示该函数位于文件顶层,1 表示它位于顶级类中,依此类推。-1 表示在父级作用域中未找到类型参数,这表明可能存在错误。

这类错误会导致IR验证(很容易发现)或IR反序列化(谁会写跨模块编译测试呢?)出现错误。启用K/Native编译的更改修复了类似的问题。

更改 IR 的公共 API

Compose 编译器插件不仅会修改函数体,还会修改函数签名。每个@Composable函数都会获得一些在编译时生成的额外隐藏参数。

这些合成参数对用户是隐藏的(至少在 Kotlin 中是这样),因为它们是在代码生成阶段(中间表示阶段)添加的。它们也不存在于元数据中,而编译器正是利用元数据来理解依赖项中存在的函数或类。元数据是在中间表示阶段之前生成的(由描述符树创建),因此@Composable即使没有这些额外的参数,函数也能正常工作。

当我们深入到IR层时,编译器为依赖项提供IR的方式就开始有所不同。在JVM和K/Native上,所有依赖项的IR都由元数据生成。这也允许为Kotlin之外的内容生成一些IR元素,例如从JVM字节码中提取的Java类和函数。这些元素很容易通过查看其来源来找到:`<string>`IR_EXTERNAL_DECLARATION_STUB或` IR_EXTERNAL_JAVA_DECLARATION_STUB<string>`。顾名思义,这些元素是“存根”:只有函数定义而没有函数体。

由于存根是由元数据生成的,因此依赖项提供的 IR 函数本身不包含 Compose 特有的合成参数,但它们的字节码却包含!这就是为什么 Compose 编译器插件会修改此类函数的存根,以确保这些参数也存在于对它们的调用中。

如果你的某个模块没有应用 Compose 插件,它虽然可以编译通过,但字节码调用会不匹配。这是因为编译器不会检查其他 Kotlin 模块的字节码,而只会检查打包后的元数据。

……在 Kotlin/JS 中

不过,Kotlin/JS 的这个过程有所不同。在这里,依赖项的 IR 直接从工件中打包的 IR 反序列化,所有元数据调用都通过IdSignature.

仅仅在 IR 阶段添加依赖项参数已经行不通了!编译在创建 IR 的初始步骤中就失败了,因为它试图反序列化一个不存在的、具有原始签名的函数。

为了缓解这个问题,Compose 会复制 Kotlin/JS 中的函数,而不是替换它们:

  • 第一个要求是确保 Kotlin 元数据中的每个函数都有与其签名匹配的中间表示 (IR) 对应函数。它在编译期间保持不变,并且绝不应在运行时执行。这类函数被称为“诱饵函数”,通常会被 webpack 优化器通过 tree-shaking 算法移除。
  • 第二个函数(即副本)会根据需要由 Compose 进行修改。所有指向“decoy”的引用都会被修改为指向这个新函数。为了在运行时区分它们(以及用于调试目的),它$composable的名称还会添加一个后缀。

例子:

// -------------- original --------------- 
@Composable
fun Counter() {
  ...
}

// ------------- transformed ------------- 
@Decoy(...)
fun Counter() { // signature is kept the same
  illegalDecoyCallException("Counter")
}

@DecoyImplementation(...)
fun Counter$composable( // signature is changed
    $composer: Composer,
    $changed: Int
) {
  ...transformed code...
}
Enter fullscreen mode Exit fullscreen mode

回到依赖项链接的问题,默认情况下,对其他模块的调用也会引用“诱饵”函数(因为只有诱饵函数才与提供的元数据签名匹配)。与 JVM 和 Native 中直接修改这些函数不同,这里会反序列化一个先前修改过的副本来.klib替换“诱饵”函数。

另外,从序列化工件中获取仅存在于中间表示(IR)中的元素也可能相当具有挑战性。如果您遇到无法通过常规IrPluginContext.reference*方法引用的元素,则可能意味着您需要强制反序列化这些元素。具体到上述 Compose 的情况,Kotlin 团队添加了IrDeserializer.resolveBySignatureInModuleAPI,但它仍然不稳定(就像编译器 API 中的所有其他部分一样)。

调试技巧

即使遵循了所有指南,在编译器插件开发过程中仍然很容易遇到这些问题。那么,应该如何调试这些问题呢?

  • 拥有本地编译器源代码(Kotlin GitHub 仓库)至关重要。目前所有编译器发行版都没有打包源代码,因此能够从插件中逐步调试非常重要。
  • 将调试器从 Gradle 附加到 Kotlin 编译器是成功调试编译失败的第二步:
    • 将此参数添加-Dkotlin.daemon.jvm.options="-Xdebug,-Xrunjdwp:transport=dt_socket\,address=5005\,server=y\,suspend=n"到 Gradle 命令中,用于调试 Kotlin JVM/JS 编译或-Dorg.gradle.debug=trueKotlin/Native 编译。(这些参数在本地运行时gradle.properties无需-D前缀即可生效。)
    • 从IDEA连接远程调试器。
  • IrElement.dump()您可以使用 `getString()` 和 `getString ()`获取 IR 元素的字符串表示形式IrElement.dumpKotlinLike()。前者包含有关符号的所有信息(例如类型参数的来源),后者打印表示当前元素的 Kotlin 代码的近似值。
  • 测试!例如,您可以查看上面提到的已转储的 IR。Compose 使用其版本的测试工具对大量转换进行了测试dumpKotlinLike(),速度非常快且高效。缺点是设置比较复杂(或许可以贡献给kotlin-compile-testing 项目?)。这里有一个 Compose 中针对 JVM 和 JS 的示例

感谢您阅读以上这篇零散的文字记录!我有时会在我的推特账号上发布类似的内容和相关公告,所以请务必关注!(这篇文章是由一个帖子整理而成的)。

额外信息来源

文章来源:https://dev.to/shikasd/kotlin-compiler-plugins-and-binaries-on-multiplatform-139e