60、文件下载与断点续传:DownloadManager、OkHttp断点下载、多线程下载

文件下载,几乎是每个App都绕不开的功能。但说实话,很多开发者在做下载功能时,都只做了个“一次性下载”——网络断了?重新下。App被杀?重新下。用户要是下载一个几百兆的文件,遇到两次断网,估计就想卸载你的App了。

今天我们就来聊聊,怎么把下载功能做得“靠谱”。我会从最简单的系统DownloadManager讲起,再到OkHttp手动实现断点续传,最后聊聊多线程下载的玩法。嗯,这些都是我在实际项目中踩过坑、填过坑的经验。

核心知识点:
  • DownloadManager:系统级下载,适合简单场景
  • OkHttp断点续传:手动控制,灵活度高
  • 多线程下载:分片下载,提速明显
文件下载与断点续传知识体系 DownloadManager 系统级下载服务 自动处理网络切换 支持通知栏显示 适合简单文件下载 缺点:控制力弱 OkHttp断点续传 HTTP Range头 206 Partial Content 本地记录已下载位置 支持暂停/恢复 优点:灵活可控 多线程下载 文件分片下载 每个线程负责一片 合并写入本地文件 RandomAccessFile 优点:速度提升明显 选择建议:简单场景用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找不到,排查了半天才发现是目标路径没有权限。

注意:DownloadManager的下载进度监听需要通过ContentObserver或者查询Cursor来实现,不能直接拿到回调。如果你需要实时更新UI进度条,建议用OkHttp方案。

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();
}
小技巧:建议用SharedPreferences或者Room数据库记录每个下载任务的已下载字节数。这样即使App被杀死,下次启动也能恢复下载进度。我一般会在数据库里存一个download_progress表,字段包括:url、filePath、downloadedBytes、totalBytes、status。

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);
        }
    }
}
重要提醒:多线程下载时,所有线程必须使用同一个RandomAccessFile实例,并且每个线程seek到自己的起始位置。如果每个线程各自打开文件,会导致文件写入错乱。另外,记得在下载完成后检查所有分片是否都成功了,如果有失败的需要重试。

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