DownloadManager 的大文件下载,断点续传靠谱吗

DownloadManager 的大文件下载,断点续传靠谱吗

DownloadManager 的大文件下载,断点续传靠谱吗


「DownloadManager 的大文件下载,断点续传靠谱吗」


去年维护一个视频缓存项目时,我接到了一个看起来挺简单的需求:后台下载 500MB 以上的视频文件,支持断点续传,网络切换后能自动恢复。产品经理的原话是"系统不是有 DownloadManager 吗,直接用就行"。我当时也是这么想的,毕竟 Android 从 API 9 就引入了这个组件,十几年过去了,大文件下载应该早就成熟了吧。结果实际踩坑的过程,让我对"系统级成熟方案"这个词有了新的理解。


先搞清楚断点续传是怎么被"支持"的


DownloadManager 的断点续传,不是你在代码里显式调用的某个 API,而是它内部基于 HTTP Range 请求头的一种隐式行为。当你通过 DownloadManager.Request 发起一个下载,系统服务 DownloadManagerService 会维护这个任务的状态。如果下载过程中网络中断,或者进程被杀死,下次系统触发恢复逻辑时,它会检查已下载文件的临时文件(通常以 .tmp 或特定后缀存在),然后构造带有 Range: bytes=已下载长度- 的请求头继续下载。


这个机制听起来合理,但问题出在"隐式"两个字上。我翻了一下 AOSP 源码,关键逻辑在 DownloadThread.java 里(不同版本文件名可能有差异,Android 10 之后重构为 DownloadJob.java 配合 JobScheduler)。核心判断大概长这样:


if (resuming) {
    addRequestHeader("Range", "bytes=" + downloadedBytes + "-");
}

resuming 的判定条件并不简单。它要求:本地临时文件存在、文件大小和数据库记录匹配、服务器之前返回过 Accept-Ranges: bytes、HTTP 响应码是 206 或 200(某些服务器不规范但允许)。任何一个环节出错,它就会从头开始下载。


我在测试中发现一个诡异现象:同一个 Wi-Fi 环境,下载同一个文件,有时候断网重连能续传,有时候直接从头开始。抓包看了才发现,我们 CDN 的某些节点在响应头里写的是 Accept-Ranges: none,而另一些节点正常返回 bytes。DownloadManager 遇到 none 时不会报错,它会静默地丢弃已下载的临时文件,重新发起完整请求。这个行为在应用层完全无感知,你只能通过日志或者抓包才能发现。


临时文件的生命周期是个黑盒


DownloadManager 的下载文件在过程中存储在哪里?这取决于你设置的 setDestinationUri 还是 setDestinationInExternalPublicDir。如果你用了后者,临时文件最终会出现在 Download/ 目录下,但文件名是一串数字 ID,没有扩展名,和普通下载完成的文件混在一起。更麻烦的是,这个临时文件的命名规则在不同 Android 版本里变过。


Android 10 之前,临时文件通常就是目标路径加个 .tmp 后缀,或者存在 /data/data/com.android.providers.downloads/cache/ 下面。Android 10 引入 Scoped Storage 后,DownloadManager 的存储路径被限制在应用专属目录或公共 Download 目录,临时文件的管理逻辑也跟着变了。我在 Pixel 3(Android 12)上测试时,发现断网后等待 5 分钟再恢复,续传成功;但如果我手动杀掉应用进程,再打开,有时候临时文件还在,有时候被清掉了。


后来跟踪源码才明白,DownloadManagerService 有一个 trimDatabase 的逻辑,会清理"过期"的下载记录。默认策略是保留最近 7 天的成功下载,但失败任务的清理更激进。如果你的下载任务在数据库里被标记为 STATUS_FAILED(比如因为网络超时),系统可能在某个时机把临时文件一起删掉。这个"某个时机"没有文档说明,我观察到的是设备重启后、存储空间不足时、或者系统触发 DownloadIdleService 的维护任务时。


这意味着你不能把 DownloadManager 的临时文件当作可靠的持久化状态。如果你需要保证"用户昨天下载到 80%,今天打开还能继续",必须在应用层自己维护下载进度,甚至自己管理分片文件。


206 响应的陷阱:Content-Range 解析的兼容性


假设服务器一切正常,返回了 206 Partial Content,DownloadManager 就能顺利续传吗?也不尽然。HTTP/1.1 RFC 7233 规定 206 响应必须包含 Content-Range 头,格式是 bytes 开始-结束/总大小。但有些服务器的实现很随意,我见过 Content-Range: bytes 1024-2047/unknown(总大小 unknown),也见过 Content-Range: bytes 1024-2047(省略总大小)。


AOSP 的解析代码在 HttpHeaderParser.java 里,Android 8.0 之前对 unknown 的处理是跳过验证,直接按已下载长度加上本次接收的数据来算。Android 8.0 之后加了一个更严格的检查:如果 Content-Range 的 start 值和请求的 Range start 值不一致,会抛出 StopRequestException,导致任务失败。这个改动记录在 AOSP 的 commit 历史里,但没有任何对外文档。


我踩到的具体坑是:某个第三方 CDN 在续传时返回的 Content-Rangebytes 0-1023/1024,但请求的是 Range: bytes=1024-。也就是说,服务器完全忽略了 Range 头,返回了 200 OK 的完整内容,但错误地标记了 206 状态码。DownloadManager 在 Android 7 的设备上接收了这个响应,把 1024 字节覆盖写到了文件开头,导致文件损坏。在 Android 10 设备上,同样的服务器行为触发了 start 值不匹配的检查,任务直接失败。同一个服务器,不同 Android 版本表现完全不同,调试时非常头疼。


大文件下载的内存与线程模型


DownloadManager 的下载不是在你应用进程里跑的,而是在 com.android.providers.downloads 这个系统应用的进程里。这个设计的好处是应用被杀后下载继续,坏处是你对下载过程几乎没有控制力。系统为每个下载任务分配一个线程(Android 10 之前是 DownloadThread,之后是 JobService 的线程池),大文件下载时,数据流从网络 socket 直接写入文件系统,不经过你的应用内存。


但这里有个隐藏问题:缓冲区大小。我抓源码看 DownloadThreadtransferData 方法,发现它用一个 8KB 的 byte 数组做缓冲:


byte[] buffer = new byte[Constants.BUFFER_SIZE]; // 8192

8KB 对于 500MB 的文件来说,意味着 6 万多次系统调用。在低端设备上,这个写入频率可能成为瓶颈。我对比测试过:同样的网络环境,用 DownloadManager 下载 500MB 文件,Redmi 9A 上耗时 12 分钟;用 OkHttp 自定义 256KB 缓冲区下载,耗时 8 分钟。当然这个对比不完全公平,因为 DownloadManager 还要维护数据库、处理通知栏进度等,但 8KB 的缓冲确实偏保守。


更隐蔽的是,DownloadManager 的写入是同步的 FileOutputStream.write(),没有使用 NIO 的 FileChannelMappedByteBuffer。Android 10 之后的 DownloadJob 有所改善,引入了 ParcelFileDescriptorStorageManager 的代理写入,但核心逻辑仍然是阻塞式 IO。对于大文件,这意味着下载线程会长时间占用,如果系统资源紧张,这个线程可能被调度器降低优先级。


网络切换的恢复逻辑:不是你想的那样


产品经理要求的"网络切换后自动恢复",DownloadManager 确实有一定支持,但边界条件很多。它的实现依赖 ConnectivityManager 的网络回调和 JobScheduler 的重试机制(Android 5.0 之后)。


具体流程是:下载过程中如果 NetworkCallback 报告网络断开,当前 DownloadThread 会捕获 IOException,把任务状态更新为 STATUS_WAITING_TO_RETRYSTATUS_WAITING_FOR_NETWORK,然后退出线程。当网络恢复时,DownloadReceiver 收到 CONNECTIVITY_ACTION 广播,或者 JobScheduler 的约束条件满足,会重新触发下载任务。


听起来很完美,实际有几个坑。第一,从 CONNECTIVITY_ACTION 到实际恢复下载,延迟不可控。我在测试中发现,Wi-Fi 切换到 4G 再切回 Wi-Fi,有时候 10 秒内恢复,有时候要等 2 分钟。这个延迟取决于系统广播的触发时机和 JobScheduler 的调度策略,应用层无法干预。


第二,某些国产 ROM 对 CONNECTIVITY_ACTION 做了限制。Android 7.0 之后,这个广播不再发给静态注册的 receiver,但 DownloadManager 作为系统应用有特权。然而华为 EMUI 10、小米 MIUI 12 等 ROM 在电量优化策略里,会延迟或合并系统应用的广播处理。我曾在华为 P30 上测试,关闭 Wi-Fi 后打开飞行模式再关闭,DownloadManager 的任务卡在 STATUS_WAITING_FOR_NETWORK 长达 15 分钟,直到我手动点击了系统下载管理器的通知栏才恢复。


第三,也是最致命的,网络切换时的 IP 变化可能导致 TCP 连接重置,但 HTTP 层面如果服务器不支持 Range 续传,或者临时文件已被清理,恢复就会从头开始。用户看到的是:下载进度突然从 80% 变成 0%,没有提示,没有重试次数显示。


通知栏进度:精确度与性能开销


DownloadManager 强制要求显示通知栏进度(除非你设置 setNotificationVisibility(Request.VISIBILITY_HIDDEN),但这需要 DOWNLOAD_WITHOUT_NOTIFICATION 权限,Google Play 会对这个权限做审核)。对于大文件下载,通知栏的更新频率是个问题。


源码里 DownloadNotifier.java 的更新逻辑,是通过 ContentObserver 监听下载数据库的变化。每次写入缓冲区后( remember,8KB),如果满足一定时间间隔(默认 1.5 秒),就会更新通知。这个 1.5 秒的间隔在 Constants.java 里定义:


public static final long MIN_PROGRESS_TIME = 1500;

但对于大文件,1.5 秒内的写入量可能很大,数据库更新和通知栏刷新成为额外开销。我在 systrace 里看到,下载 500MB 文件期间,NotificationManagerService 的 CPU 占用有显著波动,尤其是在低端设备上。如果你同时有多个下载任务,通知栏的频繁刷新可能导致 ANR 或掉帧。


更实际的问题是,通知栏显示的进度是"已下载字节数/总字节数",但断点续传时,已下载字节数是从数据库读取的,而数据库更新和实际文件写入有微小延迟。如果用户在进度 99% 时杀掉应用,重新打开后可能看到 100% 的进度,但文件实际还没写完,点击播放会报错。这个 race condition 在 DownloadManager 的架构里很难避免,因为数据库和文件系统的原子性没有保证。


实际替代方案:什么时候该放弃 DownloadManager


经过这些踩坑,我对 DownloadManager 的定位有了更清晰的判断。它适合的场景是:小文件(<100MB)、不需要精确进度控制、对断点续传要求不高、用户可接受系统通知栏的下载。对于大文件、需要可靠续传、要自定义 UI、或者有复杂网络策略(比如 Wi-Fi 和蜂窝的不同限速)的场景,自建下载方案更可控。


我最终在这个视频缓存项目里,用 OkHttp + RandomAccessFile + 数据库做了自己的下载引擎。核心思路是分片下载:把 500MB 文件切成 5MB 的块,每块独立维护下载状态。断点续传时,检查哪些块已完成、哪些需要重新下载,并发请求多个 Range。这个方案的代码量比 DownloadManager 的集成大很多,但行为完全可控。


具体实现上,有几个技术点值得提。RandomAccessFileseek() 配合 FileChannel 可以实现多线程并发写入同一文件的不同位置,不需要临时文件合并。OkHttp 的 Interceptor 可以统一注入 Range 头和重试逻辑。数据库用 Room 维护每个分片的 startByteendBytedownloadedBytesstatus,恢复时直接读取。


当然,自建方案也有代价。进程被杀后下载停止,需要借助 WorkManagerJobScheduler 做周期性恢复检查。Android 8.0 的后台执行限制让这件事更复杂,JobScheduler 的最小间隔是 15 分钟,意味着用户可能需要等待才能看到下载恢复。这和 DownloadManager 的系统级特权没法比。


还有一个容易忽略的点:存储配额。DownloadManager 作为系统应用,写入公共 Download 目录不受 Scoped Storage 限制(Android 10 之后通过 MediaStoreStorageManager 的兼容层处理)。自建方案如果也要写到公共目录,需要处理 MediaStoreIS_PENDING 标记、文件重命名、以及 Android 11 的 MANAGE_EXTERNAL_STORAGE 权限申请。这些琐碎的兼容性工作,是 DownloadManager 帮你封装掉的。


回到问题本身:断点续传到底靠谱吗


我的结论是:DownloadManager 的断点续传在"理想条件下"是工作的,但生产环境很少有理想条件。它的可靠性取决于服务器行为的一致性、Android 版本的实现差异、ROM 厂商的定制程度、以及设备存储状态等多重因素。你不能把它当作一个"保证续传"的契约,而只能当作一个"尽量优化下载体验"的启发式机制。


如果你决定用 DownloadManager 做大文件下载,我建议做这几件事:第一,抓包确认你的 CDN 或源站正确支持 Range 请求和 206 响应;第二,在应用层维护一份独立的下载进度记录,作为 DownloadManager 查询的备份;第三,处理 STATUS_FAILED 的状态,检查 COLUMN_REASONCOLUMN_ERROR_CODE,区分是网络错误、存储错误还是服务器错误,决定是否要删除临时文件重新下载;第四,对 Android 版本和 ROM 做充分的兼容性测试,尤其是断网、杀进程、切换网络、存储空间不足这些边界场景。


代码层面,查询下载状态的典型写法是这样的:


Cursor cursor = downloadManager.query(new DownloadManager.Query().setFilterById(downloadId));
if (cursor.moveToFirst()) {
    int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS));
    int reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON));
    long downloaded = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
    long total = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
    
    if (status == DownloadManager.STATUS_FAILED) {
        // 具体错误码在 reason 里,比如 ERROR_CANNOT_RESUME 表示续传失败
    }
}

注意 ERROR_CANNOT_RESUME 这个错误码,它的触发条件就是前面说的 Range 续传验证失败。但这个错误码在 Android 4.4 之前不存在,更早版本统一返回 ERROR_UNKNOWN


最后说一个我至今没搞懂的现象。在 Android 13 的 Pixel 设备上,DownloadManager 下载完成后,如果文件在 Download/ 目录,系统会自动触发 MediaScanner 扫描,文件立即出现在相册或文件管理器里。但在三星 One UI 5.1(基于 Android 13)上,同样的代码,扫描有时延迟 30 秒以上,有时甚至需要手动重启文件管理器才能看到。这个差异和 DownloadManager 本身关系不大,但说明"系统级方案"的边界比你想象的更模糊,它背后是一整套系统服务的协作,任何一环的定制都可能让你的预期落空。

Appium 和 Espresso 的 UI 测试框架对比 2026-07-06
ProGuard 和 R8 的规则文件编写技巧 2026-07-06

评论区