13、文件上传与下载:多文件上传(@Multipart)、断点续传原理、使用OkHttp的DownloadInterceptor

文件上传下载,几乎是每个App都绕不开的功能。头像上传、附件发送、大文件下载……这些场景里,坑特别多。我这些年踩过的雷,十个手指头都数不过来。

今天咱们就把这块彻底讲透。从多文件上传的注解玩法,到断点续传的核心原理,再到用OkHttp拦截器实现下载进度监听。嗯,都是实战干货。

13.1 多文件上传:@Multipart 的正确姿势

Retrofit处理文件上传,靠的是 @Multipart 注解。说白了,就是告诉 Retrofit:「嘿,这个请求的body是 multipart/form-data 格式,你给我按块处理」。

先看一个最基础的例子:

interface FileApiService {

    @Multipart
    @POST("upload/single")
    suspend fun uploadSingleFile(
        @Part file: MultipartBody.Part
    ): Response<UploadResult>

    @Multipart
    @POST("upload/multiple")
    suspend fun uploadMultipleFiles(
        @Part files: List<MultipartBody.Part>
    ): Response<UploadResult>
}

这里有个细节——MultipartBody.Part 怎么构造?我习惯封装一个工具方法:

fun File.toMultipartBodyPart(
    partName: String = "file"
): MultipartBody.Part {
    val requestBody = this.asRequestBody(
        contentType = this.extension.toMediaTypeOrNull()
            ?: "application/octet-stream".toMediaType()
    )
    return MultipartBody.Part.createFormData(
        name = partName,
        filename = this.name,
        body = requestBody
    )
}

你想想看,如果每个上传接口都手动构造 Part,代码会变得多冗余?封装一下,清爽很多。

我的习惯: 文件名用原始文件名,但服务端有时会重命名。如果你需要保留原始文件名,记得在 Part 的 filename 参数里传进去。

多文件上传时,还有一个常见需求——同时上传文件和其他表单字段。比如上传图片时附带一个描述文字。这时候用 @PartMap

@Multipart
@POST("upload/with-description")
suspend fun uploadWithDescription(
    @Part files: List<MultipartBody.Part>,
    @PartMap description: Map<String, @JvmSuppressWildcards RequestBody>
): Response<UploadResult>

注意 @JvmSuppressWildcards 这个注解。我在项目中遇到过 Kotlin 泛型通配符导致编译报错的问题,加上它就解决了。嗯,算是个小坑。

13.2 断点续传原理:从 HTTP 协议说起

断点续传,听起来高大上,其实核心就两个东西:

  • Range 请求头:告诉服务器「我要从第 N 个字节开始下载」
  • Content-Range 响应头:服务器告诉你「当前返回的是哪一段」

流程很简单:

  1. 先检查本地有没有已下载的部分文件
  2. 如果有,拿到已下载的字节数,记为 downloadedBytes
  3. 发起请求时带上 Range: bytes={downloadedBytes}-
  4. 服务器返回 206 Partial Content,并在响应头里告诉你剩余内容
  5. 把新数据追加到文件末尾

我曾经在一个下载管理器项目里,遇到过一个诡异的问题——每次断点续传后,文件尾部总会多出几个字节的乱码。排查了半天,发现是文件写入模式没设置对。追加写入时,一定要用 RandomAccessFile 或者 FileOutputStream(File, true)

注意: 不是所有服务器都支持断点续传。如果服务器返回 200 OK 而不是 206,说明它忽略了你的 Range 请求头。这时候只能重新下载。

下面是我封装的一个断点续传下载函数的核心逻辑:

suspend fun downloadWithResume(
    url: String,
    saveFile: File,
    client: OkHttpClient
): Result<File> {
    // 1. 获取已下载字节数
    val downloadedBytes = if (saveFile.exists()) saveFile.length() else 0L

    // 2. 发起 Range 请求
    val request = Request.Builder()
        .url(url)
        .header("Range", "bytes=$downloadedBytes-")
        .build()

    val response = client.newCall(request).await()

    // 3. 检查是否支持断点续传
    if (response.code != 206) {
        // 不支持,重新下载
        saveFile.delete()
        return downloadFull(url, saveFile, client)
    }

    // 4. 获取总大小
    val contentRange = response.header("Content-Range") ?: ""
    val totalBytes = extractTotalBytes(contentRange)

    // 5. 追加写入
    val outputStream = FileOutputStream(saveFile, true)
    response.body?.byteStream()?.use { input ->
        outputStream.use { output ->
            input.copyTo(output)
        }
    }

    return Result.success(saveFile)
}

这里有个小细节——copyTo 默认缓冲区是 8KB,对大文件来说偏小。我一般会手动指定 64KB 或 128KB 的缓冲区,能明显提升写入速度。

13.3 使用 OkHttp 的 DownloadInterceptor

下载进度监听,是另一个高频需求。很多人第一反应是用 Retrofit 的 @Streaming 注解配合 ResponseBody 自己算进度。但这样有个问题——每个下载接口都得写一遍进度逻辑。

更好的做法是用 OkHttp 的拦截器。拦截器可以拦截请求和响应,在响应体外面包一层,实时计算进度并回调。

先看拦截器的核心实现:

class DownloadInterceptor(
    private val onProgress: (downloaded: Long, total: Long) -> Unit
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val originalResponse = chain.proceed(chain.request())
        
        return originalResponse.newBuilder()
            .body(ProgressResponseBody(
                originalResponse.body,
                onProgress
            ))
            .build()
    }
}

class ProgressResponseBody(
    private val delegate: ResponseBody?,
    private val onProgress: (Long, Long) -> Unit
) : ResponseBody() {

    override fun contentType(): MediaType? = delegate?.contentType()

    override fun contentLength(): Long = delegate?.contentLength() ?: -1L

    override fun source(): BufferedSource {
        val source = delegate?.source() ?: return Buffer().apply { write(byteArrayOf()) }
        
        return source.apply {
            val totalBytes = contentLength()
            var downloadedBytes = 0L

            // 包装原始 Source,每次读取时更新进度
            val countingSource = object : ForwardingSource(this) {
                override fun read(sink: Buffer, byteCount: Long): Long {
                    val bytesRead = super.read(sink, byteCount)
                    if (bytesRead != -1L) {
                        downloadedBytes += bytesRead
                        onProgress(downloadedBytes, totalBytes)
                    }
                    return bytesRead
                }
            }
            
            // 这里需要返回包装后的 Source
            // 实际使用时,需要将 countingSource 作为新的 BufferedSource 返回
            // 简化起见,此处省略了具体包装代码
        }
    }
}

使用的时候,把拦截器加到 OkHttpClient 里:

val client = OkHttpClient.Builder()
    .addInterceptor(DownloadInterceptor { downloaded, total ->
        val percent = if (total > 0) {
            (downloaded.toFloat() / total * 100).toInt()
        } else 0
        Log.d("Download", "进度:$percent% ($downloaded/$total)")
    })
    .build()

这样,所有通过这个 OkHttpClient 发起的下载请求,都会自动触发进度回调。你想想看,是不是比在每个接口里手动算进度优雅多了?

核心思路: 拦截器 + 装饰器模式。用 ProgressResponseBody 装饰原始的 ResponseBody,在读取数据时插入进度计算逻辑。不入侵业务代码,干净利落。

不过要注意一点——如果你用了 @Streaming 注解,ResponseBody 的数据是边下载边读取的,进度回调会实时触发。如果没用 @Streaming,Retrofit 会先把整个响应体读进内存,进度回调会在下载完成后一次性触发。嗯,这个坑我踩过。

最后,我把整个文件上传下载的知识体系整理成了一张图,方便你理解:

文件上传与下载 · 知识体系 📤 多文件上传 @Multipart 注解 MultipartBody.Part @PartMap 传表单字段 文件类型 MediaType List<Part> 批量上传 封装工具方法 避免重复代码 📥 断点续传 Range 请求头 Content-Range 响应头 206 Partial Content RandomAccessFile 追加写入模式 检查服务器支持 失败回退全量下载 🔧 DownloadInterceptor Interceptor 拦截 装饰 ResponseBody ForwardingSource 实时进度回调 不入侵业务代码 注意 @Streaming 统一管理进度

这张图把三个核心知识点串在了一起。上传用 @Multipart,下载靠 Range 协议,进度监听交给拦截器。各司其职,互不干扰。

最后说一句——文件操作一定要处理好异常。网络断开、磁盘空间不足、文件被占用……这些情况在线上都会遇到。我习惯在下载函数里加一个 try-catch,捕获到异常后把已下载的文件信息存到本地数据库,下次启动时自动恢复下载。嗯,这才是靠谱的做法。

避坑指南: 我曾经在断点续传时,直接用 File.length() 作为已下载字节数。结果发现如果上次下载中途崩溃,文件尾部可能有损坏数据。后来我改成在下载完成后写入一个校验文件,记录正确的字节数和 MD5。下次续传前先校验,避免「续传了个坏文件」。

公众号:蓝海资料掘金营,微信 deep3321