60、文件下载与断点续传:DownloadManager、OkHttp断点下载、多线程下载
文件下载,几乎是每个App都绕不开的功能。但说实话,很多开发者在做下载功能时,都只做了个“一次性下载”——网络断了?重新下。App被杀?重新下。用户要是下载一个几百兆的文件,遇到两次断网,估计就想卸载你的App了。
今天我们就来聊聊,怎么把下载功能做得“靠谱”。我会从最简单的系统DownloadManager讲起,再到OkHttp手动实现断点续传,最后聊聊多线程下载的玩法。嗯,这些都是我在实际项目中踩过坑、填过坑的经验。
- DownloadManager:系统级下载,适合简单场景
- OkHttp断点续传:手动控制,灵活度高
- 多线程下载:分片下载,提速明显
一、DownloadManager:系统级下载方案
先说说最简单的方案——DownloadManager。这是Android系统自带的下载管理器,说白了就是Google帮你封装好了的一套下载服务。你只需要告诉它“下载这个URL,存到那个路径”,剩下的它全包了。
我个人习惯在下载一些不太重要的资源文件时用它,比如App内的广告素材、预置的离线包。为什么?因为它省事啊,连网络切换、通知栏显示都帮你处理好了。
1.1 基本用法
// 创建下载请求
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
request.setTitle("正在下载更新包");
request.setDescription("下载完成后会自动安装");
request.setNotificationVisibility(
DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
request.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS, "app_update.apk");
// 获取系统服务并执行下载
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
long downloadId = manager.enqueue(request);
你看,就这么几行代码,下载就开始了。但这里有个坑——DownloadManager在Android 10以后对文件路径的限制变严了。我曾经在一个项目里,用户反馈下载的APK找不到,排查了半天才发现是目标路径没有权限。
1.2 查询下载进度
// 查询下载状态
DownloadManager.Query query = new DownloadManager.Query();
query.setFilterById(downloadId);
Cursor cursor = manager.query(query);
if (cursor.moveToFirst()) {
int bytesDownloaded = cursor.getInt(
cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
int totalBytes = cursor.getInt(
cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
int status = cursor.getInt(
cursor.getColumnIndex(DownloadManager.COLUMN_STATUS));
// 根据status判断下载状态
switch (status) {
case DownloadManager.STATUS_SUCCESSFUL:
// 下载完成
break;
case DownloadManager.STATUS_FAILED:
// 下载失败,获取失败原因
int reason = cursor.getInt(
cursor.getColumnIndex(DownloadManager.COLUMN_REASON));
break;
}
}
cursor.close();
嗯,这里要提醒一下:Cursor用完后一定要close,不然会内存泄漏。我见过不少新手在这个地方翻车。
二、OkHttp断点续传:手动控制更灵活
如果你需要更精细的控制——比如暂停下载、恢复下载、显示实时进度——那DownloadManager就不够用了。这时候我建议你用OkHttp自己实现断点续传。
断点续传的原理其实很简单:服务器支持HTTP的Range头,客户端告诉服务器“我已经下载了1024字节,你从1025开始发给我”。服务器返回206 Partial Content状态码,只发送剩余部分。
2.1 核心实现
// 检查服务器是否支持断点续传
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
// 先发一个HEAD请求,获取文件总大小和是否支持Range
Request headRequest = new Request.Builder()
.url(downloadUrl)
.head()
.build();
Response headResponse = client.newCall(headRequest).execute();
long totalSize = Long.parseLong(
headResponse.header("Content-Length", "0"));
boolean acceptRanges = "bytes".equals(
headResponse.header("Accept-Ranges", ""));
headResponse.close();
if (!acceptRanges) {
// 服务器不支持断点续传,只能全量下载
// 我在项目中遇到过一些老旧的CDN不支持Range,只能降级处理
downloadFullFile();
return;
}
// 读取本地已下载的大小
long downloadedSize = getLocalFileSize(savePath);
// 发起断点续传请求
Request rangeRequest = new Request.Builder()
.url(downloadUrl)
.header("Range", "bytes=" + downloadedSize + "-")
.build();
Response response = client.newCall(rangeRequest).execute();
if (response.code() == 206) { // 206 Partial Content
InputStream is = response.body().byteStream();
RandomAccessFile raf = new RandomAccessFile(savePath, "rw");
raf.seek(downloadedSize); // 跳到已下载的位置继续写
byte[] buffer = new byte[8192];
int len;
long totalRead = downloadedSize;
while ((len = is.read(buffer)) != -1) {
raf.write(buffer, 0, len);
totalRead += len;
// 回调进度
if (progressListener != null) {
progressListener.onProgress(totalRead, totalSize);
}
}
raf.close();
is.close();
}
2.2 暂停与恢复
暂停下载其实就是中断网络请求,保存当前已下载的位置。恢复时再发起新的Range请求。这里有个细节:OkHttp的Call对象可以调用cancel()来取消请求,但要注意取消后不能复用,需要重新创建。
// 暂停下载
private Call currentCall;
public void pauseDownload() {
if (currentCall != null && !currentCall.isCanceled()) {
currentCall.cancel();
// 保存当前已下载的字节数到数据库
saveProgress(currentDownloadedBytes);
}
}
// 恢复下载
public void resumeDownload() {
long savedProgress = loadProgress();
if (savedProgress > 0) {
// 从断点处继续下载
startRangeDownload(savedProgress);
} else {
// 从头开始
startFullDownload();
}
}
三、多线程下载:分片加速
如果你下载的是大文件(比如几百MB的游戏资源包),单线程下载速度可能不够理想。这时候可以考虑多线程下载——把文件分成N片,每个线程下载一片,最后合并。
你想想看,一个100MB的文件,分成4片,每片25MB,4个线程同时下载,理论上速度能提升4倍。当然,实际受限于网络带宽和服务器限制,可能达不到理论值,但提升还是很明显的。
3.1 分片策略
| 线程数 | 适用场景 | 注意事项 |
|---|---|---|
| 2-3个 | 普通文件(10-50MB) | 线程开销小,适合移动网络 |
| 4-6个 | 大文件(50-200MB) | 需要控制并发数,避免OOM |
| 8个以上 | 超大文件(200MB+) | 建议在WiFi下使用,注意CPU占用 |
我个人习惯:移动网络下用3个线程,WiFi下用5个线程。为什么?因为移动网络带宽有限,线程太多反而会因为竞争导致速度下降。
3.2 多线程下载实现
// 计算每个线程的下载范围
int threadCount = 3;
long partSize = totalSize / threadCount;
for (int i = 0; i < threadCount; i++) {
long start = i * partSize;
long end = (i == threadCount - 1) ? totalSize - 1 : (i + 1) * partSize - 1;
// 启动线程下载该分片
new DownloadThread(downloadUrl, savePath, start, end, i).start();
}
// DownloadThread内部实现
class DownloadThread extends Thread {
private long start, end;
private int threadId;
@Override
public void run() {
Request request = new Request.Builder()
.url(url)
.header("Range", "bytes=" + start + "-" + end)
.build();
try {
Response response = client.newCall(request).execute();
InputStream is = response.body().byteStream();
RandomAccessFile raf = new RandomAccessFile(savePath, "rw");
raf.seek(start);
byte[] buffer = new byte[4096];
int len;
while ((len = is.read(buffer)) != -1) {
raf.write(buffer, 0, len);
}
raf.close();
is.close();
} catch (IOException e) {
// 记录失败的分片,后续重试
saveFailedPart(threadId, start, end);
}
}
}
3.3 合并与校验
多线程下载完成后,其实不需要手动合并——因为每个线程都通过RandomAccessFile写到了文件的正确位置,文件本身就是完整的。但建议做一次MD5校验,确保文件没有被损坏。
// 计算文件的MD5
public static String calculateMD5(File file) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[8192];
int len;
while ((len = fis.read(buffer)) != -1) {
md.update(buffer, 0, len);
}
fis.close();
StringBuilder sb = new StringBuilder();
for (byte b : md.digest()) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
// 与服务器返回的MD5对比
String serverMD5 = getServerFileMD5();
String localMD5 = calculateMD5(new File(savePath));
if (serverMD5.equals(localMD5)) {
// 文件完整,下载成功
} else {
// 文件损坏,重新下载
// 我曾经遇到过CDN节点返回了错误数据,MD5校验救了我一命
}
四、避坑指南
做下载功能这么多年,我踩过的坑还真不少。这里列几个最常见的,希望能帮你少走弯路:
- 服务器不支持Range:有些老旧的服务器或者CDN不支持断点续传,返回200而不是206。这时候要降级为全量下载,不要死磕。
- 文件被占用:下载过程中如果用户打开了文件(比如图片查看器),会导致写入失败。建议下载时使用临时文件名,下载完成后再重命名。
- 存储空间不足:下载前先检查剩余空间,不够的话提前提示用户。不然下载到一半报错,用户体验很差。
- 网络切换:从WiFi切到移动网络时,如果文件很大,建议暂停下载并询问用户是否继续。我曾经因为这个被用户骂过。
- 简单场景用DownloadManager,省心省力
- 需要暂停恢复用OkHttp+Range头,灵活可控
- 大文件加速用多线程分片下载,记得做MD5校验
- 无论哪种方案,都要处理好异常情况和用户交互
好了,关于文件下载与断点续传的内容就聊到这里。这些方案我都实际用过,也踩过不少坑,希望你能从中找到适合自己项目的方案。记住,下载功能看似简单,但要做好、做稳定,还是需要花些心思的。
公众号:蓝海资料掘金营,微信deep3321