
一. 引言
文件下载几乎是每一个稍微复杂一点的 App 都绕不开的需求:下载资源包、音视频文件、离线数据、更新包......可以说是"老朋友"了。
在日常开发中,我们通常都会使用一套成熟的网络请求方案,比如:
- OkHttpClient:底层网络通信
- Retrofit:接口层封装
- Moshi / Gson:JSON 序列化与反序列化
这些框架对接口请求的支持已经非常完善了,写起来也非常优雅。
但是当我想下载数据时比如zip,或者是.mp3,.mp4,当前接口请求这一套就有点满足不了了。
二. 网络请求方案简单回顾
在谈到下载之前,我们先简单回顾一下这网络三套各自的职责。
OkHttpClient
- 底层 HTTP 客户端
- 负责:连接、请求、响应、流读取
- 非常灵活,但偏底层
下载本质上就是 OkHttp 最擅长、但又最原始的事情
Retrofit
- 基于 OkHttp 的接口封装层
- 用注解描述 API
- 非常适合:请求 JSON → 转 Model
但对于下载来说:
- Retrofit 仍然是 流式下载
- 进度监听、文件写入,最终还是要你自己处理
Moshi
- JSON ↔ Model 转换
- 与下载本身关系不大
那么这么分析完之后,我们就可以看出下载这一套流程和网络请求需要分开来写了。
我们都希望 OkHttp 可以提供一个简单的下载方法,给我们回调进度,给我们下载后的资源地址,但是很遗憾并没有。
OkHttp对下载的态度是 :我只负责网络通讯,至于你要怎么处理数据,那是开发者的事儿。
所以在下载场景中,OkHttp 只给我们:
- ResponseBody
- 一个 InputStream
剩下的全交给开发者:
- 下载进度怎么算
- 文件存哪
- 写文件的方式
- 是否支持协程
- 异常如何处理
三. 基于 OkHttp 的下载实现
既然需要我们自己来实现细节,那么在写代码之前我们首先要明确,一个完整的下载,至少需要处理哪些事情。
- 发起网络请求
- 校验 HTTP 状态码
- 获取文件总大小(用于进度)
- 从 InputStream 读取数据
- 按 buffer 写入本地文件
- 计算并回调下载进度
- 处理异常
- 切换到 IO 线程执行
接下来,我们开始围绕上面的这些点开始一步步的展开。
3.1 代码实现
下面是一个简单但是很完整的下载工具类:DownloadHelper
Kotlin
package com.example.americandramaassistantandroid.network
import okhttp3.OkHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.File
import java.io.IOException
class DownloadHelper {
private val client = OkHttpClient()
/// 下载
suspend fun downloadZip(
url: String,
targetFile: File,
onProgress: (Int) -> Unit
) = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
}
val body = response.body
?: throw IOException("Response body is null")
val contentLength = body.contentLength()
val inputStream = body.byteStream()
var totalRead = 0L
val buffer = ByteArray(8 * 1024)
targetFile.outputStream().use { output ->
while (true) {
val read = inputStream.read(buffer)
if (read == -1) break
output.write(buffer, 0, read)
totalRead += read
if (contentLength > 0) {
val progress = (totalRead * 100 / contentLength).toInt()
onProgress(progress)
}
}
output.flush()
}
}
}
}
3.2 代码解析
使用协程切换到 IO 线程
Kotlin
withContext(Dispatchers.IO)
- 下载和文件写入都属于 IO 操作
- 避免阻塞主线程
- 与 ViewModel 非常好配合
构建并执行请求
Kotlin
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).execute()
- 这里使用的是 同步请求
- 但因为在 IO 线程中执行,不会卡 UI
- 同步请求更利于控制下载流程
校验响应合法性
Kotlin
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
}
- 下载失败往往不是"读不到流"
- 而是 HTTP 状态码就已经失败了
获取文件大小 & 输入流
Kotlin
val contentLength = body.contentLength()
val inputStream = body.byteStream()
- contentLength 用来计算进度
- 某些服务器可能返回 -1,这也是为什么要做判断
边读边写文件
Kotlin
val buffer = ByteArray(8 * 1024)
while (true) {
val read = inputStream.read(buffer)
if (read == -1) break
output.write(buffer, 0, read)
totalRead += read
}
这是下载的核心逻辑:
- 8KB buffer 是一个常见、合理的大小
- 一边从网络读
- 一边往文件写
- 不占用大量内存
下载进度回调
Kotlin
val progress = (totalRead * 100 / contentLength).toInt()
onProgress(progress)
- 进度完全由我们自己控制
- 可以很容易和 UI 层绑定
- 比 Retrofit 的下载监听更直观
四. 使用
在 ViewModel 中使用非常自然:
Kotlin
viewModelScope.launch {
downloadHelper.downloadZip(
url = downloadUrl,
targetFile = targetFile
) { progress ->
_downloadProgress.value = progress
}
}
我们只需要传入文件的下载地址,和文件的保存路径。
而UI层就只需要关心:当前进度、是否完成、是否失败。
五. 结语
下载看起来是一个"老生常谈"的功能,但真正自己实现一次,你会发现:
- 它非常底层
- 但也非常可控
- 非常适合用来理解 OkHttp 的本质
OkHttp 并没有替你"把事做完",而是把选择权全部交给了你。
这既是负担,也是自由。
不过目前就只是个最简单的下载器,如果进一步扩展的话,可以添加下载任务的取消与状态管理,或者支持断点续传的下载实现等等。
也欢迎大家一起讨论关于安卓中的下载。