做 Android 开发这几年,我发现自己几乎在每个项目里都要写一遍“文件下载”。App 内升级、离线资源包、图片视频缓存,甚至是一些词库、配置文件的预加载,本质都绕不开同一件事:把网络上的文件稳定地存到本地。用的网络库倒是一直很统一——OkHttp,毕竟它生态成熟、性能稳定,团队里接手的人也都能看懂。
这篇文章我就把基于 OkHttp 做文件下载的完整思路、关键代码和踩坑过程整理出来。我会从方案选型讲起,再逐步拆解下载模块该怎么设计、断点续传怎么做、进度回调如何不卡 UI,最后把实际开发中遇到的问题列成清单。无论你是刚接触 Android 网络开发,还是正想封装一个通用下载组件,都可以直接参考这里面的做法。
1. 为什么选 OkHttp:下载方案选型与设计定位
1.1 先盘一遍市面上的下载方案
在很多新手看来,Android 里做文件下载最简单的方式是直接用 DownloadManager,毕竟它是系统自带的,写几行代码就能调起系统下载服务。但真的拿它做产品级下载,往往会被几个问题卡住。
DownloadManager 的下载进度只能通过 ContentObserver 去查询数据库,更新频率不可控,而且它在部分国产 ROM 上表现不稳定,联网、存储权限、通知栏显示都有可能被系统限制。另外它无法让我们自定义请求头,服务端做了鉴权、签名这类逻辑时就很难处理。对于只想“把文件存下来”的极简场景,它可以胜任,但一旦涉及业务定制,用起来就非常别扭。
HttpURLConnection 是 JDK 自带的方案,API 足够底层,搞清楚之后几乎什么都能做。代价就是连接管理、超时重试、重定向、线程调度这些细节全都要自己处理。比如下载一个文件连接突然断了,你要自己判断是否重试;服务端返回 301/302,你还要手动跟进 Location 头。写个 Demo 没问题,写进生产项目里,代码量和出 Bug 概率都会明显上升。
还有一个经常被提到的 Volley。它本身更适合 JSON 接口、图片列表这类高频小数据请求,内部默认的缓存机制主要针对可序列化的小响应体。拿它下载上百 MB 的安装包,第一关就是内存峰值问题,很容易把 App 打崩。虽然理论上可以写自定义请求来拿流,但这种用法明显背离了它的设计初衷。
反观 OkHttp,它内置了连接池复用、请求重试、重定向跟随、HTTP/2 多路复用、拦截器链等一整套机制。对文件下载来说,我们最关心的“流式响应”,在 OkHttp 里也支持得非常好。它不会把整个响应体一次性读进内存,而是通过 okio 的流按需读取,这就为下载大文件提供了基矗所以在我看来,OkHttp 是在灵活性与工程化之间平衡得最好的选择。
为了更直观地对比,我把自己平时做技术选型时用到的判断表格放出来:
| 方案 | 断点续传 | 自定义请求头 | 内存占用控制 | 工程化程度 | 适用场景 |
|---|---|---|---|---|---|
| DownloadManager | 支持但受限 | 不支持 | 低 | 低 | 简单文件保存 |
| HttpURLConnection | 需自实现 | 支持 | 低 | 低 | 学习、极简需求 |
| Volley | 不适合 | 支持 | 高 | 中 | 小数据请求 |
| OkHttp | 易实现 | 支持 | 低 | 高 | 大文件下载、复杂业务 |
1.2 下载模块的职责拆分与架构思路
确定了网络层用 OkHttp 之后,下一步要思考的是:一个完整的下载模块到底需要承担哪些职责?
很多人第一次写下载功能时,习惯把所有逻辑塞进一个类里——Activity 里直接 new OkHttpClient,回调里写文件,文件写完了再更新 UI。这样写小工具没问题,但一旦需求变成“同时下 3 个文件”“WiFi 断了自动暂停”“杀进程后恢复进度”,代码就会迅速失控。
我建议把下载模块拆成至少四层:
第一层是协议层,也就是 OkHttp 的请求封装。这一层只做一件事:把下载地址、请求头、Range 参数转化成 Request,然后执行并拿到 ResponseBody 流。它不应该关心文件存在哪,也不需要关心 UI 怎么显示。
第二层是任务层。每一个下载动作对应一个任务对象,里面保存文件的 URL、本地保存路径、总大小、已下载大小、当前状态(等待中、下载中、暂停、完成、失败)。任务层负责调度协议层去执行真正的网络请求,同时把进度和状态变化抛给上层。
第三层是存储层。这里要处理文件写入、临时文件管理、已下载字节数的持久化。断点续传能不能跨进程生效,取决于这一层的设计是否可靠。
第四层是表现层,也就是 Activity、Fragment、Notification 里的进度条。它接收任务层的状态回调,刷新 UI。
这样分层的好处很明显:协议层可以被单元测试覆盖,任务层可以独立做多任务管理,存储层可以替换成基于 Room 的实现,表现层则可以根据业务需要自由变化。后面我在项目里扩展“下载完成后自动安装 App”和“仅 WiFi 下自动下载”这两个功能时,几乎没有改动协议层代码,只调整了任务层和表现层,省下了不少时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前必须搞懂的核心细节
2.1 依赖、权限与 Android 版本适配
先说一下工程的准备工作。我用的是 OkHttp 4.x,内部已经是 Kotlin 实现,API 和 3.x 基本兼容,但方法签名上有些小变化。当前稳定版本是 4.12.0,直接引入即可。OkHttp 底层依赖 OkIO,一般不需要手动声明,Gradle 会把传递依赖拉进来。
在 build.gradle.kts 中这样配置:
kotlin复制dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
如果你用的是老版本 Android Studio 和 Groovy DSL,写法区别也不大:
gradle复制implementation 'com.squareup.okhttp3:okhttp:4.12.0'
权限方面,下载文件需要网络权限,这是写在 AndroidManifest.xml 里的:
xml复制<uses-permission android:name="android.permission.INTERNET" />
如果要把文件保存到公共目录,比如 Download 目录,Android 6.0 及以上需要动态申请存储权限,Android 10 及以上又引入了分区存储,建议直接使用 MediaStore.Downloads 或 getExternalFilesDir()。不想在权限上浪费时间的做法是:把下载的文件放到 App 专属外部目录,也就是 context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),这样不需要任何存储权限。缺点是一旦 App 卸载,文件也会跟着删除。如果是更新包这类用完即弃的文件,完全没有问题。
目标版本是 Android 7.0 及以上时,还有一个很容易踩的坑:直接用 file:// Uri 把 APK 安装包传给系统安装器会触发 FileUriExposedException。必须要配合 FileProvider 生成 content:// 形式的 Uri。热搜里经常被提到的 content://com.tencent.wework.fileprovider/external_path/android/data/com... 这类路径,本质上就是各家 App 通过 FileProvider 映射出来的安装包或者文档分享路径。我们自己实现下载功能时也要重视这一层适配,否则在应用内升级功能上线后会在 7.0 以上的机型上大面积崩溃。
2.2 OkHttp 下载文件的核心机制
接下来聊一聊 OkHttp 下载文件的底层机制。很多人第一版写的下载代码是这样:
kotlin复制val response = client.newCall(request).execute()
val content = response.body?.string()
saveToFile(content)
这段代码有两个明显问题。第一,string() 会把整个响应体读进内存,如果文件有 500MB,不出意外会直接 OutOfMemoryError。第二,即使文件不大,string() 返回的是字符串,如果内容是二进制文件,中间遇到编码转换也有风险。正确的做法应该是拿字节流,循环写入文件。
kotlin复制val inputStream = response.body?.byteStream()
val fileOutputStream = FileOutputStream(file)
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytesRead = inputStream.read(buffer)
while (bytesRead != -1) {
fileOutputStream.write(buffer, 0, bytesRead)
bytesRead = inputStream.read(buffer)
}
fileOutputStream.flush()
这个循环里涉及到一个容易忽略的参数:缓冲区大小 DEFAULT_BUFFER_SIZE。它决定了每次从网络流读取多少字节再写入磁盘。设置太小,IO 次数太多,CPU 消耗大;设置太大,单次占用内存大,在低端机上容易卡顿。常驻 8KB、16KB、64KB 我都测试过,权衡下来 16KB 到 64KB 之间比较合适。对大多数业务场景,直接用 32KB 是一个不容易出错的选择。
再往下说,进度计算其实很简单,就是从响应体的 contentLength() 拿到总大小,再用每次循环累加的字节数作为当前进度:
kotlin复制val totalBytes = response.body?.contentLength() ?: -1L
var downloadedBytes = 0L
while (bytesRead != -1) {
fileOutputStream.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead
if (totalBytes > 0) {
listener.onProgress(downloadedBytes, totalBytes)
}
}
但这里有一个细节很容易被忽略,就是进度回调的频率。如果每次循环都回调一次,在网速很快的时候,每秒可能回调几百次甚至上千次。假设 UI 层直接刷新 ProgressBar,就会大量占用主线程,导致界面卡顿。我通常的做法是加一个“时间窗口节流”,也就是两次回调之间至少间隔 100ms。这样进度条看起来是平滑跳动的,主线程的压力也会小很多。
断点续传则主要由 HTTP 协议里的 Range 机制支撑。客户端在请求头里带上 Range: bytes=已下载字节数-,服务端如果支持,会返回 206 Partial Content,响应体里只有从指定位置开始的那部分数据。我们把文件设置成“追加写入”模式,就能接着之前的内容继续写。
kotlin复制val request = Request.Builder()
.url(url)
.header("Range", "bytes=$downloadedBytes-")
.build()
需要特别注意的是,并不是所有服务器都支持 Range。如果服务器返回的是 200 OK 而不是 206,说明它忽略了 Range 头并返回了完整内容。此时我们如果还按“追加写入”的方式续传,文件中间就会有一段重复的数据。正确的做法是判断响应码,如果是 200,说明服务端不支持续传,就从头开始下载,把文件清空后重新写。
理解这一点之后,再看很多网上的断点续传代码,你就会发现他们忽略了一个“不支持 Range”的情况。我在生产环境里也确实遇到过,一些 CDN 节点配置不当,导致部分用户下载异常。所以断点续传不能只实现理想路径,必须有兼容策略。
2.3 回调线程与 UI 更新
OkHttp 的 enqueue 回调跑在 OkHttp 的内部线程池里,execute 如果是直接在子线程调用,也不存在主线程问题。但从回调里更新 UI,必须切回主线程。常用的做法有几种:
- 使用
runOnUiThread,适合简单场景; - 使用
Handler,配合Looper.getMainLooper()创建一个主线程 Handler; - 使用协程
withContext(Dispatchers.Main),适合 Kotlin 项目。
我推荐在封装下载器时,把“线程调度”统一放在表现层。也就是说,下载器回调里明确标注“回调发生在 OkHttp 线程”,UI 层自己决定怎么切线程。这样做的好处是下载器不依赖任何 UI 组件,在后台任务或者单元测试环境也都能跑。
生命周期问题同样值得重视。如果在 Activity 里发起下载,页面销毁后回调仍在执行,就会触发内存泄漏,甚至出现空指针。规避方案是配合 Lifecycle 或者协程的 LifecycleScope。当页面进入 DESTROYED 状态时,自动取消下载任务。注意,取消 OkHttp 请求会抛出 IOException,回调里需要专门处理这种“非真实失败”的情况,避免提示用户“网络错误”。
3. 完整实操:使用 OkHttp 手写一个下载模块
3.1 准备工程与依赖
这一节我给出一个可以完整跑起来的示例。假设我们新建了一个 Kotlin 工程,minSdk 设置为 21,compileSdk 设置为 34。这一版示例的目标是:下载一个文件到 App 专属目录,展示进度,支持暂停和继续,并且在下载完成后通过 FileProvider 给其他 App 读取。
首先在 build.gradle.kts 里添加 OkHttp 依赖:
kotlin复制plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.okhttpdownload"
compileSdk = 34
defaultConfig {
applicationId = "com.example.okhttpdownload"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
}
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
}
Manifest 里配置权限和 FileProvider:
xml复制<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:theme="@style/Theme.AppCompat">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
res/xml/file_paths.xml 中配置可访问路径:
xml复制<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path
name="external_files"
path="." />
</paths>
3.2 核心代码:下载回调接口与下载器实现
我们先定义一个下载进度回调接口,尽可能精简,方便不同 UI 复用:
kotlin复制interface DownloadCallback {
fun onStart(totalBytes: Long)
fun onProgress(downloadedBytes: Long, totalBytes: Long)
fun onPause()
fun onComplete(file: File)
fun onFailed(e: Exception)
}
接着是核心下载器。这里我用一个 DownloadManager 类来统一管理。
kotlin复制class DownloadManager(private val context: Context) {
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
private val tasks = ConcurrentHashMap<String, DownloadTask>()
fun download(url: String, fileName: String, callback: DownloadCallback): String {
val taskId = UUID.randomUUID().toString()
val task = DownloadTask(
id = taskId,
url = url,
fileName = fileName,
state = DownloadState.DOWNLOADING
)
tasks[taskId] = task
startDownload(task, callback)
return taskId
}
private fun startDownload(task: DownloadTask, callback: DownloadCallback) {
Thread {
var downloadedBytes = task.downloadedBytes
val saveFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), task.fileName)
val requestBuilder = Request.Builder()
.url(task.url)
.header("User-Agent", "Mozilla/5.0 (Android) OkHttp-Download")
if (downloadedBytes > 0) {
requestBuilder.header("Range", "bytes=$downloadedBytes-")
}
val request = requestBuilder.build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
callback.onFailed(IOException("Unexpected code $response"))
return@Thread
}
val totalBytes = response.body?.contentLength() ?: -1L
val responseCode = response.code
// 服务端不支持断点续传时,从头开始写
if (responseCode == HTTP_OK) {
downloadedBytes = 0
if (saveFile.exists()) {
saveFile.delete()
}
}
if (downloadedBytes == 0L) {
task.totalBytes = totalBytes
callback.onStart(totalBytes)
}
val inputStream = response.body?.byteStream()
val outputStream = FileOutputStream(saveFile, downloadedBytes > 0)
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) // 32 * 1024
var bytesRead = inputStream?.read(buffer) ?: -1
var lastNotifyTime = 0L
while (bytesRead != -1) {
outputStream.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead
task.downloadedBytes = downloadedBytes
val now = System.currentTimeMillis()
if (now - lastNotifyTime >= 100) {
lastNotifyTime = now
callback.onProgress(downloadedBytes, totalBytes)
}
if (task.state == DownloadState.PAUSED) {
callback.onPause()
break
}
bytesRead = inputStream?.read(buffer) ?: -1
}
outputStream.flush()
outputStream.close()
inputStream?.close()
if (task.state != DownloadState.PAUSED) {
task.state = DownloadState.COMPLETED
callback.onComplete(saveFile)
}
}
} catch (e: Exception) {
callback.onFailed(e)
}
}.start()
}
fun pause(taskId: String) {
tasks[taskId]?.state = DownloadState.PAUSED
}
fun resume(taskId: String, callback: DownloadCallback) {
val task = tasks[taskId] ?: return
task.state = DownloadState.DOWNLOADING
startDownload(task, callback)
}
companion object {
private const val DEFAULT_BUFFER_SIZE = 32 * 1024
private const val HTTP_OK = 200
}
}
代码并不长,但有几个点值得展开说。
第一,我在下载线程里用了 Thread { } 而不是协程。原因是这个示例要尽量保持纯 OkHttp 语义,方便任何人直接阅读。在实际项目中,我更推荐把 Thread 替换成协程,用 withContext(Dispatchers.IO) {} 包裹整个下载逻辑,配合 suspendCancellableCoroutine 实现真正的协程取消,体验会好很多。
第二,tasks 用 ConcurrentHashMap 保存任务对象,解决多任务并发时 download 方法被多次调用的问题。每次下载都生成一个 taskId 返回给外部,外部通过它来暂停或者继续。注意暂停操作并不能立刻中断网络请求,只能等下一步循环写入时判断状态。如果你真的需要立刻断开网络,应该调用 call.cancel(),这会在读取流的位置抛出异常。两种做法的差别在于:用 call.cancel() 会中断 TCP 连接,服务端可能记录一个不完整的请求;用状态标记则会更“温和”,文件句柄和连接都能正常关闭。
第三,总大小 totalBytes 是通过 contentLength() 拿到的。但它并不总是可信的。当服务器返回 Transfer-Encoding: chunked 时,响应头里没有 Content-Length,contentLength() 会返回 -1。这就意味着进度条显示不了总大小,通常的表现是“一直在跑但不知道还要多久”。针对这种情况,我的兜底方案是:如果总大小未知,进度条就使用“不精确模式”,也就是只转圈不显示百分比。
第四,response.use {} 块确保了 Response 正常关闭,这是防止连接泄漏的关键。很多人写完下载代码后,发现 App 运行一段时间后网络变慢甚至无法请求,多数情况下就是某个地方忘了关闭 Response 或者 Body 流。
3.3 多任务与断点续传的落地
上面的代码里已经有了一个简化的 DownloadTask:
kotlin复制enum class DownloadState {
WAITING,
DOWNLOADING,
PAUSED,
COMPLETED,
FAILED
}
data class DownloadTask(
val id: String,
val url: String,
val fileName: String,
var state: DownloadState = DownloadState.WAITING,
var downloadedBytes: Long = 0L,
var totalBytes: Long = 0L
)
这个数据结构可以进一步扩展。比如增加 filePath、mimeType、createTime、finishTime 等字段。如果想让下载任务在 App 进程被杀后仍然能恢复,就需要把 DownloadTask 持久化到磁盘。最简单的方案是用 SharedPreferences 存 JSON,复杂一点就用 Room。我个人建议从第一天就把任务表设计成数据库表,因为下载任务天然有“创建、增补、查询、删除”这四种操作,用数据库承载最合理。
持久化的另一个价值在于计算“已下载字节数”。比如一个 APK 已经下载了 300MB,还剩 200MB,这时候进程被杀,用户重新打开 App。如果不持久化,就只能从 0 开始,这显然很伤害体验。如果持久化了,恢复任务时读取 downloadedBytes,然后放到 Range: bytes=300000000- 请求头里,续传直接生效。
我项目中有一个升级模块,就专门建了三张表:download_task 存元数据,download_chunk 存分片状态,download_log 存每次失败的错误信息。后续扩展多线程分片下载时,这些表结构可以直接用上。
3.4 UI 进度展示的接入
下载器写完之后,UI 层接入会比较直接。这里以 ViewModel + ProgressBar 为例,简单展示一下。
kotlin复制class DownloadViewModel : ViewModel() {
private val downloadManager = DownloadManager(application)
private val _progress = MutableLiveData<Int>()
val progress: LiveData<Int> = _progress
fun startDownload(url: String, fileName: String) {
downloadManager.download(url, fileName, object : DownloadCallback {
override fun onStart(totalBytes: Long) {
_progress.postValue(0)
}
override fun onProgress(downloadedBytes: Long, totalBytes: Long) {
if (totalBytes > 0) {
val percent = (downloadedBytes * 100 / totalBytes).toInt()
_progress.postValue(percent)
}
}
override fun onPause() {
// 更新按钮状态
}
override fun onComplete(file: File) {
// 跳转安装或打开
}
override fun onFailed(e: Exception) {
// Toast 提示
}
})
}
}
在 Activity 里观察这个 LiveData:
kotlin复制viewModel.progress.observe(this) { percent ->
progressBar.progress = percent
progressText.text = "$percent%"
}
如果你需要通知栏进度,逻辑也类似。在 onProgress 里更新 NotificationManager 的进度即可。需要注意的是通知栏进度必须用 NotificationCompat.Builder.setProgress(max, progress, false) 来设置,并且要定期 notify(),否则通知栏不会刷新。
下载完成后的“打开文件”操作,Android 7.0 以上必须使用 FileProvider 生成 Uri。示例代码如下:
kotlin复制val fileUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(fileUri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent)
这里有个小细节容易被忽略:FileProvider.getUriForFile 映射的路径必须在 XML 配置过。如果文件存放在 getExternalFilesDir 的子目录里,路径配置要写清楚,不能简单用 . 表示整个外部存储,否则在一些 ROM 上会得到误导性的“Unable to find configured root”错误。
4. 实际问题排查与经验总结
4.1 常见问题速查表
做下载器这么久,遇到的问题是五花八门的。我整理了一个速查表,排在前面的是出现频率最高、最容易踩坑的问题。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 进度条总是显示 0% | contentLength 返回 -1,或服务端 chunked 传输 | 改用不精确进度模式,或服务端配置 Content-Length |
| 下载到一半断网,重试后文件损坏 | 断点续传时没有判断 HTTP 206 | 判断响应码,非 206 时删除旧文件重新下载 |
| Android 7.0 以上打开 APK 崩溃 | 使用了 file:// Uri | 改用 FileProvider 生成 content:// Uri |
| 下载完成的文件大小为 0 | 写入文件时忘记 flush/close,或流提前关闭 | 确保 OutputStream 在循环结束后 flush 再 close |
| 下载大文件时 OOM | 用了 string() 而不是 byteStream() | 改用流式读取,设置合理缓冲区 |
| 下载完成后 MD5 与服务端不一致 | 服务器返回了 gzip 压缩内容,下载的是压缩数据 | 请求头禁止 Accept-Encoding: gzip,或下载前解压 |
| 页面销毁后回调更新 UI 崩溃 | 回调没有切主线程或没有取消任务 | 使用 LifecycleScope 在销毁时取消回调 |
| 部分机型下载速度很慢 | OkHttp 连接未复用,每次下载新建连接 | 使用同一个 OkHttpClient 实例,避免频繁 new |
这个表格里值得多说两句的是 gzip 那个问题。有些服务端不管客户端是什么请求,只要是文件资源,就用 gzip 压缩后返回。OkHttp 在发送请求时默认会带上 Accept-Encoding: gzip,如果服务器返回了 gzip 压缩内容,Content-Length 对应的是压缩后的大小。我们如果直接把这个压缩后的字节流写入以“zip”“apk”命名的文件,打开时必然会失败。排查这种问题的思路是抓包看响应头,如果出现 Content-Encoding: gzip,说明下载到的是压缩数据,需要在请求头里去掉 Accept-Encoding: gzip,或者对下载流做一次 GZIPInputStream 解压。
4.2 大文件下载与稳定性
下载小文件和大文件的关注点完全不同。小文件几秒钟就下完了,基本上不用考虑断点续传;但超过 100MB 的文件,稳定性就成了核心问题。
首先是存储空间判断。下载前应该检查目标目录的可用空间是否大于文件大小。可以用 StatFs 查询:
kotlin复制val stat = StatFs(Environment.getExternalStorageDirectory().path)
val availableBytes = stat.availableBytes
if (availableBytes < totalBytes) {
// 提示用户存储空间不足
}
不判断空间造成的后果很尴尬:下载到一半磁盘满了,文件写入抛出 IOException,用户看到的提示是“网络错误”,实际上跟网络一点关系都没有。
其次是网络切换的处理。当 WiFi 切换到移动网络,或者网络短暂断开,OkHttp 默认的重试机制可能会重新执行请求。但对于大文件来说,最好在网络状态变化时主动暂停任务,让用户确认后再继续。这个可以通过注册 ConnectivityManager.NetworkCallback 监听实现。
还有就是文件完整性校验。大文件传输过程中哪怕只有一个 bit 出错,最终文件也可能完全不可用。服务端一般会在响应头或额外接口中提供 MD5 或 SHA-256 值。下载完成后,我们应该在后台线程计算文件的哈希值,和服务端值比对,不一致就删除文件并重新下载。虽然这会让“下载完成”的时机变晚,但它能防止脏数据进入业务链路。
4.3 我踩过的坑与心得
最后分享几个我在实际项目中踩过的坑,有些是代码层面的,有些是设计层面的。
第一个坑是“响应体没有及时关闭”。早期版本我在 onResponse 里读完了 body.string() 就直接返回,没有手动关闭 Response。后来线上反馈下载多个大文件时出现连接泄漏,排查半天,最后用 strictmode 才定位到是 Response 没关。OkHttp 文档里写得清楚,Response 必须被关闭,否则连接不会回到连接池。现在我的代码里一律用 response.use {} 或者 try-with-resources 的写法,从根源上杜绝这个问题。
第二个坑是“暂停后再次续传写重复数据”。之前我把 downloadedBytes > 0 作为“是否追加写入”的唯一条件,但没判断服务端是否返回了 206。有一次测试服务器突然把 Range 头忽略了,返回 200,我就直接把 300MB 的新内容追加到了已有 300MB 的文件后面,导致文件变成 600MB,而且内容完全错乱。从那以后,我所有的续传逻辑都先判断响应码,代码里也特意加了注释:responseCode != 206 时从头开始。
第三个坑是关于“下载器的通用化设计”。一开始我为了快速上线,把下载代码直接写在 Fragment 里。后来其他业务也要用下载功能,我只得把它从 Fragment 里抠出来重写。这件事让我意识到,下载器从第一天起就应该设计成独立的工具类,不依赖任何页面。现在我的做法是把下载器放进单独的 core-download Module 里,对外暴露极少的方法:start、pause、resume、cancel,上层想怎么包装都不会影响底层。
另外我还想讲一个体会:进度回调的线程模型一定要在接口注释里写清楚。我们的下载回调有时候在 OkHttp 线程,有时候在协程 Dispatcher,上层如果不知道这一点,会莫名其妙地出现“第一次能更新 UI,第二次不能”的诡异 Bug。现在我的接口注释都会写明“回调线程为后台线程,请自行切换主线程”,团队里的人接手时也少了很多困惑。
最后再补充一点和热点需求相关的思考。很多人在网上搜“Android 文件下载”时会看到各种封装库,比如 FileDownloader、PRDownloader、okdownload 等。这些库功能很全,但用起来也有学习成本。如果你只是需要下载一两个固定的文件,自己基于 OkHttp 写一个 200 行的下载器完全够用;如果你要做大型离线包分发、多线程加速下载,那确实可以考虑成熟的第三方库。但无论如何,理解 OkHttp 的流式响应、Range 续传、线程切换这三大基础,是每个 Android 开发者都应该掌握的底层能力。写一遍,对你后面排查复杂下载问题会有很大帮助。
