1. 错误背景:当 Moshi 撞上“双胞胎”
第一次把项目跑真机时,Gradle 突然甩出一句cause: duplicate entry: com/squareup/moshi/recordjsonadapter$1.class
打包流程直接中断。字面意思很直白:同一个类被重复写进了 APK。
Moshi 从 1.14 开始给 JDK 16+ 的 Record 做了适配,生成RecordJsonAdapter及其匿名内部类。如果两条依赖路径各自拉进了不同版本(或相同版本但被不同构建缓存),JAR 里就会出现两份.class,DX/D8 在合并时就会炸锅。
典型触发场景:
- 主工程显式依赖
moshi:1.15.0,某个二方库内部又依赖moshi:1.12.0 - 混用
moshi与moshi-kotlin,后者自带moshi的runtime传递依赖 - 多模块项目里,A 模块
api引入,B 模块implementation引入,版本未对齐
根因一句话:依赖树里同一坐标不同版本并存,且都携带了 Record 适配器代码。
2. 技术方案对比:三把手术刀怎么选
| 方案 | 思想 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 依赖排除(exclude) | 把多余的那份直接踢出依赖树 | 配置简单,APK 瘦身 | 需要人工找出冲突源;升级后可能再次引入 | 快速止血,单点冲突 |
| 强制版本(force / strict) | 统一强制解析到指定版本 | 一次配置全局生效;Gradle 自动仲裁 | 若旧库不兼容新 API 会运行时崩溃 | 团队能统一版本管理 |
| Shading(重定位) | 把类名整体搬家,物理隔离 | 彻底避免冲突;可共存多版本 | 构建耗时增加;调试堆栈变长 | SDK 厂商、二方库无法改源码时 |
3. 实现细节:直接能抄的 Gradle 片段
以下均以 Kotlin DSL 为例,Groovy DSL 把括号换成空格即可。
3.1 依赖排除
// build.gradle.kts dependencies { implementation("com.squareup.moshi:moshi:1.15.0") implementation("com.xxx:some-lib:3.2.1") { exclude(group = "com.squareup.moshi", module = "moshi") } }验证命令:
./gradlew app:dependencies --configuration releaseRuntimeClasspath | grep moshi确保只剩一条1.15.0。
3.2 强制版本
// build.gradle.kts dependencyResolutionManagement { versionCatalogs { create("libs") { version("moshi", "1.15.0") library("moshi", "com.squareup.moshi", "moshi").versionRef("moshi") } } } configurations.all { resolutionStrategy.eachDependency { if (requested.group == "com.squareup.moshi" && requested.name == "moshi") { useVersion(libs.versions.moshi.get()) because("Align moshi to avoid duplicate RecordJsonAdapter") } } }3.3 Shading(以 Shadow 插件为例)
plugins { id("com.github.johnrengelman.shadow") version "8.1.1" } shadowJar { archiveClassifier.set("shaded") relocate("com.squareup.moshi", "shaded.moshi") // 把 moshi 本身也打进去 from(project.sourceSets.main.get().output) configurations = listOf(project.configurations.runtimeClasspath.get()) } // 发布到本地仓库供其他模块依赖 publishing { publications { create<MavenPublication>("shadow") { artifact(shadowJar) artifactId = "my-moshi-runtime" } } }主工程里直接依赖my-moshi-runtime即可,与业务代码零感知。
4. 性能考量:构建与运行双面看
- 构建时间
exclude/force 只做依赖解析,增量构建几乎无额外耗时;shade 需要重写字节码,全量打包增加 15~30 s(视 CPU 而定)。 - APK 大小
exclude/force 仅保留一份,体积最小;shade 会多拷贝一份 relocated 类,增加 300-400 KB。 - 运行时
前两种方案对启动速度无影响;shade 因类名变长,首次加载反射略慢(<5 ms),可忽略。 - 维护成本
shade 需要单独发布阴影包,CI 流程复杂;exclude/force 只需代码审查阶段保证版本一致即可。
5. 避坑指南:90% 的人都踩过的坑
- 只在
debug排除,release忘记排除,结果发到线上才崩溃——用configurations.all统一处理。 - 用了
force但二方库硬编码调用旧 API,运行时NoSuchMethodError——先用./gradlew dependencyInsight确认兼容。 - Shading 时把
moshi-adapters也 relocate 了,导致 Kotlin 扩展找不到类——只 relocatemoshi核心库即可。 - 混用
moshi-kotlin-codegen与kapt,注解处理器生成的JsonAdapter与反射冲突——保持同一版本且只选一种代码生成方式。 - 升级 Android Gradle Plugin 后缓存未清,依旧报 duplicate——
./gradlew clean并删除.gradle/caches/transforms-3。
6. 进阶建议:把冲突扼杀在摇篮里
- 在
buildSrc写一份版本清单,所有模块统一引用,禁止硬编码数字。 - CI 里加一条任务:解析依赖树并输出到 PR 评论,方便 Code Review 一眼看到新增库。
- 使用 Gradle
failOnVersionConflict()在本地提前失败,强制开发者显式解决。 - 对二方 SDK 要求提供
exclude-moshi的 pom 配置,或让厂商直接 shade。 - 定期跑
./gradlew dependencyUpdates,把整条网络保持最新,减少“旧+新”组合概率。
7. 小结与思考
依赖冲突不是编译器故意找茬,而是模块化生态的副作用。
Moshi 的RecordJsonAdapter只是冰山一角,今天遇到 duplicate,明天可能是 Okio、Kotlin Stdlib。
与其每次救火,不如把“版本仲裁策略”写进团队规范:统一入口、自动检测、渐进升级。
当你能一眼从依赖树里揪出那只“双胞胎”,就已经走在高质量交付的路上了。
写完这篇排查笔记,我又顺手把团队里的语音对话 Demo 升级了依赖——没错,就是那个用火山引擎豆包实时语音模型的小玩具。
如果你想亲手搭一个会“听、想、说”的 AI 伙伴,又不想被依赖冲突绊住脚,可以戳这个动手实验:从0打造个人豆包实时通话AI。
我按文档跑了一遍,半小时就能在浏览器里跟虚拟角色聊起来,顺便还能把今天学到的 Moshi 排坑技巧用上,一举两得。