Compose Compiler 新版本的性能优化,编译时间缩短了吗
Compose Compiler 新版本的性能优化,编译时间缩短了吗
Compose Compiler 2.0.0 跟着 Kotlin 2.0 一起落地已经有一段时间了。JetBrains 在 K2 编译器的发布博客里把性能提升吹上了天,Google 的 Compose Release Note 也照例写着"改进了编译性能"。但我翻了一圈各个技术社区的反馈,发现一个有意思的现象:大家似乎并没有在举杯庆祝构建时间腰斩,反而在 Kotlin Slack 和 Reddit 上,关于"升级之后编译变慢了"的帖子一点没见少。这就很尴尬了。如果 K2 编译器真的让 Kotlin 前端快了两倍,而 Compose Compiler 又"持续优化"了好几个版本,那理论上我们的 Gradle 构建时间应该已经跌到一个让人舒爽的数字了才对。现实呢?Build Analyzer 里那行 Kotlin Compiler 的耗时数字,可能比你想象中要顽固得多。
K2 前夜:1.5.x 时代的编译泥潭
要聊清楚 Compose Compiler 2.0.0 到底快没快,得先回头看一下 1.5.x 那段日子。Compose Compiler 1.5.0 是在 2023 年 7 月发布的,绑定了 Kotlin 1.9.0。这个版本在当时被寄予厚望,因为 Google 终于干掉了 Live Literals。这个特性本来是为了让预览工具能在运行时动态改写常量值,但它在编译期插入了一大堆额外的字节码和反射调用,对编译时间和包体积都是纯粹的负担。1.5.0 把它移除之后,理论上编译产物更轻了,Compiler 的工作量也该小一点。
但问题在于,1.5.x 同期引入了 Strong Skipping Mode 的实验性支持。这个功能后来在 1.7.0 里转正,目的是让 Composable 在参数没有变化时跳过重组。听起来很美,对吧?但这意味着编译器必须在编译期做更激进的稳定性推断。你的数据类是不是 Stable,lambda 有没有被记住,这些分析全都要在 IR 转换阶段完成。Compose Compiler 作为一个 Kotlin Compiler Plugin,深度插入了 Kotlin 的编译管线,在 Intermediate Representation 层面为每个 @Composable 函数注入 Composer.startRestartGroup、endRestartGroup 调用,还要生成 Group Key 和 Slot Table 相关的元数据。这些工作一点没少,反而随着 Strong Skipping 的加入,编译期的分析负担更重了。
那时候社区里有一个很典型的痛点:增量编译形同虚设。你在一个多模块项目里改了一个纯工具类里的一行日志,如果这个类被某个 Composable 函数引用,Compose Compiler 经常会把整个模块甚至依赖链上的 Compose 文件全部重新处理一遍。原因是 Compose 的 Group Key 生成对函数签名和调用关系极其敏感,IR 层面的微小变动可能导致 ABI 层面的连锁反应。Gradle 的增量编译状态被击穿,Kotlin Compile Daemon 的内存占用却随着重编译次数直线上升。Google 的 Issue Tracker 上关于这一点的抱怨从未断过,但官方回复往往是"建议开启 Gradle Build Cache"或者"请提供复现项目"。
Kotlin 2.0 绑定升级:是救赎还是绑架
到了 2024 年 5 月,Kotlin 2.0.0 正式发布,K2 编译器前端取代了老旧的 K1。JetBrains 给出的官方数据非常诱人:K2 比 K1 编译速度快了约百分之七十到八十,内存占用也更低。这本来应该是 Android 开发者的福音,毕竟 Kotlin 编译慢是祖传的痛点。Compose Compiler 2.0.0 也随之发布,宣布适配 K2。
但这里有一个结构性问题:Compose Compiler 的版本被 Kotlin 版本死死绑定。你想用 Compose Compiler 2.0.0,就必须把整个项目升级到 Kotlin 2.0.0。这不是一个可以独立升级的组件,你没有选择权。这种绑定策略让 Google 和 JetBrains 省了很多兼容性维护的麻烦,却把迁移风险全部转嫁给了开发者。Kotlin 2.0 不仅仅是一个编译器升级,它还牵扯到 KSP 的版本迁移、Gradle 插件的版本对齐、以及一系列第三方库的二进制兼容性问题。
更微妙的是,Compose Compiler 作为一个 Compiler Plugin,它的性能并不仅仅取决于 Kotlin 前端解析代码有多快。K2 确实把 PSI 分析、类型推断和语义分析的速度提上去了,但 Compose Compiler 的核心工作发生在 IR Lowering 阶段。它要把普通的 Kotlin 函数转换成带有重组能力的 Composable 函数,这涉及大量的 IR 节点变换、Lambda 捕获分析、以及稳定性元数据的注入。K2 前端省下来的时间,被 Compose Plugin 在 IR 阶段的繁重劳动吃掉了相当一部分。这就导致一个尴尬的局面:Clean Build 确实快了,因为 K2 解析十万行 Kotlin 代码的时间大幅缩短;但日常开发中最关键的增量编译,体验并没有出现质变。有时候甚至因为 K2 的变更检测逻辑和 K1 不同,某些文件被判定为需要重编译的范围反而更大了。
JetBrains 和 Google 的联合博客里画了很多性能对比的柱状图,但仔细看他们的测试基准,往往是在一个相对干净的环境里跑单个模块。真实世界的 Android 项目是什么德行?十几个 Gradle 模块、混用 KSP 和 KAPT、Room 生成代码、Hilt 生成代码、再加上 Compose 的 IR 变换,这些步骤在构建链里互相阻塞。K2 的快,快在了它自己的领地,但构建总时间被其他环节均摊之后,开发者的体感就近乎于无了。
增量编译的谎言
Compose 项目的增量编译问题,其实比官方文档愿意承认的要严重。Gradle 的增量编译依赖于输入输出的精确追踪和 ABI 稳定性判断。但 Compose Compiler 生成的 IR 有一个特性:它会在每个 Composable 函数里插入一个基于源码位置生成的 Group Key。这个 Key 通常和函数名、包名、文件路径相关。当你修改了一个被 Composable 调用的普通函数的签名,或者调整了某个常量值,这个 Group Key 的变化可能会沿着调用链传播。
在实际工程中,你经常能观察到这种现象:修改了一个位于 utils 模块里的纯工具函数,按道理只该触发这个模块的增量编译,但 feature-ui 模块里那些调用了这个工具的 Composable 也全部重新编译了。Build Analyzer 里显示 compileDebugKotlin 任务被标记为"增量",但执行时间却和全量相差无几。因为 Compose Compiler 重新处理了这些文件,重新生成了 IR,重新计算了稳定性。对于