Jetpack Room 的 Kotlin 代码生成,KSP 迁移完成度
Jetpack Room 的 Kotlin 代码生成,KSP 迁移完成度
KAPT 那个 kaptGenerateStubs Task 跑起来的时候,你是真的能感觉到编译管线在硬撑着。尤其是项目里 Room 的 Entity 和 DAO 一多,每次改个非 Room 相关的 Kotlin 文件,Gradle 还是要把 Kotlin 代码先翻译成 Java Stub,扔给 APT 处理器绕一圈。Room 作为 Android Jetpack 里最基础、装机量最大的持久层库之一,它对 KSP 的适配进度,某种程度上比 Compose 的某个新 Animation API 更能决定你每天的编译体验。可问题是,Google 对这个迁移的投入节奏,怎么看都透着一股"能用就行"的敷衍。
KAPT 的结构性缺陷:Room 逃不掉的 Java Stub 弯路
KAPT 的根本问题不是慢在 APT 本身,而是它整个架构就是一个兼容层。Kotlin 源代码要先经过 kaptGenerateStubs 生成中间 Java Stub,这些 Stub 丢掉了 Kotlin 的很多东西——扩展函数、顶层声明、实际的空性信息,全都得靠元数据或者命名约定去猜。Room 的注解处理器原本是为 Java APT 设计的,跑在 KAPT 上等于是在看一张失真的复印件。
Room 的代码生成逻辑很重。@Entity 要生成 _Impl 类,@Dao 要生成实现类,@Database 还要做各种校验和桥接。KAPT 环境下,这些处理器面对的 Java Stub 不仅信息量少,而且生成 Stub 本身就要耗时间。更烦的是,KAPT 的增量编译长期以来是个半残状态,Room 的 room.incremental 开关在 KAPT 下行为诡异,经常出现改了 DAO 里的一个方法,结果整个 Database 相关的生成代码全量重编的情况。
Google 不是不知道这个问题。KSP(Kotlin Symbol Processing)从 2021 年开始推,Room 也在后续版本里跟进了。但 KSP 对 Room 来说不只是一个"更快的 KAPT",它意味着 Room 的处理器终于可以第一次直接读到 Kotlin 的语法树,而不是透过 Java Stub 那层毛玻璃去猜。这个前提本身就很讽刺:Room 作为 Kotlin 优先的 Android 官方库,居然这么多年一直靠 Java APT 的兼容层苟着。
Room 的 KSP 时间表:从 2.5.0 到 2.6.1 的挤牙膏式更新
Room 正式引入 KSP 支持是在 2.5.0 版本。如果你去看当时的 Release Note,Google 的措辞很保守,大意是"实验性支持,欢迎试用"。可那个时候 KSP 本身已经 release 了相当长一段时间,Moshi、Glide 这些第三方库早就有相对成熟的 KSP 适配方案了,Room 作为亲儿子反而慢半拍。
到了 Room 2.6.0,KSP 支持被标为稳定。但稳定不代表好用。Room 2.6.x 对 KSP 的依赖有一个很隐晦的坑:它要求 KSP 的版本必须和项目的 Kotlin 版本严格对齐。KSP 的版本号命名规则是 {kotlin-version}-{ksp-version},比如 Kotlin 1.9.22 对应 KSP 1.9.22-1.0.17。Room 2.6.0 发布时,Kotlin 1.9.20 刚出不久,结果没过两个月 Kotlin 1.9.21、1.9.22 连着发,KSP 版本也跟着连跳。Room 本身虽然没有强制锁死 KSP 小版本,但一旦 KSP 和 Kotlin 版本对不上,编译报错的信息往往极其晦涩,像是 e: [ksp] java.lang.IllegalStateException: unexpected jvm signature 这类日志,根本看不出是版本 mismatch 还是 Room 的 Bug。
Room 2.6.1 修了几个 KSP 路径下的具体问题,比如跨模块引用 Entity 时符号解析失败的情况,以及某些 Kotlin 内建类型在 KSP 下被错误映射的问题。但说实话,这些补丁的发布节奏给人一种强烈的"社区报 Bug 了,我们修一下"的被动感,而不是主动把 KSP 路径打磨到和 KAPT 路径同等健壮。
Kotlin 2.0 与 KSP2:Room 刚追上末班车,车又改了
2024 年 Kotlin 2.0 发布,KSP 这边搞了个更大的新闻:KSP2。KSP2 是为 Kotlin 2.0 的新编译器前端(K2)设计的,API 有变化,性能更好,但它是 Preview 状态。这直接把 Room 的 KSP 适配工作拖进了一个尴尬的过渡期。
Room 2.6.x 的 KSP 支持是基于 KSP1 API 的。在 Kotlin 2.0 项目上,你需要用支持 Kotlin 2.0 的 KSP1 版本(比如 1.0.21 以后),而 KSP2 则要等 Room 重新适配。换句话说,Room 花了几个大版本刚把 KSP1 的支持做到能用,KSP2 一来,相当于又要再来一遍。这种基础设施的反复折腾,对开发者来说是纯成本。
更麻烦的是 AGP 的兼容层。Android Gradle Plugin 8.x 系列对 Kotlin 版本有隐形天花板,AGP 8.2 配 Kotlin 1.9.x 没问题,但配 Kotlin 2.0 就得上 AGP 8.3 甚至 8.4。Room、KSP、Kotlin、AGP 这四个组件的版本矩阵,在 2024 年简直是一张死亡拼图。你在升级 Room 到 2.6.1 想用 KSP 的时候,很可能发现还得先升级 AGP,而 AGP 一升级,Gradle 版本又要跟着动,然后某些 Gradle Plugin 还不支持新 Gradle。Room 的 KSP 迁移在这个链条里只是其中一环,但它是最容易被卡住的环节之一,因为代码生成一旦失败,编译直接挂,没有降级容错的余地。
编译真的快了吗?KSP 下的 Room 体感与数据
KSP 的宣传点一直是"比 KAPT 快两倍"。这个倍数在纯 Kotlin 项目里确实能测出来,因为省掉了 Stub 生成那一步。但 Room 场景下,实际情况要复杂得多。
Room 的 KSP 处理器虽然在读 Kotlin AST,但它生成的代码量并没有减少。一个带 20 个字段的 Entity,KSP 路径下生成的 _Impl 类和 KAPT 下一样臃肿。真正省下来的时间主要是 kaptGenerateStubs 那个 Task 的消失。对于中型项目,clean build 确实能快个十几秒到几十秒,但增量编译的收益取决于你的修改范围。如果你改的是 Room 的 Entity,KSP 和 KAPT 都得重新跑 Room 的注解处理器,生成新的代码,这一步两者没什么本质区别。
还有个很实际的噪音来源:大部分 Android 项目不是只有 Room。Dagger / Hilt 的 KSP 支持来得比 Room 更晚,Hilt 直到 2.50 左右才算真正把 KSP 支持做稳。如果你项目里 Hilt 还在 KAPT,那你切 Room 到 KSP 的意义被大幅稀释——整个编译流程还是要等 KAPT Task 跑完,Room 省下来的时间被 Hilt 的 Stub 生成吃掉了。只有当你把 Room、Hilt、Moshi 甚至 DataBinding 全部迁到 KSP,才能感受到编译管线的整体轻快。但这要求整个生态都 ready,而 Room 作为 Jetpack 核心库,本应该在这个生态位上带头把路铺平,而不是等第三方库追上来。
Room KSP 路径下仍存的边角料问题
Room 的 KSP 支持在"能用"这条线上是及格的,但离"丝滑"还差得远。
一个是 room.schemaLocation 的导出行为。在 KAPT 时代,这个目录导出相对成熟,配合 Room 的自动迁移和测试比较稳。切换到 KSP 后,有开发者反馈 schema 导出的增量行为不一致,有时候 clean build 能导出,增量编译却漏掉。这背后的原因可能是 KSP 的增量处理模型和 KAPT 不同,Room 的处理器在某些增量场景下没有正确标记输出。Google Issue Tracker 上有相关工单,状态长期是 Assigned 或者 Fixed in future release,实际落到具体版本又要等。
另一个是 Kotlin 特有的语法支持。比如带有默认参数的 DAO 方法、内联函数(虽然 DAO 里不太用,但配合 Paging 的 RemoteMediator 时可能出现复杂接口继承)、或者使用了 Kotlin 内建类型如 kotlin.collections.Map 的自定义 TypeConverter。KSP 虽然比 K