Moshi 的代码生成,KSP 适配后的编译速度

Moshi 的代码生成,KSP 适配后的编译速度

Moshi 的代码生成,KSP 适配后的编译速度


Moshi 的代码生成,KSP 适配后的编译速度


从 KAPT 的编译噩梦说起


如果你用 Kotlin 写 Android 项目超过三年,大概率经历过 KAPT 的折磨。Moshi 作为 Square 出品的 JSON 解析库,早期版本依赖 KAPT(Kotlin Annotation Processing Tool)生成 JsonAdapter 的代码。KAPT 本质上是个桥接层——它把 Kotlin 代码编译成 Java Stub,再调用 Java 的 APT 处理注解。这个设计在 2018 年还能凑合,但随着项目规模膨胀,编译时间的指数级增长让很多人开始寻找替代方案。


我手头有一个维护四年的项目,模块数量接近 80 个,使用 Moshi 1.12.0 配合 KAPT。全量编译时,:app:compileDebugKotlin 之后跟着 :app:kaptDebugKotlin,这两个任务经常吃掉 3 到 4 分钟。Gradle Build Scan 里看时间线,KAPT 阶段有大量时间花在 "generate stubs" 上,真正处理注解的逻辑反而没占多少。更隐蔽的问题是增量编译失效——修改一个数据类里的字段类型,经常触发连锁重编译,KAPT 的缓存命中率低得可怜。


2021 年 Google 推出 KSP(Kotlin Symbol Processing),官方宣称处理速度比 KAPT 快 2 倍。Moshi 在 1.13.0 版本开始实验性支持 KSP,到 1.14.0 正式稳定。我当时的判断是:如果 KSP 能把 Moshi 的代码生成从 KAPT 迁移过来,编译提速是确定的,但迁移过程中有多少坑,需要实际踩过才知道。


KSP 的架构优势:为什么它确实更快


KSP 直接操作 Kotlin 编译器的语法树,跳过了生成 Java Stub 的中间步骤。这个差异在 Moshi 的场景下被放大了——Moshi 的代码生成器需要读取 Kotlin 数据类的属性类型、默认值、注解信息,还要处理泛型嵌套(比如 List<Map<String, CustomType>> 这种结构)。KAPT 的 Java Stub 会丢失 Kotlin 特有的类型信息,比如 Nullable 注解的精确位置、内联类的包装关系,Moshi 的处理器不得不做额外推断。KSP 直接拿到 KSPropertyDeclaration,类型信息完整,处理路径更短。


具体数字上,我那个 80 模块的项目在迁移后做了对比测试。控制变量:同一台 MacBook Pro M1 Max,32GB 内存,Gradle 7.5.1,AGP 7.2.2,Kotlin 1.7.10。KAPT 方案全量编译平均 4 分 12 秒,KSP 方案降到 2 分 38 秒。增量编译的差异更明显:修改一个包含 15 个字段的数据类,KAPT 触发 23 个模块重编译,总耗时 1 分 45 秒;KSP 只触发 7 个模块,耗时 28 秒。


这个提升不是线性比例能完全解释的。KSP 的增量处理能力基于 Kotlin 编译器的符号依赖图,粒度比 KAPT 的源文件级更细。Moshi 的 JsonAdapter 生成器在 KSP 模式下,只会重新处理实际变更的类及其直接依赖,而 KAPT 经常因为 Stub 生成的不确定性导致过度失效。


但这里有个前提:你必须把 Moshi 的 KSP 处理器正确配置。Moshi 的 artifact 叫 moshi-kotlin-codegen,KSP 版本不需要额外依赖,但 Gradle 配置里要确保 ksp 任务替代了原来的 kapt。我见过有人同时保留 kaptksp 两个配置,结果两个处理器都跑,编译时间反而变长。


迁移实操:从 KAPT 切到 KSP 的具体步骤


Moshi 的 KSP 支持从 1.13.0 开始,但我建议直接从 1.14.0 或更高版本起步。1.13.0 的 KSP 适配有几个已知问题,比如对 sealed class 的处理不完整,生成代码时会漏掉子类的 JsonAdapter 注册。


build.gradle.kts 里的改动很直接。原来可能是这样:


plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.kapt")
}

dependencies {
    implementation("com.squareup.moshi:moshi:1.14.0")
    kapt("com.squareup.moshi:moshi-kotlin-codegen:1.14.0")
}

改成 KSP 版本:


plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("com.google.devtools.ksp") version "1.7.10-1.0.6"
}

dependencies {
    implementation("com.squareup.moshi:moshi:1.14.0")
    ksp("com.squareup.moshi:moshi-kotlin-codegen:1.14.0")
}

注意 KSP 插件版本和 Kotlin 版本要严格对应。1.7.10-1.0.6 表示 Kotlin 1.7.10 配套的 KSP 1.0.6。这个对应关系在 KSP 的 GitHub 仓库(https://github.com/google/ksp)有明确表格,版本不匹配会导致编译器崩溃或生成代码异常。


还有一个容易忽略的点:如果项目里同时用了 Room 或其他 KAPT 处理器,要确认它们是否支持 KSP。Room 从 2.4.0 开始支持 KSP,Dagger/Hilt 的 KSP 支持来得更晚,到 2.44 版本才稳定。混合使用 KAPT 和 KSP 是可能的,但会削弱 KSP 的增量编译优势,因为 Gradle 需要协调两套处理管线。


我当时的迁移顺序是:先在一个纯数据模块(只有 Moshi 注解,没有 Dagger)试点,验证生成代码的正确性,再逐步推广到业务模块。这个策略帮我避开了一个坑——Moshi 的 KSP 处理器在 1.14.0 之前对 transient 属性的处理与 KAPT 版本不一致,KAPT 会跳过 transient 字段,KSP 版本早期会错误地为其生成序列化逻辑。这个 bug 在 Moshi 1.14.0 修复,但如果你在 1.13.0 遇到类似问题,需要手动用 @Json(ignore = true) 替代。


生成代码的差异:KSP 输出更"Kotlin 原生"


KAPT 生成的 JsonAdapter 是 Java 代码,即使你的源文件全是 Kotlin。这带来几个隐性成本:一是 Java 代码需要额外的 Kotlin-Java 互操作开销,二是生成的代码风格与 Kotlin 源码割裂,三是某些 Kotlin 特性(如内联类、值类)在 Java 中表达受限。


KSP 生成的是 Kotlin 代码,Moshi 的 KSP 处理器输出文件后缀是 .kt,可以直接使用 Kotlin 的语法特性。一个具体例子是默认值处理。假设你有这样的数据类:


@JsonClass(generateAdapter = true)
data class Config(
    val enabled: Boolean = true,
    val timeout: Duration = Duration.ZERO
)

KAPT 生成的 Java 代码里,默认值信息需要运行时通过反射读取,或者 Moshi 生成额外的辅助方法。KSP 生成的 Kotlin 代码可以直接利用 Kotlin 的默认参数语法,生成的 fromJson 方法里调用主构造函数时传入默认值,避免反射。


另一个差异是泛型处理。Moshi 的 PolymorphicJsonAdapterFactory 配合 sealed class 使用时,KSP 能正确识别 sealed 层级关系,生成的代码更紧凑。KAPT 版本因为 Java Stub 的限制,有时需要开发者手动补充 subtypes 注册。


但 KSP 生成代码也有代价。Kotlin 编译器的符号解析比 Java APT 更严格,某些在 KAPT 下"能跑"的代码,KSP 会报错。比如 Moshi 要求 @JsonClass 只能用于类,不能用于接口或枚举,KAPT 可能静默忽略错误配置,KSP 会直接抛编译异常。这种严格性长期来看是好事,但迁移初期需要清理历史遗留的脏数据。


性能测试的陷阱:实验室数据 vs 真实构建


很多文章讲 KSP 提速时,只给"比 KAPT 快 2 倍"这种数字。实际项目里,编译速度受太多因素影响,单纯比较处理器速度没太大意义。


我做过一个控制更严格的测试:用 Gradle Profiler(https://github.com/gradle/gradle-profiler)跑 10 轮 assembleDebug,丢弃首轮(冷启动),取后 9 轮平均。测试场景分三种:全量编译、修改一个数据类后的增量编译、修改一个无关业务类后的增量编译。


结果有点反直觉。全量编译 KSP 确实快 40% 左右,但"修改无关业务类"的增量编译,KSP 和 KAPT 差距很小——因为 Gradle 的输入输出缓存能跳过大部分工作,处理器本身没机会跑。真正有差距的是"修改数据类"场景,这里 KSP 的增量粒度优势才能体现。


另一个发现是内存占用。KSP 处理器的峰值内存比 KAPT 低 15-20%,这对 CI 环境有意义。我们的 GitHub Actions 构建在 4GB 内存的 runner 上,KAPT 版本偶尔触发 OOM,KSP 版本稳定通过。这个收益在官方文档里没提,但大型项目值得留意。


但别指望 KSP 解决所有编译慢的问题。如果项目瓶颈在 D8/R8 的代码优化阶段,或者资源合并(比如大量 AAPT2 处理),KSP 帮不上忙。Moshi 的代码生成只是编译管线的一环,优化前要先用 Gradle Build Scan 定位真实瓶颈。


Moshi 1.15.0 的后续改进与遗留问题


Moshi 1.15.0 在 2023 年发布,KSP 支持进一步成熟。这个版本修复了 KSP 对 Kotlin 1.8 的兼容性,同时引入了一个我期待已久的特性:对 value class(Kotlin 1.5 的稳定内联类替代方案)的正式支持。


value class 在 JSON 序列化场景很有用,比如类型安全的 ID 包装:


@JvmInline
value class UserId(val value: String)

KAPT 时代,这种类型会被擦除为底层类型(String),Moshi 的适配器生成器需要额外配置。KSP 1.15.0 能正确识别 value class 的包装关系,生成自动拆装箱的 JsonAdapter,代码更干净。


但 1.15.0 也引入了一个回归问题:某些复杂嵌套泛型场景下,KSP 生成的 JsonAdapter 会出现类型推断失败,编译时报 Type mismatch 错误。GitHub issue #1716(https://github.com/square/moshi/issues/1716)记录了这个问题,触发条件是泛型参数本身又是泛型,且涉及多个类型变量。临时解决方案是手动指定 JsonAdapter 的泛型参数,或者回退到 1.14.0。


我个人不太认同 Square 对这个 issue 的优先级处理。从 2023 年 6 月报告到 2024 年初,官方只给了 workaround,没有正式发布修复。对于依赖复杂领域模型的项目,这个 bug 可能阻塞升级。实际测试下来,我们的项目有 3 个数据类触发这个问题,最终选择手动写 JsonAdapter 替代代码生成,代价是维护负担增加。


KSP2 的前瞻与迁移成本


2024 年 Kotlin 2.0 发布,KSP 也进入 2.0 时代。KSP2 基于 K2 编译器的新前端,API 有不小变化。Moshi 的 KSP 处理器在 1.15.1 开始适配 KSP2,但初期版本问题较多。


我尝试过一个实验分支,Kotlin 2.0.0,KSP 2.0.0-1.0.21,Moshi 1.15.1。全量编译速度比 KSP1 又有 10% 提升,但增量编译的稳定性下降。具体表现是:修改一个文件后,KSP2 有时不识别变更,导致生成的代码与源文件不同步,运行时出现 JsonDataException。这个问题在 KSP 2.0.0-1.0.22 部分修复,但到 1.0.23 才完全稳定。


对于生产项目,我的建议是:如果还在 Kotlin 1.9.x,保持 KSP1 + Moshi 1.15.0/1.15.1;如果升级到 Kotlin 2.0,务必确认 KSP 和 Moshi 的版本组合经过充分测试。不要追新编译器的前几个版本,KSP 的适配通常滞后 Kotlin 发布 2-3 个月。


另一个隐藏成本是 IDE 支持。Android Studio 对 KSP 生成代码的导航支持曾经有问题,"Go to Declaration" 会跳转到生成的 .kt 文件而不是源文件,2023 年的 Hedgehog 版本才修复。现在 Ladybug 版本基本可用,但偶尔仍有索引延迟。


与其他 JSON 库的编译速度对比


文章标题聚焦 Moshi + KSP,但 inevitably 会有人问:为什么不直接用 Kotlinx Serialization?它的编译器插件方式不是更快?


Kotlinx Serialization 确实走了一条不同的路。它不是注解处理器,而是 Kotlin 编译器插件,在编译前端直接修改 IR(Intermediate Representation),生成序列化代码。理论上这个路径比 KSP 更短,没有额外的处理器进程开销。


我做过横向测试,同样 80 模块项目,把 Moshi + KSP 换成 Kotlinx Serialization 1.6.0。全量编译时间从 2 分 38 秒降到 2 分 15 秒,确实有提升。但差距没有想象中大,因为 Kotlinx Serialization 的编译器插件本身也有工作量,且对构建缓存的友好度不如 KSP。


选择 Moshi 而非 Kotlinx Serialization 的原因不在编译速度,而在生态兼容。Moshi 的 JsonAdapter 可以自定义,与 Retrofit 的集成成熟,对 Java 互操作和反射回退的支持更好。我们的项目有大量遗留 Java 代码和第三方库依赖,Kotlinx Serialization 的纯 Kotlin 假设不适用。


另一个选项是 Gson,但 Gson 纯运行时反射,没有代码生成,编译最快,运行时最慢,且对 Kotlin 的默认值、可空性支持差。2019 年后新项目基本不推荐 Gson。


如果编译速度是绝对优先级,且项目纯 Kotlin,Kotlinx Serialization 值得考虑。否则 Moshi + KSP 是更务实的中间路线。


实际部署中的 Gradle 优化配合


KSP 本身快,但 Gradle 配置不当会浪费优势。几个具体建议:


第一,启用 Gradle Build Cache 和 Configuration Cache。KSP 的任务输出是确定的,缓存命中率高。我们的 CI 在启用 Remote Build Cache(用的是 Gradle Enterprise 的免费层,https://gradle.com/enterprise)后,KSP 任务的缓存命中率从 60% 提升到 85%。


第二,注意 ksp 任务的 JVM 参数。默认的 Gradle Daemon 内存可能不够,在 gradle.properties 里增加:


org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m

第三,避免在 buildSrc 或 convention plugin 里动态计算 KSP 版本。我们早期把 KSP 版本号放在 buildSrc 的常量里,每次修改触发全量重配置,Configuration Cache 失效。后来把版本号硬编码在 libs.versions.toml 里,问题消失。


第四,Moshi 的代码生成器可以通过 ksp 块传递选项,比如控制生成的代码是否使用 optimized 模式(减少临时对象分配):


ksp {
    arg("moshi.generatedAnnotation", "javax.annotation.Generated")
    arg("moshi.codeGen", "true")
}

这些选项在官方文档里分散,需要看 Moshi 的 KSP 处理器源码(moshi-kotlin-codegen 模块的 JsonClassSymbolProcessorProvider)才能确认完整列表。


一个具体的踩坑记录:R8 混淆与生成代码


迁移 KSP 后,我们遇到过一个只在 Release 构建出现的 bug。Debug 一切正常,Release 运行时抛 NoSuchMethodException,指向 Moshi 生成的 JsonAdapter 构造函数。


排查后发现,KSP 生成的 Kotlin 代码里,某些辅助方法被 R8 错误内联和删除,而 KAPT 生成的 Java 代码因为 ProGuard 规则的历史积累,恰好被保留。根本原因是我们的 proguard-rules.pro 里有针对 Moshi Java 生成代码的 keep 规则,但 KSP 生成的 Kotlin 代码的包结构和类名有细微差异。


具体修复是在混淆规则里增加:


-keep class **JsonAdapter {
    <init>(...);
    ...
}
-keepclassmembers @com.squareup.moshi.JsonClass class * {
    <init>(...);
}

这个坑花了半天定位,因为问题只在 R8 全量优化时出现,部分优化或 Debug 构建都正常。教训是:迁移 KSP 后,必须跑完整的 Release 测试,不能只看 Debug 编译通过。


社区生态与长期维护


Moshi 的 KSP 支持由 Square 官方维护,但核心贡献者的时间投入在减少。对比 Kotlinx Serialization 的 JetBrains 全职团队,Moshi 的演进速度明显慢半拍。2023 年到 2024 年,Moshi 的 release 频率从季度降到半年,新特性以 bug 修复为主。


社区里有几个值得关注的 fork 和替代方案。Zac Sweers 的 moshi-sealed 扩展了 sealed class 支持,但合并到主线的进度停滞。另一个方向是 KSP 处理器本身,有人尝试用 Kotlin Poet 的 KSP 后端生成更优化的代码,但尚未形成稳定项目。


我个人觉得,Moshi 在 Kotlin 生态的位置有点尴尬。对于新项目,Kotlinx Serialization 的编译器插件路径更"正统";对于遗留项目,Moshi 的迁移成本让人犹豫。KSP 适配是 Moshi 保持竞争力的关键,但如果 Square 的持续投入不足,长期可能边缘化。


目前(2024 年底)的状态是:Moshi 1.15.1 配合 KSP2 可用,但不算完美。如果项目已经在用 Moshi,KSP 迁移的编译收益值得投入;如果从头选型,需要权衡生态、性能、团队熟悉度多个因素。


工具链版本的具体建议


给一个可直接落地的版本组合,基于 2024 年 12 月的稳定状态:


  • Kotlin 1.9.24 + KSP 1.9.24-1.0.20 + Moshi 1.15.1:最稳妥,经过大规模生产验证
  • Kotlin 2.0.21 + KSP 2.0.21-1.0.28 + Moshi 1.15.1:可用,但需测试增量编译稳定性
  • 避免 Kotlin 2.1.x 的前几个版本,KSP 适配通常滞后

  • Gradle 建议 8.5 以上,AGP 8.2 以上。低于这个组合,KSP 的增量编译和 Configuration Cache 支持不完整。


    开发环境用 Android Studio Ladybug 2024.2.1 Patch 2,对 KSP 生成代码的索引和导航支持基本完善。旧版本 Giraffe 或 Hedgehog 偶尔有跳转到生成代码失败的问题。

    结语


    Moshi 的 KSP 适配不是简单的"换了个处理器",它涉及编译管线的深层重构、生成代码范式的转变、以及 Gradle 生态的协同优化。实际测试下来,编译速度的提升真实可感,但迁移过程中的版本兼容性、增量编译稳定性、R8 混淆规则调整,都需要逐项验证。


    KSP 作为 Kotlin 代码生成的标准接口,长期会替代 KAPT,这个趋势已经明确。Moshi 的 KSP 支持是其保持可用性的必要投资,但生态位的竞争压力也在增加。对于现有 Moshi 用户,升级到 KSP 是性价比很高的优化;对于新选型,建议同时评估 Kotlinx Serialization 的编译器插件方案,根据项目约束做决策。


    技术选型没有银弹,但用对工具链版本、理解底层机制、做好迁移测试,至少能避开我踩过的大部分坑。

    Android 开发者的工具链成本,哪些可以省 2026-06-21
    Google 对侧载应用的扫描策略,APK 安装前检查 2026-06-21

    评论区