首页 / 资讯中心 / 文章详情

Android Gradle依赖冲突全解析:从Duplicate class到工程化治理

Android Gradle依赖冲突全解析:从Duplicate class到工程化治理 ★ FEATURED ARTICLE
1. 从一次深夜发布事故说起你有没有过这种体验项目本地跑得好好的一打正式包就崩或者同事拉完代码怎么都编不过报错信息翻来覆去就是那一句“Dependency xxx not found”或者干脆是看不出任何头绪的Duplicate class。我前两年就栽过一回凌晨一点准备发版结果构建半小时后直接抛出一长串Duplicate class com.youdao...当时真是一眼黑。这事儿的根源就是依赖冲突Android Gradle 项目里最常见也最阴间的构建问题之一。它不会在你刚写完代码的时候冒出来往往在你升级某个库、引入一个新 SDK、或者把 compileSdk 往上提一档的时候突然爆发。你能搜索到无数篇讲 exclude 和 force 的文章但真正能系统性把问题摁死的并不多。这篇就是要把“依赖冲突”从里到外扒干净冲突到底怎么产生的、Gradle 默认怎么调停、用什么命令能把依赖树挖到底、有哪些常用的解决手法、各自适用什么场景、还有什么更工程化的手段可以避免团队反复踩同一个坑。不管你是刚入行的初级开发者还是经常给别人解决构建问题的老手这篇的思路和命令应该都能直接用上。2. 依赖冲突的本质Gradle 的“版本调停逻辑”为什么会坑你2.1 先搞懂依赖坐标与传递依赖每个 Android 依赖项都由三个要素唯一定位group、module、version也就是常说的group:module:version。比如常见的网络库就是com.squareup.okhttp3:okhttp:4.12.0这种三段式坐标。你在 build.gradle 里写一行implementation com.squareup.okhttp3:okhttp:4.12.0Gradle 会先把这个库本身拉下来同时还会把这个库所依赖的其他库也就是“传递依赖”一并拉下来例如 OkHttp 内部依赖的 Okio。问题往往就出在传递依赖上。一个 App 通常要集成几十个乃至上百个第三方库A 库内部依赖 OkHttp 3.14B 库内部依赖 OkHttp 4.9C 库内部依赖 OkHttp 4.11这个时候 Gradle 必须决定最终给你哪一个版本。2.2 Gradle 默认的调停策略选最高版本Gradle 默认的版本调停策略非常简单粗暴在完整的依赖图中对同一个group:module出现多个版本时默认选择最高的那个版本。这个策略从 Gradle 早期版本一直沿用至今好处是省事坏处是它根本不在乎某个库是否真的兼容高版本。我举个实际场景某个老牌图片加载库内部依赖了旧版androidx.core1.5.0而你在项目里直接依赖了androidx.core1.13.0。按照“选最高版本”的规则最终生效的是 1.13.0这通常是安全的因为 AndroidX 向后兼容做得还不错。可如果调换过来某个库必须依赖androidx.core2.0.0假设存在而你的业务代码是基于 1.x API 写的那高版本里某个 API 被删除或行为变化你压根意识不到问题出在哪因为代码是你自己写的报错却在第三方库深处。更常见的情况是 NDK 版、Kotlin 版本、协程版本这类“连锁冲突”。项目里有个模块用了 KTX 库KS 插件要求 Kotlin 1.8另一个模块传递依赖引入了 Kotlin 1.6Gradle 可能最终选了 1.8而那个模块是用 1.6 编译的运行时多为合成方法和元数据版本不一致构建不报错但运行就崩。2.3 依赖冲突的两个典型表现形态依赖冲突的报错形态主要有两类碰到之后先分辨是哪一种再动手查。**第一类Duplicate class重复类。**构建过程直接失败错误信息会明确列出多个来源 jar 包含同一个全限定类名。例如Duplicate class kotlin.collections.jdk8.CollectionsJDK8Kt found in modules kotlin-stdlib-1.8.0 (org.jetbrains.kotlin:kotlin-stdlib:1.8.0) and kotlin-stdlib-jdk8-1.8.0 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0)这种多半是同一个库的不同变体都被拉进来了比如kotlin-stdlib和kotlin-stdlib-jdk8或者老项目里support-compat和androidx.core同时存在。**第二类运行时崩溃或方法找不到。**构建没有问题但运行到某个功能时抛NoClassDefFoundError、NoSuchMethodError、ClassNotFoundException。比如你依赖了库 X库 X 在编译期以为某个类存在但运行时实际加载的却是另一个版本的 jar类方法签名对不上。这种比编译期重复类更恶心因为排查成本高出一大截。注意如果一个冲突最终没有导致上述两类问题那它在 Gradle 的视角里其实是“被正确调停”了不会报任何警告。也就是说很多潜在隐患是在静默状态下潜伏的直到某个库版本升级才被瞬间引爆。3. 排查四板斧把依赖树彻底看穿3.1 第一板斧Gradle dependencies 命令排查依赖冲突的第一步永远是看依赖树而不是瞎猜。在项目根目录执行./gradlew :app:dependencies --configuration debugRuntimeClasspath如果你在 Windows 环境下把./gradlew换成gradlew.bat。看到那串树状输出之后重点搜同一行里出现了-符号的位置。例如输出中可能出现com.squareup.okhttp3:okhttp:3.14.9 - 4.12.0这里的意思就是依赖图中有人要求 3.14.9但 Gradle 最终选的是 4.12.0。如果你并不希望它升级到 4.12.0就必须找出是谁传递依赖了 3.14.9然后决定是排除还是强制锁定。这里有一个新手特别容易踩的坑只想看自己的模块时用了./gradlew :app:dependencies结果输出里只显示implementation配置的依赖遗漏了api和运行时变体相关的依赖。一定要指定 configuration常见的有debugRuntimeClasspathdebug 包运行时的完整依赖集合排查运行时崩溃基本用它debugCompileClasspathDebug 编译期依赖排查编译期重复类用它releaseRuntimeClasspathrelease 包运行时依赖排查混淆或正式包专属问题用它3.2 第二板斧dependencyInsight 定位特定依赖依赖树看着庞大但如果要精确知道某个库的一整条依赖路径还是得用 insight 命令。假设我想搞清楚androidx.core这个库最终为什么是这个版本以及谁引入了它./gradlew :app:dependencyInsight --dependency androidx.core --configuration debugRuntimeClasspath输出结果会像这样androidx.core:core:1.13.0 Variant: default ... Selection reasons: - By conflict resolution ... com.example:some-sdk:2.1.0 \--- androidx.core:core:1.9.0 (requested)Selection reasons里会写清楚这个版本的选中原因By conflict resolution表示这是一个冲突调停结果By constraint表示受约束限制By ancestor表示由更高层依赖决定。这能帮你快速判断一个版本是“被人有意提高”还是“某个库带入后又碰巧被选中”。3.3 第三板斧Android Studio 的 Gradle 工具窗口对于不习惯敲命令的同学Android Studio 也提供了可视化的依赖查看方式。打开右侧 Gradle 面板找到app - Tasks - help - dependencies双击运行会在 Run 窗口里直接展示依赖树。或者用顶部菜单的Analyze - Analyze Dependencies...虽然功能不够细致但对快速确认某个库谁在用、用到哪里也已经足够。我个人更推荐敲命令因为可以带 configuration 参数而且看日志时可以快速复制粘贴到文本编辑器里做全文搜多。3.4 第四板斧开启构建扫描如果项目是团队协作依赖关系特别复杂建议直接把 Gradle 构建扫描能力用起来。在 settings.gradle 里已经默认内置了buildScan插件的支持执行./gradlew build --scan构建结束后终端会让输入邮箱以接收报告链接如果有私有构建扫描服务则是直接输出链接然后你可以在网页端看到完整的依赖图、每个依赖的冲突解决方案和耗时详情。网页版还支持拖拽式查看依赖关系比命令行输出直观太多。唯一要注意的是构建扫描会额外消耗一些上传时间内网环境需要搭配专用的扫描服务端才能完整体验个人项目用免费版也能玩。4. 五大解决方案详解从“修补”到“治理”4.1 方案一传递依赖排除exclude最简单的“外科手术”如果确认某个库传递进来的某个依赖根本没有用或者想换成自己的版本最直接的就是在依赖声明时排除它。排除有两种粒度按模块排除和按 group 排除。按模块排除会排除掉指定的group:module保留同组其他库implementation(com.example:library-a:1.0.0) { exclude group: com.squareup.okhttp3, module: okhttp }按 group 排除会把整个组织下所有模块都排除掉implementation(com.example:library-a:1.0.0) { exclude group: com.squareup.okhttp3 }我实际工作中很少用按 group 排除因为误伤概率太高。一个组织下可能有多个模块比如com.squareup.okhttp3下既有okhttp也有logging-interceptor你只想排除前者结果把后者也一起丢了构建反而报更多的错。使用 exclude 的注意点有三个排除会让依赖树结构不完整一旦被排除的模块确实被运行时需要结果就是ClassNotFoundException而且这种异常往往要在特定场景才能触发极其隐蔽。exclude 只对当前声明的依赖生效。如果同一个库被多处声明每处都得写一遍很容易漏。排除后最好重新跑一遍依赖树确认被排除的模块没有以另一个版本再次出现。4.2 方案二强制指定版本force用“蛮力”锁定唯一版本如果你已经确定某个版本是所有库都兼容的可以在 build.gradle 里直接 force 指定版本configurations.all { resolutionStrategy { force com.squareup.okhttp3:okhttp:4.12.0 force androidx.core:core-ktx:1.13.0 } }force 的意思就是无论依赖图里出现什么版本最终都用这个版本。Gradle 在冲突调停时会优先采用 force 的版本。force 的优点是一个地方声明全模块生效缺点是它完全绕过了传递依赖的真实编译版本风险比 exclude 还高。如果 force 的版本与某个库编译期的内部 API 不兼容结果也是运行期崩溃。我用 force 的场景基本有两个一是某个老库长期不再更新但它传递依赖拉进了带漏洞的旧版库必须强行替换二是团队里统一约定某个核心基础库版本比如全部锁定 OkHttp 4.12.0防止各模块各自为政。提示force 在 Gradle 里已经被标记为旧式用法deprecated了吗目前官方文档里没有完全废弃但建议优先使用 constraints。实际上 Gradle 新版本里 force 仍然可用但社区更倾向于用 constraint因为它可以搭配“可选版本”做更精细的控制。老项目里看到 force 不用急于替换新项目则建议直接用 constraint。4.3 方案三依赖约束constraint更优雅的“规则引擎”Gradle 从 4.6 开始引入了dependencyConstraints的概念后来以constraints的形式直接内置在 DSL 里。它的作用是定义一个“可能被选择”的版本而不是“强制指定”。dependencies { implementation com.squareup.okhttp3:okhttp constraints { implementation(com.squareup.okhttp3:okhttp) { version { strictly 4.12.0 } } } }注意这里第一行只写了group:module没有写版本真正的版本由 constraint 决定。严格版的strictly也是锁顶但和 force 的区别是如果依赖图中有某个库明确要求一个不兼容的更高版本strictly 会直接报错而不是默默选择高版本或者强制降级。这个行为在团队协作时特别有用——一个错误在构建初期就暴露而不是等运行时报个莫名其妙的异常。constraint 还有一个好处是可以“有条件地指定版本”比如只在某个配置生效或者通过because写清楚原因constraints { implementation(com.squareup.okhttp3:okhttp) { version { strictly 4.12.0 prefer 4.12.0 } because 所有三方库已验证兼容禁止意外升级 } }4.4 方案四组件选择规则Component Selection Rule彻底拒绝某个版本有些依赖冲突的根源是某个特定版本引入了 bug你不想让依赖图里出现这个版本而不是想锁定最终版本。此时用componentSelection写规则更合适configurations.all { resolutionStrategy { componentSelection { all { ComponentSelection selection - if (selection.candidate.group com.example.sdk selection.candidate.module core selection.candidate.version 2.0.0-beta) { selection.reject(2.0.0-beta is broken, please use stable version) } } } } }被拒绝的版本会直接排除出依赖图Gradle 会转而选择同模块的其他版本。这个方案适合那些“发布了一个坏版本又没法收回”的库属于比较后置的保底策略大多数项目里用到它的频率不高。4.5 方案五模块替换Substitute把某个库整体换成另一个还有一类冲突不是同模块版本冲突而是两个模块功能相同、坐标不同比如老项目用的com.android.support:appcompat-v7和新的androidx.appcompat:appcompat。理论上你不应该同时引用但迁移过程中确实会出现某些老库还没有 AndroidX 版本又必须在一个项目里共存的情况。用 substitute 可以把旧坐标直接替换成新坐标configurations.all { resolutionStrategy.dependencySubstitution { substitute module(com.android.support:appcompat-v7) using module(androidx.appcompat:appcompat:1.6.1) substitute module(com.android.support:support-v4) using module(androidx.legacy:legacy-support-v4:1.0.0) } }这样旧库的引用会自动映射到新坐标但前提是二者包名和 API 兼容否则还是会在运行时炸。AndroidX 迁移工具本身也能做类似的事情不过手工写 substitute 更可控。5. 工程化治理从“出了问题再修”到“让问题不发生”排除、force、constraint 这些都是“手术刀”解决单个冲突没问题但一个中大型项目如果隔三差五冒出依赖冲突本质上是依赖治理机制没建立起来。我经历过的几个团队项目在依赖治理上踩了很多坑之后总结出一套组合拳。5.1 用 Version Catalog 统一版本管理Gradle 从 7.0 开始把 Version Catalog版本目录作为正式特性推广。具体做法是在项目根目录的gradle文件夹下建一个libs.versions.toml文件统一存放依赖版本[versions] okhttp 4.12.0 coreKtx 1.13.0 [libraries] okhttp { module com.squareup.okhttp3:okhttp, version.ref okhttp } androidx-core-ktx { module androidx.core:core-ktx, version.ref coreKtx }然后在模块的 build.gradle 里这样引用implementation(libs.okhttp) implementation(libs.androidx.core.ktx)这种做法的好处是项目里不会再出现同一个库三种版本满天飞的情况。版本号只要在 toml 里改一次所有模块引用同步更新。配合 IDE 的代码提示也不会出现“版本字符串写错还编不过”的尴尬。5.2 建立依赖管理规范尽量少用 api多用 implementation很多依赖冲突都是因为api用得太随意。api会把依赖透传给下游模块而implementation则不会。假设你有三个模块app 依赖 module-coremodule-core 通过api依赖了 OkHttp那么 app 的依赖树上也会出现 OkHttp如果 module-core 改用implementationapp 就看不到 OkHttp 了整个依赖图都会瘦一圈。排查过重复类问题的开发者应该深有感触大量重复类往往就是某个上游模块用api把自己的老依赖广播给所有下游。所以团队规范里第一条就是除非模块的对外接口需要暴露某个类型否则一律用implementation。5.3 定期执行依赖更新体检依赖冲突往往都是依赖版本“老化”后突然爆发。比如某个库半年不更新它传递依赖了一个存在已知 bug 的旧版基础库当你引入另一个新库时两个老库的新旧版本就开始打架了。我现在所在的项目组会在每个迭代末尾跑一次脚本自动对比依赖树中所有版本与远程仓库最新版本然后手动评估是否升级。不一定每次都升级但至少保证心中有数不会等到发布前夜才被构建日志教做人。5.4 定制全工程的冲突预警脚本如果团队足够大还可以在 CI 脚本里加一个“依赖冲突扫描”任务。核心思路是构建成功后自动跑dependencies命令用脚本解析输出如果检测到同一group:module出现多个版本或者出现-箭头就输出警告甚至直接构建失败。实现上不算难Gradle 本身也有现成的插件可以做依赖分析比如某些静态分析工具自带 dependency 检查。如果你的项目已经在用自定义 Gradle 插件体系这一步的维护成本非常低但收益极大。6. 实战案例库六个最常撞上的冲突场景6.1 场景一support 库与 AndroidX 库共存老项目做 AndroidX 迁移时最容易出现的就是 support 相关库与 androidx 库同时在依赖树里。由于两者包含大量同名类构建直接报Duplicate class而且是几百行起步的那种。我当时的处理流程是这样的先跑./gradlew :app:dependencies --configuration debugRuntimeClasspath确认有哪些支持库旧坐标。对每个旧坐标逐一排查是被哪个第三方库引入的。对于第三方库本身提供 AndroidX 版本的直接升级该库到对应版本去掉旧坐标。对于第三方库本身就依赖旧 support 且不更新的用 dependencySubstitution 把旧坐标映射到 androidx。最后再跑一遍构建直到重复类清空。这里有个容易忽略的点android.enableJetifier其实也能处理一部分老库的问题但 Jetifier 本质是字节码改写每次构建都会增加耗时。能通过升级库解决的就尽量不要靠 Jetifier 硬扛。6.2 场景二三方库各自带入不同版本的图片加载库假设你的项目依赖了 A 库和 B 库A 库内部依赖了某个图片加载库的 2.x 版本B 库内部则依赖了同一个图片加载库的 3.x 版本。如果两个版本之间的 API 不兼容最终按“选最高版本”原则选中 3.xA 库在运行时调用了 2.x 的 API可能直接抛NoSuchMethodError。这种场景我总结的排查路线是用dependencyInsight找到 A、B 各自对图片加载库的版本请求。看一下自己项目中是否有直接依赖该库如果没有就显式添加依赖并锁定一个 A、B 都兼容的版本。如果找不到两个库的公共版本就只能排除其中一个库的传递依赖然后自己接入图片加载库并提供兼容适配。6.3 场景三Kotlin 标准库版本被反复提升Kotlin 版本冲突是最容易静默发生的。原因很简单几乎每个现代 Android 库都会传递依赖 Kotlin 标准库而 AGP 和 Kotlin 插件也会自动注入版本。你就会发现某个库传递依赖了kotlin-stdlib:1.6.0另一个库传递依赖了kotlin-stdlib:1.7.0最终选中 1.7.0 并不是问题但如果项目里同时存在kotlin-stdlib和kotlin-stdlib-jdk8就极容易触发重复类错误。常见的解决方案是统一用kotlin-stdlib并且通过约束强制到当前项目使用的 Kotlin 版本constraints { implementation(org.jetbrains.kotlin:kotlin-stdlib) { version { strictly 1.8.22 } } }实际上只要你始终保持kotlin-stdlib-jdk7和kotlin-stdlib-jdk8不被传递进来就能省掉相当一部分麻烦。直接排除守卫即可implementation(com.example:sdk:1.0.0) { exclude group: org.jetbrains.kotlin, module: kotlin-stdlib-jdk8 }6.4 场景四NDK 与 ABI 版本冲突依赖冲突不只是 Java/Kotlin 层的专利NDK 原生库也会冲突。比如项目里多个库都内置了不同版本的libc_shared.so打包时就会因为重复的 so 文件报错或者某些机型加载旧版本 so 导致崩溃。这类冲突没法用 Gradle 的 exclude 完全解决主要靠在 packagingOptions 里配置jniLibs的 keep 或 exclude。让所有原生库使用同一个 NDK 版本编译并要求三方库对齐。android { packagingOptions { jniLibs { useLegacyPackaging false excludes /libc_shared.so } } }要注意的是excludes /libc_shared.so这个配置是把 so 文件彻底排除掉。如果某个库运行时需要它那必须确保另一个库有对应的同版本 so 文件可以兜底。这个复杂度已经超出纯 Gradle 依赖冲突的范畴只能在架构选型层面控制原生库数量。6.5 场景五多模块工程中同一个库的版本漂移在大型多模块工程里每个业务模块可能都有自己偏好的一套基础库版本。有的模块用 OkHttp 4.10有的模块用 OkHttp 4.12一时构建不出问题但后续一旦某个模块升级了 Kotlin 插件或 compileSdk依赖漂移的问题就集中爆发。我见过的极端案例是一个工程有 30 多个模块依赖树里同一个生命周期库出现了 5 个版本且每个版本都活跃在运行时。这种状态根本没法通过一两句 force 解决必须从版本管理机制上约束把统一版本提升到工程规范层面。6.6 场景六BOMBill of Materials带来的“低调”冲突如果你用过某些大厂 SDK 或者 Spring Boot 这种后端框架可能已经接触过 BOM 的概念。Android 这边也慢慢出现类似做法某些 SDK 会提供一个 BOM 坐标你只要引入它它就把一整套相关的库的版本都约束好。比如implementation platform(com.example:some-bom:1.0.0) implementation com.example:library-a implementation com.example:library-bBOM 本质上是一堆约束的集合它本身不包含代码只告诉 Gradle “这些库应该用这些版本”。好处是统一、省心坏处是如果两个 BOM 对同一个库给了不同版本strictly冲突会直接报错不会默默选一个。前几年我在项目里遇到过一个 SDK 的 BOM 把我们整个项目的协程版本锁到了 1.5.0而后另一个 SDK 要求 1.6.0两边直接僵住。解决办法是解析 BOM 内容后把不需要的条目用exclude在平台坐标上排除implementation platform(com.example:some-bom:1.0.0) { exclude group: org.jetbrains.kotlinx, module: kotlinx-coroutines-android }这种操作可以局部摆脱 BOM 的约束但也说明 BOM 不是万能的。7. 常见问题的排查速查表这里整理了一份我在实际工作中用到的问题速查表按“现象 - 定位方式 - 处理方案”的格式来组织方便你遇到问题时直接对着看。现象可能的定位方式推荐处理方案构建报 Duplicate classdependencies 或构建日志中的模块列表排除多余变体统一版本运行时报 NoClassDefFoundError先复现崩溃场景再依赖树分析检查是否有人用 excludue 误删了必需依赖运行时报 NoSuchMethodErrordependencyInsight 查看冲突版本显式引入正确版本或加 constraint 锁定构建成功但库功能异常比对多个库的版本兼容性排除某个库的传递依赖并自行适配多模块下版本漂移全局扫依赖树用 Version Catalog constraints 统一管理升级某个库后一堆库报错检查该库的新版本是否改变了依赖要么升级所有相关库要么锁旧版本备注上面表格里的处理方案是“通常有效的路径”但不代表能套用所有项目。每个项目的库组合不同最可靠的做法永远是先把依赖树完整挖出来再对着具体报错逐层处理。8. 我的最终建议“三步走”排查法说了这么多实操的时候最怕的就是拿着一个报错瞎试。我会把排查流程固定成三步任何依赖冲突都先走这三步再考虑别的。**第一步识别冲突类型。**编译期的重复类错误、运行期的类找不到错误处理思路完全不同。先看报错类别再决定下一步用什么命令。**第二步挖出完整依赖路径。**执行dependencyInsight找到“谁引入了这个库”以及“最终选中了哪个版本”。不要只看最终版本关键的是那条完整链路因为只有知道链路才能对症下药。**第三步选对处理手段按优先级排列。**优先选择升级或降级第三方库这是最干净的方案其次是用 constraints 统一版本再次是排除传递依赖force 作为保底手段使用substitute 尽量少用。为什么我不推荐一上来就写 exclude因为我见过太多人 exclude 掉一个依赖之后另一个地方又开始报错最后项目里堆了二三十行 exclude谁都不敢动。先确定真正有问题的链路再做最小的修改才是维护依赖树的长久之计。一个健康的项目依赖树应该简洁、透明、所有版本都在掌控之中。碰到依赖冲突不可怕可怕的是每次都是靠瞎猜和乱写排除来“碰运气”解决这样迟早会欠下技术债务。希望你读完这篇之后下次再看到Duplicate class时第一反应是掏出依赖树命令而不是直接复制网上的 exclude 代码。最后再多说一句排查依赖冲突的过程也是审视工程依赖卫生的好时机。如果每次冲突排查后都能顺手清理掉没用的依赖把版本收敛到统一目录里你会发现自己解决同类问题的速度会越来越快构建时间也会肉眼可见地缩短。这算是我这些年从依赖冲突这座“屎山”里爬出来之后最想分享的一条经验。
阅读完成 · 觉得有帮助?
咨询建站