你有没有遇到过这种场景——新拉下一个项目,AS 同步刚跑起来,进度条就卡在某个依赖上下载不动,过一会儿直接一片红:Could not resolve ...。然后删掉 Gradle 缓存重来,还是不行,最后只能挨个问同事“你那边能编译吗”。如果这个描述戳中了你,那这篇笔记应该能帮你省掉不少弯路。
这是 Gradle 系列笔记的第四篇,专门聊依赖下载这件事。从一行implementation声明到依赖最终进入本地缓存,这条链路到底是怎么运作的;下载慢、下载失败又该怎么一步步定位;传递依赖和版本冲突为什么会把人逼疯。适合被依赖问题折腾过的 Android 开发者,也适合刚接触 Gradle 想搞清楚底层逻辑的新手。我会把实际项目里踩过的坑和验证过的排查手段都写出来,尽量做到能直接照着操作。
1. 依赖下载的完整链路:坐标、仓库与本地缓存
1.1 一行依赖声明背后发生了什么
很多同学对依赖的理解停留在“写一行就能用”的层面,一旦出问题就只会删.gradle目录。其实依赖下载这条链路由三层组成:坐标描述、仓库查找、缓存落地。任何一个环节出问题,最终表现都是同步失败。
先看一个最常见的声明:
implementation 'com.squareup.okhttp3:okhttp:4.12.0'这里面的三段信息分别是group:name:version,Gradle 会把它翻译成一个远程路径去仓库里找。以 Maven 仓库为例,它实际访问的文件是:
com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.pom com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.jar也就是说,坐标里的冒号会被替换成路径分隔符。Gradle 拿到 POM 文件或 Gradle Metadata 文件之后,再去解析这个模块的传递依赖,最后把需要的 JAR、AAR 逐个下载到本地。理解这个路径规则很重要,因为排查问题时你经常需要手动拼出这个 URL 去验证某个文件是否真的存在。
1.2 仓库查找顺序与“第一个命中者”
Gradle 解析依赖时有一个容易忽略的行为:它是按顺序逐个仓库去试的。
假设你配置了三个仓库:
repositories { mavenCentral() google() maven { url = uri("https://maven.example.com/repository/public") } }Gradle 会先查 Maven Central,如果没有匹配的 group/name/version,再查 google,最后才查你的私有仓库。找到就直接用,不会继续往后翻。这里有个经典问题:如果同一个依赖在多个仓库都有,Gradle 只认第一个命中的,不同仓库的版本内容可能不一样,甚至某些仓库里存在被篡改或残缺的 POM 文件。我之前遇到过一次,AAR 包在 Maven Central 上是正常的,但私有仓库里缓存了一份损坏的元数据,Gradle 按顺序先访问了私有仓库,结果校验失败,整个构建就卡在那里。后来调整了仓库顺序才解决。
1.3 下载下来的东西到底存在哪里
Gradle 的下载缓存默认在用户目录下:
~/.gradle/caches/modules-2/files-2.1/里面按 group/name/version 分目录,每个版本下面放了 3 个关键文件:.pom(或.module)、.aar/.jar、以及哈希校验文件。Gradle 在下载时会把文件先写入临时目录,经过哈希校验后再移动进正式缓存目录。这也是为什么你偶尔会看到~/.gradle/caches/下有残留的.lock文件或临时文件——某些情况下进程被杀掉,临时文件没清干净。
除了这个模块缓存,还有两个目录需要了解:
| 目录 | 作用 |
|---|---|
~/.gradle/caches/modules-2/metadata-2.* | 依赖描述符的序列化缓存,用于加速解析过程 |
~/.gradle/wrapper/dists | Gradle 本身的分发版本缓存,和依赖无关但经常被混淆 |
很多人说“我清了 Gradle 缓存”,实际只是删了~/.gradle/caches。如果删除的时候 Gradle 进程正在运行,Windows 上经常会提示文件被占用,删不干净,这时候需要先停掉所有 Java 进程再删。
2. 仓库配置的正确姿势与常见翻车点
2.1 默认仓库和三方仓库的定位差异
在 Android 工程里,现代模板默认的仓库配置是这样的:
pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } }我不是来逐行讲语法的,而是想提醒你注意几个背后的逻辑。google()放最前面是合理的,因为 Android SDK、AndroidX 等大量构件只在 Google 的 Maven 仓库里有。mavenCentral()是 JVM 生态的老牌中央仓库,覆盖面最广。gradlePluginPortal()则专门用于解析 Gradle 插件。
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)这一行可能很多人没注意,它表示禁止在子模块的build.gradle里再写repositories。设置成这个模式后,所有仓库都统一在顶层管理,避免不同模块引用了不同的仓库顺序导致解析结果不一致。实际项目里我见过有人在一个多模块工程里每个模块都写仓库,有的写了jcenter()(已经关闭),有的写了旧地址,导致同一份依赖在不同的模块里解析结果不同,编译时灵异报错。所以如果你在维护一个多模块项目,建议尽早统一仓库配置。
2.2 仓库顺序、国内镜像与手动验证
仓库顺序影响的除了命中优先级,还有构建速度。在国内开发环境下,直接访问 Maven Central 和 Google Maven 通常很慢,甚至超时。常见的做法是配置国内公共镜像仓库(这里以你实际使用的服务商镜像地址为准,域名部分我用示例占位):
repositories { maven { url = uri("https://maven.example-mirror.com/repository/maven-public") } maven { url = uri("https://maven.example-mirror.com/repository/gradle-plugin") } google() mavenCentral() }但这里有个细节:把镜像放在前面,会让 Gradle 对所有依赖都先访问镜像。镜像同步频率不如官方源,某些新发布的版本可能在镜像上还没有,导致明明官方源有,你却下载失败。我的做法是:项目里默认用官方源,遇到特定仓库下载超时,再把对应仓库的镜像加到最前面,并配合下文说的超时配置,不要全盘替换。
手动验证一个依赖是否真的存在,可以直接用浏览器或命令行访问 POM 路径,比如用curl -I看响应头:
curl -I https://repo.maven.apache.org/maven2/com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.pom返回200 OK就说明官方源有这个文件。如果镜像上没有,返回404,那基本就能定位到“仓库不同步”的问题。
2.3 私有仓库接入时最容易被忽略的两个配置
私有仓库在很多公司的 Android 项目里很常见,用来放内部 SDK。接入方式本身就是加一个maven { url = uri("...") },但有两个点经常被坑到:
一是HTTP 仓库。如果仓库地址是http://而非https://,Gradle 6 以上默认会拒绝访问,需要在 settings 里显式允许:
maven { url = uri("http://maven.internal.example.com/repository/maven-public") isAllowInsecureProtocol = true }不配置这个,报错信息会直接告诉你Using insecure protocols with repositories, without explicit opt-in is unsupported。安全起见,建议内部仓库上 HTTPS,这就不只是给 Gradle 加开关的问题了。
二是认证信息。私有仓库需要用户名密码时,不要硬编码在 build 脚本里。Gradle 支持从环境变量或本地文件读取:
maven { url = uri("...") credentials { username = providers.environmentVariable("MAVEN_USER").orNull password = providers.environmentVariable("MAVEN_PASSWORD").orNull } }或者把凭据写在~/.gradle/gradle.properties里,然后从Providers.gradleProperty()读取,这样脚本不进版本库也不容易泄露。
3. 下载慢、下载失败:完整排错链路与可疑点清单
3.1 先分清问题是哪一类
依赖下载失败的报错看起来五花八门,但归纳起来就几类。先分清楚类型,排查就不会像无头苍蝇。
| 报错关键词 | 问题类型 | 常见原因 |
|---|---|---|
Could not resolve | 解析失败 | 坐标不存在、仓库没配、仓库顺序不对 |
Could not GET | 网络访问失败 | 网络不通、超时、被拦截,仓库地址变了 |
Certificate | HTTPS 证书问题 | 内部仓库用自签名证书 |
Checksum | 文件校验失败 | 下载不完整、仓库文件损坏 |
Using insecure protocols | 明文仓库被拒 | HTTP 仓库未显式开启 |
这一步很重要。很多人看到Could not resolve就疯狂清缓存,但如果是网络访问失败,清缓存完全没用。所以遇到问题第一件事:完整阅读--stacktrace输出的第一段异常,找到真正是网络层、协议层还是依赖声明层的问题。
我自己常用的排错命令是:
./gradlew :app:dependencies --configuration debugRuntimeClasspath --stacktrace这个命令会列出指定配置解析到的全部依赖树。如果某个依赖显示FAILED,它会把失败的仓库尝试顺序也打出来。通过这份日志,你能直接看到 Gradle 依次访问了哪些仓库、哪个仓库先失败、最终放弃了哪个候选地址。
3.2 网络层定位:区分“仓库没有”和“机器访问不到”
网络层问题是国内开发者最常踩的,也是最难直接看出来的。我的一般步骤是这样的:
第一步,测试基本连通性。用curl -v访问仓库地址,观察 TLS 握手和响应时间。如果卡在connected状态很久,说明是网络路由问题;如果返回403/407,说明是网关拦截。
第二步,检查代理相关配置。这里不是指某种特殊工具,而是公司内网常见的 HTTP 代理。Gradle 运行时会读取环境变量里的HTTP_PROXY、HTTPS_PROXY,如果这些变量指向一个已经失效的代理,表现就是所有网络请求都超时。在终端先确认:
echo $HTTP_PROXY echo $HTTPS_PROXY如果发现代理变量指向了过期的地址,临时清掉再跑一次构建,问题立刻就能确认。还有一种情况相反:你在需要代理的网络环境里,但环境变量没设,Gradle 直连不通。这种情况可以在gradle.properties里显式配置代理参数,但我更推荐保持环境变量统一管理,避免脚本里留硬编码。
第三步,检查 DNS 解析。仓库域名解析到错误的 IP 时,症状也是超时。用dig或nslookup查一下目标域名返回的 IP,对比正常的解析结果。
3.3 强制刷新缓存与离线模式的正确用法
Gradle 默认对同一版本只下载一次,之后直接使用缓存。这个机制大多数情况下很省事,但也会掩盖问题:比如仓库那边的文件更新了(典型的如 SNAPSHOT 版本),本地还是旧的。
强制刷新缓存的命令是:
./gradlew build --refresh-dependencies它的作用是让 Gradle 重新检查所有依赖的远程状态,如果有变化就重新下载。注意:这个命令会大大增加构建时间,并且如果仓库不可达,直接失败。不要把它当成日常命令用。
还有一种情况是本地其实已经有缓存了,但构建还在反复尝试联网。如果你确定依赖在缓存里是好的,只是想快速编译验证代码,可以加--offline参数:
./gradlew assembleDebug --offlineGradle 会完全跳过远程检查,只用本地缓存。这个模式在出差路上、网络极差时很救命。但要注意,如果某个依赖在本地缓存里是缺失的,--offline模式下会直接报找不到,不会给你任何下载的余地。
这里再说一个容易混淆的点:AS 里常见的File -> Sync Project with Gradle Files不是下载按钮,它只做配置同步。真正触发依赖下载的是执行任务,比如 build、assemble。有不少人同步失败第一反应就是“重新 Sync”,但如果你只是改了仓库配置,Sync 才会起效;如果只是代码变了,Sync 根本不会重新下载依赖。
3.4 修改下载超时时间与重试次数
Gradle 默认的网络超时时间是多少?说实话默认值挺短的,在网络波动稍大的环境里很容易失败。官方文档里的默认配置项是这样的:
| 配置项 | 默认值 |
|---|---|
systemProp.org.gradle.internal.http.connectionTimeout | 30000 ms |
systemProp.org.gradle.internal.http.socketTimeout | 30000 ms |
systemProp.org.gradle.internal.http.retries | 3 次 |
可以把它调大一点。国内网络环境下我一般会把连接超时调到 120000 ms,并在gradle.properties里加上:
systemProp.org.gradle.internal.http.connectionTimeout=120000 systemProp.org.gradle.internal.http.socketTimeout=120000 systemProp.org.gradle.internal.http.retries=5调大超时以后,下载慢问题会从“一分钟超时”变成“耐心等两分钟可能就过了”,能明显减少莫名其妙的失败。当然这也意味着如果仓库彻底不可用,构建会卡更长的时间,所以这个参数需要结合实际情况权衡。
4. 传递依赖与版本冲突:大多人在这里折腾到半夜
4.1 你引一个库,它偷偷带进来一卡车库
传递依赖是 Gradle 默认行为。你声明com.squareup.okhttp3:okhttp:4.12.0,Gradle 在解析它的 POM 文件时,会把它内部依赖的okio、kotlin-stdlib等一并加入依赖图。这个机制让开发者不用手动补齐一个库的所有底层依赖,但也带来了不透明的隐患。
最常见的问题是版本冲突。比如 A 库依赖okio:2.10.0,B 库依赖okio:1.14.0,两个版本同时在依赖图里。Gradle 的默认策略是取版本较高的那个,也就是最终解析到2.10.0,但这不代表一切都会顺利。如果当前项目的其他代码是按照 1.x API 写的,运行时就可能抛NoSuchMethodError。
这种问题最折磨人的地方在于编译期不报错,运行时才崩。所以如果你在运行阶段遇到了奇怪的NoSuchMethodError或ClassNotFoundException,第一步就该怀疑依赖冲突。
4.2 排查依赖树的几个实用命令
我排查依赖问题时,一般会这样操作:
# 查看整个模块的依赖 ./gradlew :app:dependencies # 只看某个 configuration ./gradlew :app:dependencyInsight --dependency okio --configuration debugRuntimeClasspathdependencies输出的是完整依赖树,方便你快速概览。dependencyInsight则是反向查询:指定一个库,它告诉你这个库为什么会被引入、被哪个库引入、最终解析到哪个版本。处理冲突时,dependencyInsight比dependencies好用得多。
输出的依赖树里会有一些标记,比如->表示版本被强制升级,(*)表示版本是继承来的,failed表示解析失败。这些符号看懂了,排查效率能翻倍。
再补充一个--info级别的日志技巧:
./gradlew :app:assembleDebug --info在这个日志级别下,Gradle 会打印每个依赖下载的详细地址和耗时,适合定位具体是哪个依赖卡住了下载。日志会非常长,建议用管道重定向到文件再搜索:
./gradlew :app:assembleDebug --info > build.log 2>&1 grep "Downloading" build.log这样你能清楚地看到哪些依赖是从远程下载的,哪些是命中缓存直接跳过的,耗时多少。结合这个日志,就能知道“整体构建慢”是因为依赖数量多还是网络慢。
4.3 排除依赖、强制版本的正确姿势
解决冲突的办法有很多,但我建议遵守“从最小干预开始”的原则。上来就全局强制版本,容易掩盖真正的问题。
如果确认某个库的传递依赖是多余的,可以单独排除:
implementation("com.example:sdk:2.0.0") { exclude group: "com.example.legacy", module: "old-utils" }排除粒度可以是 group 级别,也可以是 group+module 级别。模块内排除比全局排除更安全,影响范围可控。
如果冲突确实无法通过排除解决,可以在依赖声明时强制指定版本。对于implementation形式的单库依赖,可以直接把目标版本写在后面:
implementation("com.example:library:1.0.0") { force = true }但如果你在维护多模块工程,我更推荐用平台 BOM 的方式统一管理版本。比如定义一个版本目录(version catalog):
[libraries] okhttp = { module = "com.squareup.okhttp3:okhttp", version = "4.12.0" } okio = { module = "com.squareup.okio:okio", version = "3.7.0" }然后在多个模块里统一引用:
implementation(libs.okhttp) implementation(libs.okio)这样版本的入口只有一个,全局想升级时改一处即可。版本目录在gradle/libs.versions.toml文件里定义,Android 新模板默认就支持,不用额外装插件。它的核心价值不是技术多深,而是让项目里不再出现每个模块各写一个版本的乱象。
5. 锁版本、依赖治理与日常保养
5.1 动态版本和 SNAPSHOT 的隐患
有些刚接触 Gradle 的同学喜欢这样写依赖:
implementation("com.example:library:1.+")或者用latest.release。这种写法的初衷是“永远用最新的”,但实际会带来两个问题:
一是构建的不确定性。同样一份代码,上周能编译,这周因为依赖更新了构建失败,你会很难定位是代码问题还是依赖问题。在多人协作和 CI/CD 环境里,这是大忌。
二是每次构建都要重新检查远程版本,拖慢构建速度。Gradle 对动态版本有默认的缓存时间(默认 24 小时),但没有锁版本的话,这个缓存时间再长也只是减少检查频率,无法彻底消除不确定性。
SNAPSHOT 版本同理。很多内部库在开发阶段发布 SNAPSHOT,如果你在正式分支里依赖它,每次构建都可能在拉取新快照,而快照内容是不保证稳定的。我见过因为这个导致线上版本实际打包进去的依赖内容和本地测试完全不一致。
5.2 把依赖版本锁定的实操方案
Gradle 自带依赖锁定(dependency locking)机制,但它在 Android 项目里用得不算多,原因是一般配合版本目录就已经够了。不过如果你对可复现性要求高,还是可以启用锁文件:
dependencyLocking { lockAllConfigurations() }启用后,首次构建会生成锁文件。之后构建时,Gradle 会按照锁文件里记录的精确版本列表解析依赖,不会轻易漂移。
在团队里推荐把生成的锁文件纳入版本库,这样所有人构建的都是同一批依赖。升级依赖时用到下面这个命令:
./gradlew dependencies --write-locks它会更新锁文件到最新允许的版本范围。锁文件的好处是:你不需要手动去逐条改版本号,只要放宽范围跑一次更新,它会把所有依赖的最新状态写进去。这种机制在大型多模块项目里比手动维护版本目录还省心。
5.3 日常工程里值得养成的几个依赖习惯
依赖治理这件事,不解决的话会越来越痛。我自己的经验是,长期稳定的做法往往不是应急操作,而是一些不起眼的小习惯:
第一,每个关键依赖的版本升级都单独提交,并在提交信息里写清楚升级原因。依赖一起升级几十个,出了问题很难定位是哪个造成的回归。
第二,定期用依赖检查工具看看有没有废弃版本或已知漏洞版本。不用过度追求最新,但也不能常年停在远古版本。市面上有不少现成插件可以做这件事,选一个适合自己团队的即可。
第三,新引入第三方库前,先看它的依赖树,确认它不会把一堆你不想要的东西带进项目。方法很简单,加完依赖跑一次./gradlew :app:dependencies,看看新增的树里有没有重复的、过旧的库。这一步多花两分钟,能避免之后好几个小时的排查。
第四,全组统一 Gradle 版本和 JDK 版本。依赖解析行为在不同 Gradle 版本之间会有差异,团队内如果不统一,经常出现“我这边编译过了你那边不行”的闹剧。用 Wrapper 配置好版本,并确保大家用的是同一份gradle-wrapper.properties。
最后再分享一个个人体会:依赖问题九成以上不是靠瞎试能解决的,而是靠一次完整的链路排查——坐标对不对、仓库通不通、缓存干不干净、是谁把某个版本带上来的。把这套排查思路练熟,下载失败、版本冲突这类问题基本就不会再纠缠你了。如果你正被某个依赖问题卡住,按照上面这几步走一遍,大概率能找到答案。