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,代码会变得多冗余?封装一下,清爽很多。
多文件上传时,还有一个常见需求——同时上传文件和其他表单字段。比如上传图片时附带一个描述文字。这时候用 @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 响应头:服务器告诉你「当前返回的是哪一段」
流程很简单:
- 先检查本地有没有已下载的部分文件
- 如果有,拿到已下载的字节数,记为
downloadedBytes - 发起请求时带上
Range: bytes={downloadedBytes}- - 服务器返回
206 Partial Content,并在响应头里告诉你剩余内容 - 把新数据追加到文件末尾
我曾经在一个下载管理器项目里,遇到过一个诡异的问题——每次断点续传后,文件尾部总会多出几个字节的乱码。排查了半天,发现是文件写入模式没设置对。追加写入时,一定要用 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 发起的下载请求,都会自动触发进度回调。你想想看,是不是比在每个接口里手动算进度优雅多了?
不过要注意一点——如果你用了 @Streaming 注解,ResponseBody 的数据是边下载边读取的,进度回调会实时触发。如果没用 @Streaming,Retrofit 会先把整个响应体读进内存,进度回调会在下载完成后一次性触发。嗯,这个坑我踩过。
最后,我把整个文件上传下载的知识体系整理成了一张图,方便你理解:
这张图把三个核心知识点串在了一起。上传用 @Multipart,下载靠 Range 协议,进度监听交给拦截器。各司其职,互不干扰。
最后说一句——文件操作一定要处理好异常。网络断开、磁盘空间不足、文件被占用……这些情况在线上都会遇到。我习惯在下载函数里加一个 try-catch,捕获到异常后把已下载的文件信息存到本地数据库,下次启动时自动恢复下载。嗯,这才是靠谱的做法。
File.length() 作为已下载字节数。结果发现如果上次下载中途崩溃,文件尾部可能有损坏数据。后来我改成在下载完成后写入一个校验文件,记录正确的字节数和 MD5。下次续传前先校验,避免「续传了个坏文件」。
公众号:蓝海资料掘金营,微信 deep3321