Gradle 里写一个下载任务,代码量真不大,二十行就能让文件落地;可要把这件事做稳,坑能从构建脚本一路铺到流水线上。我见过构建机磁盘被一堆半截的 zip 和 bin 文件撑爆,也见过某个"下载成功"的产物其实是上一次失败留下的残骸,最后被一路打进发布包里。这类问题的共同点是:第一次跑通很轻松,第二次、第二百次跑才会开始出问题。
这篇文章聊的就是Gradle 下载任务插件从设计到落地会遇到的典型问题,以及在Gradle 8 之后(Configuration Cache 默认开启、Kotlin DSL 成主流)这些问题的表现和解决方式。内容覆盖自定义 DownloadTask 的输入输出声明、增量构建判定、HTTP 层的超时与重定向、大文件断点续传、校验和、并行构建下的文件锁,以及"插件根本拉不下来"这类搜关键词时经常撞上的另一种情况。适合已经能写简单 Gradle Task、想把下载环节做成可复用插件的同学,也适合正在排查 CI 上偶发下载失败、产物损坏问题的工程同学。文中的 URL 和路径全部是示例占位,照着换成自己的即可。
1. 先把"下载任务"这件事拆开:它到底该由谁负责
动手写代码之前,值得先花五分钟想清楚一个问题:你这次要下的东西,是"依赖",还是"资源"。这两者在 Gradle 的世界里走的是完全不同的两条路,选错了,后面所有的问题都会变得难解。我踩过最典型的一次,是把一个 200MB 的模型文件当成"文件依赖"塞进了 dependencies 块,结果依赖缓存里多了一份、build 目录里又多了一份,构建缓存再存一份,磁盘占用翻了六倍不说,每次内容更新还会因为坐标没变而导致依赖不刷新。
1.1 三类"下载"场景,别混在一起处理
我把实际项目里遇到的下载需求归成三类,处理方式完全不同。
第一类是带坐标的二进制依赖,也就是 jar、aar、pom 这些。这种一律交给依赖管理去做,仓库声明、缓存、校验、冲突解析全是现成的,自己写下载逻辑纯属自找麻烦。
第二类是有版本号但不在仓库里的原始文件,比如数据集、模型权重、字体包、预编译的工具链、第三方 SDK 的压缩包。这类文件通常很大、更新频率低、必须有完整性校验,是自定义 DownloadTask 的主战场。
第三类是构建期动态拉取的产物,比如从内部服务拿一份配置快照、拉一张清单文件。这类东西的特点是内容随时在变、体积极小,重点不在下载本身,而在于怎么让 Gradle 知道"这次的内容和上次不一样"。
分类的意义在于:第二类和第三类对增量构建的要求完全相反。第二类希望"校验通过就永远别重下",第三类恰恰需要"每次都要重新判断内容是否变化"。很多下载任务写崩,根源就是拿第二类的写法去处理第三类的需求,或者反过来。
1.2 为什么不用依赖管理直接拉原始文件
有人会问,Gradle 的repositories配上flatDir或者自定义 Ivy 仓库,不也能拉任意文件吗。能,但代价不小。依赖管理是为"坐标 → 构件"的映射设计的,它假设同一个坐标的内容永远不变,一旦你违背这个假设,缓存就会给你上课。更现实的问题是,很多文件根本不在任何仓库里,就是一个直链,硬套依赖管理等于自己造一个假的仓库元数据,维护成本远超写一个 Task。
反过来,自定义 Task 的代价是你得自己负责增量判断、自己负责校验、自己负责并发安全。这三件事只要漏一件,就会在某个深夜的流水线上爆出来。所以我的取舍是:能进仓库的坚决进仓库,进不去的才写 Task,而且写就写全。
1.3 一个能跑的最小骨架(先求对,再求快)
先把最短路径跑通。下面这段放在build.gradle.kts里就能工作,用的是 JDK 自带的java.net.http.HttpClient,Gradle 8 要求 JDK 17 起步,所以这个类一定有。
abstract class DownloadTask : DefaultTask() { @get:Input abstract val url: Property<String> @get:OutputFile abstract val target: RegularFileProperty @TaskAction fun run() { val out = target.get().asFile out.parentFile.mkdirs() val client = java.net.http.HttpClient.newBuilder() .connectTimeout(java.time.Duration.ofSeconds(10)) .followRedirects(java.net.http.HttpClient.Redirect.NORMAL) .build() val req = java.net.http.HttpRequest.newBuilder(java.net.URI.create(url.get())).build() client.send( req, java.net.http.HttpResponse.BodyHandlers.ofFile( out.toPath(), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.TRUNCATE_EXISTING, java.nio.file.StandardOpenOption.WRITE ) ) } } tasks.register<DownloadTask>("fetchAsset") { url.set("https://example.invalid/assets/model-v3.bin") target.set(layout.buildDirectory.file("assets/model-v3.bin")) }这段能跑,但它有三个明显的问题,正好对应后面三章要讲的内容:文件是直接写到最终路径的,中途失败会留下半截;没有任何校验,下坏了也不知道;@Input只有 URL,内容变了但 URL 没变时会误判为最新。先把骨架立住,再一层层补。
注意:不要用
new URL(String)去构造地址,它对空格、中文、#的处理和你想象的不一样,用URI.create再转,或者干脆要求调用方传入已编码的地址。
2. 让任务"跑一次就够":输入输出声明与增量构建
增量构建是 Gradle 最值钱的能力,也是自定义任务最容易毁掉的能力。判断标准其实很朴素:Gradle 在任务执行后会给所有@Output*标注的文件拍一个快照存进.gradle目录,下次构建时比对输入指纹和输出快照,全都一致就跳过。所以你的任务要么声明得足够准确让它能跳过,要么声明得足够诚实让它不能跳过,最怕的是声明了但声明错了——那会出现"该跳的没跳"和"不该跳的跳了"两种灾难,后者更致命。
2.1 @Input 与 @Output 的边界怎么划
输入标注的核心原则是:凡是会影响输出内容的,全部标@Input。对一个下载任务来说,至少有三个:地址、期望的校验和、以及可能存在的请求头(比如版本标识)。只标 URL 是远远不够的,因为线上资源经常是"地址不变、内容更新",这种时候 URL 的指纹没变,Gradle 就会认为输出还是最新的,直接跳过。
我的做法是强制要求调用方提供sha256,把它作为@Input。这样输入指纹天然包含了内容的身份,地址换了、内容变了、校验值变了,任何一个变化都会触发重新执行。校验和的来源可以是发布方给的清单文件,也可以是自己第一次下载后记录下来固化到脚本里的常量——后者土但有效,尤其适合那些"发布方不给校验值"的资源。
输出标注有个细节容易被忽略:如果你下载的是一个压缩包并解压到目录,那么@OutputDirectory应该标解压目录,压缩包本身要么标@LocalState(可被清理的中间产物),要么放到layout.buildDirectory下的临时位置。标错了会导致 Gradle 把压缩包当最终产物做快照和缓存,白白消耗时间。
2.2 半截文件才是增量构建的头号杀手
这是我最想强调的一条。假设任务执行到一半,网络断了,抛异常退出。Gradle 对失败的任务不会更新执行历史,所以下次构建它会重跑——这种情况下没问题。真正的问题出在两种边缘场景。
第一种是异常被吞掉了。比如你在下载逻辑外面包了 try-catch,捕获后只打了一行logger.warn,任务正常返回。Gradle 认为任务成功,把半截文件拍了快照存进历史。下一次构建,输入没变、输出快照没变,直接跳过。这个残骸会一直存在,直到有人手动清理。
第二种是多个任务共享同一个输出路径。A 任务成功了写了文件,B 任务也声明了同一个@OutputFile但失败了,历史记录的混乱程度会让人怀疑人生。
解决办法是固定的套路:永远先写.part临时文件,校验通过后原子重命名到最终路径。这样最终路径上要么不存在,要么一定是完整的。Files.move(src, dst, StandardCopyOption.ATOMIC_MOVE)在同一文件系统内是原子操作,Windows 上如果目标已存在会抛异常,所以要先删目标再 move,或者用REPLACE_EXISTING配合非原子降级。我一般这么做:先删目标(如果存在),再 move,接受极小的中间窗口——因为即使此刻崩溃,结果也只是"文件不存在",下次会重下,不会产生"文件存在但是坏的"。
2.3 Build Cache 与几百 MB 的大文件:该关就关
Gradle 的构建缓存会把@OutputFile的内容按哈希存到本地或远程缓存里。一个 500MB 的模型文件,意味着每次它变化都会往缓存里塞 500MB,远程缓存如果是共享的,团队几个人的构建机加起来能把它撑爆。
我的处理是给这类任务加@get:CacheableTask的反面——也就是显式声明不缓存:
@get:DisableCachingByDefault(because = "大文件不适合进入构建缓存") abstract class DownloadTask : DefaultTask() { /* ... */ }DisableCachingByDefault是 Gradle 7.0 之后推荐的写法,比在outputs.cacheIf里写条件更清晰。注意它只影响构建缓存,不影响增量构建,任务该跳过还是跳过,只是不往缓存里存内容。
如果文件确实不大(几十 KB 的清单、配置),那反过来应该打开缓存,并且把@Input声明完整,这样团队成员和 CI 之间可以直接复用,省掉一次网络请求。判断阈值我的经验是 10MB,超过就别进缓存了。
3. HTTP 层的实际坑位:超时、重定向、编码与鉴权
任务本身的结构搭好之后,剩下九成的问题都出在 HTTP 这一层。Gradle 跑在 JVM 上,网络行为受 JVM 系统属性、代理设置、TLS 配置共同影响,而这些配置在本地开发和 CI 环境里往往不一样,于是就有了"我本地好好的,CI 上就是不行"。
3.1 客户端选型:为什么我最终固定在 JDK 自带 HttpClient
能选的就三类:HttpURLConnection、JDK 11+ 的java.net.http.HttpClient、以及 OkHttp / Apache HttpClient 这类第三方库。
HttpURLConnection的问题是太老,连接池行为诡异,重定向默认不跟随,遇到 307/308 直接返回让你自己处理,断点续传要靠setRequestProperty手动拼 Range 头,写出来一堆模板代码。它的唯一优势是零依赖。
第三方库功能最全,OkHttp 的连接池、拦截器、自动重试都很成熟,但代价是你得把依赖打进 buildSrc 或者插件本身,这会引入插件类路径的冲突风险。尤其当你的插件被别的插件依赖时,传递依赖打架是常有的事。
JDK 自带的HttpClient是我现在的默认选择:零依赖、支持 HTTP/2、支持异步、BodyHandlers提供了文件落盘和流式处理两种模式、连接池行为可预期。它的短板是不支持 HTTP/1.0 的一些老服务器特性,也不像 OkHttp 那样内置重试,但这两点对我来说反而是好事——重试逻辑应该由我自己控制,因为盲目重试大文件下载会把流量翻倍。
3.2 超时参数:连接超时和请求超时不是一回事
这里有个非常高频的误解。HttpClient.connectTimeout()是建立 TCP 连接的超时,和传输时长无关。而HttpRequest.timeout()是整个请求从发出到响应体读取完成的总时长——注意,是总时长。
对一个 300MB 的文件来说,如果按"请求不能超过 60 秒"去设置HttpRequest.timeout,那它在慢速链路上必定超时,而且报的错误还是HttpTimeoutException,看起来像网络问题,实际上是配置问题。我见过有人为了"解决超时"把这个值改成 30 分钟,结果遇到服务器挂起时整个构建卡死半小时。
正确的做法是:connectTimeout设 10 秒左右,HttpRequest.timeout对下载请求不要设或设得很大,然后用流式读取配合自定义的"空闲超时"来控制——也就是两次读到数据之间的间隔超过阈值才判定失败。BodyHandlers.ofInputStream()配合带超时的读操作可以做到这点,但实现起来略繁琐;更省事的办法是直接用BodyHandlers.ofByteArrayConsumer或自己解析流,在读循环里记录时间戳。如果不追求极致,把HttpRequest.timeout去掉、依赖底层 TCP 的保活机制,也是可行的,代价是服务器彻底挂死时要等操作系统的 TCP 超时(通常是几分钟)。
| 参数 | 作用范围 | 推荐值 | 设错的后果 |
|---|---|---|---|
connectTimeout | TCP 建连 | 5-10 秒 | 太大导致服务器不可达时长时间等待 |
HttpRequest.timeout | 单次请求总时长 | 下载请求不设 | 大文件在慢链路上必然超时 |
| 读空闲超时 | 两次读之间的间隔 | 30 秒 | 服务器挂起时构建卡死 |
| 任务级重试次数 | 整个请求 | 2-3 次 | 太多导致流量翻倍、CI 时间失控 |
提示:重试要加退避,且只在"连接被拒、5xx、读超时"这几类可恢复错误上重试。遇到 4xx 直接失败,重试一百次也不会变好。
3.3 重定向、URL 编码与 403
重定向方面,HttpClient.Redirect.NORMAL会跟随同协议和 http→https 的跳转,但不会跟随 https→http 的降级跳转,这个行为我认为是合理的,能挡住一部分中间人攻击的降级场景。如果你用的地址经过 CDN 重定向后恰好是降级,那就会拿到一个 301 却不跟随,表现为"空文件"或者"下载失败"。遇到这种情况先确认跳转链路,再决定要不要显式处理,不要一上来就改成ALWAYS。
URL 编码是另一个高频坑。带中文、空格、括号的路径,直接在字符串里拼进 URI 是不行的,URI.create遇到非法字符会抛IllegalArgumentException。正确做法是对路径部分逐段编码:
val encoded = "报表 2024(最终).xlsx".replace(" ", "%20") // 更稳妥:用 URI 的多参构造或对每一段做 URLEncoder对整串用URLEncoder.encode是错的,它会把/也编码掉,导致路径结构被破坏。分段编码、保留分隔符,是唯一正确的姿势。
403 的情况多和鉴权头缺失、User-Agent 被拦、Referer 校验有关。有些 CDN 对空 User-Agent 的请求直接拒绝,加一个普通的 UA 头就能过。但要注意,加了 UA 之后如果开始返回 200 但内容是 HTML 错误页,那就是跟错了重定向或者被防护页拦了——这种"200 但内容不对"最难查,只能靠校验和兜底。
3.4 鉴权信息不要写进 URL
把 token 拼在 query string 里是最常见的做法,也是最不该用的做法。原因有三个:URL 会被写进@Input,从而出现在构建扫描、CI 日志、缓存 key 里;URL 会被打日志方便排查,一打就泄漏;URL 可能被服务端记录进访问日志。
正确做法是把凭据放在@get:Internal的属性里,通过请求头传递,并且重写toString()避免被打印。@get:Internal的含义是"参与任务执行但不参与输入指纹计算",用它正好——因为凭据变了不应该触发重新下载,但凭据本身又确实需要传给动作。
@get:Internal abstract val token: Property<String> // 使用 .header("Authorization", "Bearer ${token.get()}")凭据的来源应该是环境变量或 Gradle 属性,脚本里写providers.environmentVariable("ASSET_TOKEN"),不要写死。CI 上通过密钥管理注入,本地通过gradle.properties(记得加进忽略列表)。
4. 大文件下载与断点续传:流、临时文件、校验和三件套
小文件怎么写都行,大文件是另一回事。这里的"大"门槛比想象中低,50MB 开始就要认真对待了,因为构建机上的内存、磁盘、网络都不是你独占的。
4.1 流式落盘与缓冲区大小
绝对不要用BodyHandlers.ofByteArray()去接一个大文件,它会先把整个响应体读进内存,200MB 的文件意味着峰值内存至少 200MB,几个任务并行走会直接 OOM,然后报一个和下载毫无关系的OutOfMemoryError,排查方向会完全跑偏。
流式写入的标准写法是拿到InputStream之后用固定大小的缓冲区循环读:
val buffer = ByteArray(64 * 1024) var total = 0L input.use { ins -> FileOutputStream(partFile).use { out -> while (true) { val n = ins.read(buffer) if (n < 0) break out.write(buffer, 0, n) total += n } out.fd.sync() // 关键:刷盘后再 rename } }缓冲区大小我实测下来 64KB 到 256KB 之间差别不大,64KB 足够覆盖绝大多数场景,开太大反而增加 GC 压力。真正影响速度的是有没有用BufferedOutputStream包一层——直接用FileOutputStream逐块写,在小块情况下会产生大量系统调用。我这里简化了,实际代码里我会套BufferedOutputStream(FileOutputStream(part), 128 * 1024)。
fd.sync()这一步值得单独说。不加它,File层面的写入可能还在操作系统页缓存里,此时如果直接rename然后断电或者强制终止进程,文件内容可能是空的但文件名是对的。加上 sync 会慢一点(每个文件几十毫秒),但对构建产物来说这个代价可以接受。
4.2 断点续传的判定条件
断点续传不是"看到.part文件就接着下",那样会拼出一个内容错乱的怪物。正确的判定链路是:
先看.part文件是否存在且大小大于 0,是的话带上Range: bytes=<已有大小>-发起请求。服务端如果支持,返回206 Partial Content,此时才能追加写入;如果返回200 OK,说明服务端忽略了 Range 或者资源已变,必须从头开始,把.part截断重写。
这里有个隐藏问题:服务端可能对不同版本的资源都返回 206,导致你把新旧两个版本的内容拼在一起。防这个要用If-Range头,配合上一次响应返回的ETag或Last-Modified。如果资源变了,服务端会返回 200 全量内容,你就能识别出来。所以落盘的时候除了文件本身,还要把 ETag 存到旁边的.part.meta文件里。
我的实际经验是,很多内部文件服务其实不支持 Range(返回 200 或者 416),这时候断点续传逻辑就退化成"重新下载",反而多了一次无效请求。所以我会在第一次失败后记录该地址不支持 Range,本次构建内不再尝试续传。这个"记住"只需要放在任务实例的字段里,不需要持久化。
4.3 校验和:放在 rename 之前
校验的时机非常关键:必须在.part转正之前做。顺序是 下载 → 计算哈希 → 与期望值比对 → 一致则 rename → 不一致则删掉.part并失败。
计算哈希同样要流式,不要readBytes()之后算:
val digest = java.security.MessageDigest.getInstance("SHA-256") FileInputStream(partFile).use { ins -> val buf = ByteArray(64 * 1024) while (true) { val n = ins.read(buf) if (n < 0) break digest.update(buf, 0, n) } } val actual = digest.digest().joinToString("") { "%02x".format(it) }这里有两个实操细节。第一,哈希比对要忽略大小写并 trim,因为清单文件里的值经常带空格或者大写十六进制。第二,如果期望值本身为空(调用方没提供),千万不要静默跳过校验直接放行,而应该至少记录一次实际哈希到日志里,方便事后比对。我甚至会在 debug 模式下把实际哈希写进一个.sha256旁文件,出问题时能快速定位是哪一次下载出了问题。
4.4 并行构建下的文件锁
--parallel开启后,多个子项目可能同时触发同一个下载任务。如果它们下载到同一个路径,你会看到两个进程往同一个.part里写,最终文件大小翻倍、校验失败,或者其中一个 rename 时把另一个刚写好的文件覆盖掉。
解决办法是在文件层面加锁。FileChannel.lock()提供跨进程的排他锁,虽然在某些网络文件系统上不可靠,但在本地磁盘和主流 CI 环境里够用:
RandomAccessFile(lockFile, "rw").use { raf -> raf.channel.use { ch -> val lock = ch.lock() // 阻塞直到拿到锁 try { if (!target.get().asFile.exists()) { doDownload() } } finally { lock.release() } } }拿到锁之后要再检查一次目标文件是否存在,因为可能在等锁的过程中别人已经下完了。这就是典型的双重检查。锁文件本身放在build/.locks或gradleUserHome下,不要和产物放在一起,避免被当成输出。
更省事的替代方案是把下载结果放到一个全局共享的缓存目录(比如gradle.gradleUserHomeDir下的自定义子目录),然后任务只负责"确保缓存里有、再复制到 build 目录"。这样多个项目共享同一份下载,文件锁的范围也收敛到一个地方。但这个方案会带来缓存清理的问题,需要配套的清理任务,视团队情况取舍。
5. 配置缓存与离线模式:Gradle 8 之后绕不开的两道坎
如果你的插件在 Gradle 7 上跑得好好的,升到 8 之后突然报一堆 "invocation of Task.project at execution time is unsupported",那就是撞上配置缓存了。这不是 bug,是 Gradle 在强制把"配置阶段"和"执行阶段"彻底分开,出发点是构建性能,但对自定义插件来说意味着任务类里很多东西不能再碰。
5.1 Configuration Cache 对任务代码的硬性约束
规则很明确:任务动作执行时,不能访问project对象。这意味着project.logger、project.layout、project.exec、project.copy、project.file全都不能用。
替代方案是依赖注入。Gradle 提供了@get:Inject来注入几个服务:
| 服务类型 | 替代的旧 API | 用途 |
|---|---|---|
Logger | project.logger | 打日志 |
ObjectFactory | project.objects | 构造 Property、Provider |
FileSystemOperations | project.copy/project.delete | 文件复制删除 |
ExecOperations | project.exec | 执行外部命令 |
ArchiveOperations | project.zipTree/tarTree | 解压 |
ProviderFactory | project.providers | 读取环境变量和属性 |
写法上就是在抽象类里声明抽象属性或者 getter:
abstract class DownloadTask : DefaultTask() { @get:Inject abstract val fs: FileSystemOperations @get:Inject abstract val providers: ProviderFactory @get:Inject abstract val log: Logger }还有一个隐藏的坑:任务的构造函数里也不能碰project。很多老代码习惯在init块里做路径计算,升到 8 之后直接炸。正确做法是用Provider延迟计算,在配置阶段把layout.buildDirectory.file(...)的结果 set 进去,执行阶段只读 Provider 的值。
第三个坑是不要捕获不可序列化的对象。配置缓存会把任务的配置状态序列化存起来,如果你在配置阶段把一个HttpClient实例塞进了任务的字段里,序列化就会失败。所有运行时才需要的对象,都应该在@TaskAction里现场创建。
5.2 离线模式下该失败还是该跳过
--offline的语义是"不要访问网络"。对于下载任务,这里有个策略选择:如果目标文件已经存在且校验通过,直接跳过;如果不存在,应该快速失败并给出明确的错误信息,而不是尝试连接然后等超时。
判断方式是通过注入的ProviderFactory读启动参数:
val offline = providers.gradleProperty("org.gradle.offline")实际上更可靠的是通过StartParameter,但配置缓存下它也不能直接访问。一个折中的做法是在配置阶段(不是执行阶段)读取gradle.startParameter.isOffline,转成一个Property<Boolean>传给任务。配置阶段访问是允许的,只要不把它带进执行阶段的闭包里。
失败时的错误信息要写清楚,告诉使用者"当前处于离线模式,xxx文件不存在,请联网后重跑或手动放置到yyy路径"。这句话能省掉大量沟通成本。
5.3 代理与证书:为什么依赖能下、你的任务不能下
这是排查起来最费时的一类问题。现象是:Gradle 解析依赖一切正常,gradle.properties里配了代理,但你的下载任务就是连不上。
原因是代理配置的作用范围不同。Gradle 的依赖解析有自己的 HTTP 客户端,它读的是systemProp.http.proxyHost这类 JVM 系统属性,或者 Gradle 内部的代理配置。而你的任务用的是 JDK 的HttpClient,它默认的ProxySelector取的是java.net.useSystemProxies和 JVM 参数。如果只配了 Gradle 层面的代理而没配 JVM 系统属性,两者行为就会不一致。
解决办法有两个:一是在gradle.properties里统一用systemProp.https.proxyHost、systemProp.https.proxyPort的形式配置,这样两边都能生效;二是在代码里显式读配置并构造ProxySelector:
val proxy = ProxySelector.of(InetSocketAddress(host, port)) val client = HttpClient.newBuilder().proxy(proxy).build()显式构造的好处是可控,可以只在需要时启用代理,坏处是配置项要自己管理。我一般选前者,因为在团队协作里"少一个配置项"永远是正确的方向。
证书问题表现为PKIX path building failed或SSLHandshakeException。内部 CA 签发的证书不在 JDK 的信任库里,就会这样。正确做法是通过-Djavax.net.ssl.trustStore指定包含内部根证书的信任库,或者在HttpClient构造时传入自定义SSLContext。不要图省事写一个信任所有证书的TrustManager,那等于把下载通道的安全性完全放弃,尤其是在 CI 上跑的时候,这个风险不能接受。
6. 问题速查:从现象倒推原因的排查路径
前面讲的都是设计和实现层面的东西,这一节聊排查。Gradle 的错误信息有个特点:真正的原因经常被包在两三层异常里,最外层那句话基本没什么用。掌握几个固定的排查动作,能省掉大量猜的时间。
6.1 症状对照表
| 现象 | 最可能的原因 | 快速验证方式 | 处理 |
|---|---|---|---|
| 文件存在但大小是 0 | 直接写最终路径,请求失败后未清理 | ls -l看大小和时间戳 | 改.part+ rename |
| 文件大小对但内容损坏 | 未做校验,或续传拼接错误 | 手动算 SHA-256 比对 | 加校验,续传加 If-Range |
| 第二次构建不下载了 | @Input不完整,内容变了但指纹没变 | --info看任务是否 UP-TO-DATE | 加校验和作为输入 |
| 每次都重新下载 | @OutputFile路径在 build 目录被清理,或输入含时间戳 | --info看任务为何不跳过 | 检查输入里有无动态值 |
OutOfMemoryError | 用了ofByteArray接大文件 | 看堆栈是否在 HttpResponse | 改流式写入 |
| 连接超时但网络正常 | 代理未生效 | curl同地址对比 | 配 systemProp 代理 |
PKIX path building failed | 内部 CA 不在信任库 | 看证书链 | 配 trustStore |
| 403 Forbidden | 缺 UA 或鉴权头 | 用curl -v复现 | 补请求头 |
| 并行构建时文件损坏 | 多任务写同一路径 | 看是否--parallel | 加文件锁或改共享缓存 |
| 升级 Gradle 后任务报错 | 访问了project | 看是否提示 configuration cache | 改用注入服务 |
6.2 三个典型的排查过程
第一个案例是"CI 上偶发下载失败,本地永远成功"。现象是流水线随机某个任务报IOException: Connection reset,重跑就过。查了一圈,发现构建机上同时跑着多个流水线,并发下载把出口带宽打满了,触发了中间设备的连接数限制。解决不是改代码重试,而是把下载任务的并发度降下来——用maxParallelUsages限制,或者在 CI 配置里对下载类任务加锁串行。这类问题的教训是:偶发的网络错误,先怀疑环境,再怀疑代码。
第二个案例是"下载下来的 jar 能解压但启动报 ClassNotFound"。这种最迷惑,文件大小对、能解压、SHA-256 也对不上期望值但没人发现——因为期望值是从同一个错误源复制的。最后定位到是服务端在特定 UA 下返回了一个精简版包,两个包大小只差几 KB。教训是:校验值的来源必须独立于下载源,从下载服务拿校验值等于没校验。
第三个案例是"任务被标记为 UP-TO-DATE,但产物明明是旧的"。查--info日志,发现任务的输入里有一个@Input是字符串,值来自System.currentTimeMillis()。看起来像是"每次构建都应该重跑",实际因为配置缓存把第一次的值存下来了,后面每次都一样,于是被判定为没变化,直接跳过。这类问题的通用排查手法是:加上--info,找Task ':xxx' is not up-to-date because:这行,后面会列出具体是哪个输入变了或者没变。
提示:调试增量构建问题,
--info是必须的,--debug信息量太大反而不好看。如果怀疑是缓存问题,先--rerun-tasks强制重跑一次,确认代码逻辑没问题,再回头查缓存。
6.3 插件本身解析不到的情况
搜"Gradle 下载任务插件"这个词的时候,还有一批人是另一个意思:插件本身下不下来。典型报错是Plugin [id: 'xxx'] was not found in any of the following sources。这跟自定义下载任务没关系,是插件仓库解析的问题,但既然撞上了就一并说。
排查顺序是:先确认settings.gradle.kts里的pluginManagement.repositories是否包含插件所在的仓库,默认只有 Gradle Plugin Portal,如果你的插件发布在内部仓库,必须显式加进去。再确认版本号是否写错——插件市场里的版本和 Maven 仓库里的版本经常不同步,写了个不存在的版本,报错信息也是"not found",很容易误判。
如果确认仓库和版本都对,那就看网络。企业内网通常有仓库代理,需要在pluginManagement.repositories里把内部仓库地址放在最前面,让 Gradle 优先走内网。同时也别忘了在dependencyResolutionManagement.repositories里做同样的配置,这两处是独立的,只配一处会导致"插件能解析但依赖不能"。
还有一个细节:pluginManagement块必须写在settings.gradle.kts的最前面,写在plugins块后面会直接报语法错误。这个规则很多人第一次写的时候会踩。
我个人在实际操作中的体会是,下载任务这类"看起来简单"的基础设施代码,投入产出比的分水岭就在"要不要做校验"和"要不要做临时文件"这两个决定上。省掉这两步,代码能短三分之一,但你会用后面几个月的偶发故障来偿还。反过来,把.part + 校验 + 原子重命名这三件套做扎实,剩下的问题基本都能靠日志和--info定位,不会出现那种"东西坏了但不知道为什么"的悬案。另外,任何时候看到自己写catch (e: Exception) { log.warn(...) }包住整个下载流程,都应该停下来想一想:这个异常吞掉之后,Gradle 会认为任务成功了,它会给一个可能不完整的文件盖上"已验证"的章。这个章一旦盖上,后面所有环节都会信任它。