做 Flutter 下载功能时,最容易栽的跟头就是大文件下载到一半,网络闪断了一下,整个文件作废,又要从头开始。尤其下载体积几百 MB 到几个 GB 的视频、安装包、模型文件时,重来的代价完全不能接受。这类场景里,断点续传几乎是大件下载的标配。dio 是 Flutter 生态里最常用的网络库,虽然自带 download API,但离真正可靠的断点续传之间还隔着一层窗户纸——不少教程直接往 download 里塞一个 Range 头就完事,跑起来才知道有各种坑。这篇文章把我在实际项目里基于 dio 做断点续传下载的完整方案、遇到的坑和最终沉淀下来的代码结构,一次性说清楚。
无论是准备做下载功能的新手,还是已经被“下载失败从头再来”折磨过的老手,这篇都适用。我会先从断点续传的底层原理讲起,再给出基于 dio.download 的官方方案,最后提供一个用 ResponseType.stream 自建下载器的进阶思路,把续传的每个环节都掰开揉碎。
1. 断点续传的底层逻辑:Range 请求与本地临时文件模型
1.1 HTTP Range 是怎么让“续传”成立的
断点续传不是 Flutter 特有的能力,而是 HTTP 协议早就设计好的功能。核心就一个请求头:
Range: bytes=<起始字节>-假设一个文件总共 1000 MB,你下载到 500 MB 时断了。下次发起请求时带上Range: bytes=524288000-,意思是“我已经有前 524288000 个字节的数据了,你从第 524288001 个字节开始发给我”。服务端如果支持 Range,会返回206 Partial Content状态码,并且响应体只有从断点开始的后半段数据。
反过来,如果服务端不认这个头,它会直接返回200 OK和完整文件内容。这一点非常关键——判断服务端到底“认不认账”,不能只看你有没有发 Range 头,要看响应状态码是 206 还是 200。
支持 Range 的服务端通常还会在响应头里带一个标记:
Accept-Ranges: bytes这个头相当于服务端在说“我支持按字节断点续传”。但需要注意,有些服务端实现了 Range 却不带这个头,所以它只能作为参考,不能作为绝对依据。
1.2 临时文件模型:为什么不能直接写最终文件
这是整个断点续传里最容易出错的地方。很多人的第一反应是:既然要续传,直接打开目标文件,从断点位置继续写不就行了?
实际操作中千万别这么干。原因很简单:你从服务端拿到的不是“从 0 到 1000 MB 的完整连续数据”,而是“从第 500 MB 开始的半截数据”。如果你直接用目标文件路径做下载写入,最终得到的文件要么是残缺的(从头覆盖,后半段缺失),要么是错位的(从尾部追加,但文件头本来就应该在开头,结果变成一个坏文件)。
工程上通用的做法是引入临时文件:
video.mp4.part <-- 下载过程中的真实写入目标 video.mp4 <-- 下载完成后的最终文件下载期间,所有数据都写到video.mp4.part这个临时文件里。断点续传时,Range 的起始位置就等于.part文件的当前大小。全部下载完成后,把.part文件重命名为video.mp4即可。这个方案的好处是:video.mp4这个路径下永远不会出现“写到一半的坏文件”,用户看到的就是“要么没下载,要么下载完成”。
1.3 断点信息怎么落盘
还有一个隐蔽问题:App 被杀掉之后重启,怎么知道上次下载到哪了?
最简单可靠的做法是——不额外存什么断点信息,.part文件的大小本身就是断点位置。
final partFile = File('$savePath.part'); final downloadedSize = await partFile.length();只要.part文件还在,它的字节数就是下一次续传的起始位置。不需要什么数据库,不需要记忆上次的进度是多少,文件系统天然帮你记住了。
如果想做得更完善一点,可以在下载元数据里记录 totalSize、ETag 等附加信息。比如拿到服务器返回的总大小,可以用来判断“本地部分文件是不是已经完整了”“远端文件是不是被替换过”。这个后面在多任务管理章节会展开。
2. 环境准备与下载服务类骨架:依赖、目录与任务模型
2.1 项目依赖与最小结构
实战项目里的依赖不会只有 dio 一个,但做断点续传至少需要这两个:
dependencies: dio: ^5.7.0 path_provider: ^2.1.4path_provider用来拿到 App 的私有目录,把下载文件放在应用自己的空间里。这样既不需要申请存储权限,也不会在用户卸载 App 后残留垃圾文件。
代码结构我习惯这样组织:
lib/ models/ download_task.dart # 下载任务元数据模型 services/ download_service.dart # 单文件下载核心逻辑(断点续传) manager/ download_manager.dart # 多任务管理、暂停恢复调度2.2 下载任务的元数据设计
下载任务不能只有一个“文件保存路径”,至少要维护以下信息:
enum DownloadStatus { pending, downloading, paused, completed, failed, } class DownloadTask { final String url; final String savePath; int totalSize; // 服务端文件总大小 int downloadedSize; // 已完成字节数 DownloadStatus status; CancelToken? cancelToken; DownloadTask({ required this.url, required this.savePath, this.totalSize = 0, this.downloadedSize = 0, this.status = DownloadStatus.pending, this.cancelToken, }); }totalSize和downloadedSize的作用是给 UI 进度条用的,downloadedSize也可以直接从.part文件长度获取,但在多任务列表场景下,内存里维护一份会更快。
特别注意CancelToken。它是内存对象,不能序列化存储。App 重启后任务恢复时,CancelToken要重新创建。
目录初始化代码:
class DownloadService { final Dio _dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(minutes: 2), ), ); Future<Directory> _getDownloadDir() async { final docsDir = await getApplicationDocumentsDirectory(); final dir = Directory('${docsDir.path}/downloads'); if (!dir.existsSync()) { dir.createSync(recursive: true); } return dir; } }这里我特意把receiveTimeout设成了 2 分钟,而不是默认的十几秒。下载大文件时,服务端偶尔会有一段时间不发数据(比如手头有其他任务在排队),如果超时时间太短,下载会被“误杀”,而且receiveTimeout在某些版本里对流式下载的处理并不完全可靠,所以宁可设宽松一些。
3. 主流程实现:用 dio.download 跑通真正可靠的断点续传
3.1 第一步:探测请求,确认服务端能力和总大小
正式调用download之前,先发一个 HEAD 请求摸清情况:
Future<({int totalSize, bool supportRange})> probe(String url) async { final headResp = await _dio.head(url); final totalSizeStr = headResp.headers.value('content-length'); final totalSize = int.tryParse(totalSizeStr ?? '') ?? 0; final acceptRanges = headResp.headers.value('accept-ranges') ?? ''; return ( totalSize: totalSize, supportRange: acceptRanges.toLowerCase() == 'bytes', ); }为什么要发 HEAD?因为断点续传必须知道两个信息:
- 服务端文件到底多大。这个值用来判断本地
.part文件是不是已经完整了。 - 服务端是否支持 Range。如果不支持,根本谈不上续传,直接从头下。
supportRange不能完全信任,因为个别服务端返回了Accept-Ranges: bytes却在断点续传时表现异常,也有反过来的情况。但作为第一道判断,它已经能拦截掉大多数不支持续传的服务器。
如果 HEAD 请求拿不到content-length,还有一种方式是解析 206 响应里的Content-Range头,格式一般是bytes 524288000-1048575999/1048576000,最后一段/1048576000就是文件总大小。这个在进阶的流式方案里更常用。
3.2 第二步:组装 Range 头并启动下载
核心下载逻辑如下:
Future<DownloadTask> download({ required String url, required String savePath, CancelToken? cancelToken, void Function(int current, int total)? onProgress, }) async { final partPath = '$savePath.part'; final partFile = File(partPath); final probeInfo = await probe(url); if (probeInfo.totalSize > 0) { final partExists = partFile.existsSync(); final partLen = partExists ? await partFile.length() : 0; // 本地已有完整数据,直接完成 if (partLen >= probeInfo.totalSize) { await partFile.rename(savePath); return DownloadTask( url: url, savePath: savePath, totalSize: probeInfo.totalSize, downloadedSize: probeInfo.totalSize, status: DownloadStatus.completed, ); } // 文件已经被服务端替换,旧的 part 数据没有意义,删掉重下 if (partLen > probeInfo.totalSize) { await partFile.delete(); } } final cancelTokenLocal = cancelToken ?? CancelToken(); final response = await _dio.download( url, partPath, cancelToken: cancelTokenLocal, deleteOnError: false, options: Options( headers: partFile.existsSync() && await partFile.length() > 0 ? {'range': 'bytes=${await partFile.length()}-'} : null, ), onReceiveProgress: (received, total) { // 见 3.3 的进度校准说明 }, ); // 下载结束后统一处理文件落地 await _finalizeDownload(response, savePath, partFile); return buildCompletedTask(url, savePath); }这里有两个关键点,先敲黑板:
第一,deleteOnError: false必须传。
dio 的download方法有个默认参数,出错或取消时默认会删除已下载的文件。对断点续传来说这简直是灾难——好不容易下载了 800 MB,一个网络波动就把.part文件删了。所以一定要显式设成false,让文件在异常时保留下来。
第二,不要自己提前创建.part文件。
dio 内部会处理文件创建。如果你传的是一个已存在的文件,不同版本的 dio 对写入模式的默认行为不一样,与其依赖它内部可能是“追加”也可能是“覆盖”,不如让 dio 自己决定,我们用 Range 头来控制写入的数据范围。真正要保证的是:.part文件里已有的字节数,恰好等于 Range 头的起始位置。
3.3 第三步:进度回调的偏移校准
断点续传里最容易让进度条“抽风”的就是回调参数。onReceiveProgress回调里有两个参数,received和total。很多人直接拿它俩算百分比:
onReceiveProgress: (received, total) { percent = received / total; // 错误写法,大坑 }在续传场景下,这里的received是本次连接收到的字节数,不是整个文件的累计字节数。比如你已经下载了 500 MB,续传时服务器从第 500 MB 开始发,received可能就是 800 MB(本次连接收到的剩余部分),但实际上文件已经下载了 1300 MB。
total也不是完整文件大小。206 响应下,total是服务器响应头里的content-length,等于剩余未下载的部分,而不是整个文件。
正确的校准方式是用.part文件的真实大小加上本次回调值:
onReceiveProgress: (received, total) { final currentDownloaded = existingPartLen + received; final totalSize = probeInfo.totalSize; onProgress?.call(currentDownloaded, totalSize); }existingPartLen在发起下载前就取好,在回调闭包里引用同一个值,不要每次现算文件大小,否则频繁的文件 IO 会拖慢下载。
3.4 第四步:下载完成后的文件落地与清理
dio.download正常返回后,.part文件应该已经写完了。这时候做收尾:
Future<void> _finalizeDownload( Response response, String savePath, File partFile, ) async { // 服务器返回 200 说明它忽略了 Range 头,返回了完整文件。 // 但此时 .part 里如果混有旧数据,最终文件会损坏,需要特殊处理。 if (response.statusCode == 200) { // 这是降级场景,说明服务器不支持续传 // 需要删掉旧 part,重新走一遍普通下载 if (partFile.existsSync()) { await partFile.delete(); } throw DioException.connectionError( reason: 'Server does not support Range requests, need redownload', requestOptions: response.requestOptions, ); } final destFile = File(savePath); if (destFile.existsSync()) { await destFile.delete(); } await partFile.rename(savePath); }重命名前先把目标文件删掉,是为了避免某些平台上rename遇到目标已存在时报错。正常场景下目标文件不可能存在,但程序要防御这种情况。
我实际项目中遇到过一种比较坑的情况:服务端返回 200 全量数据的同时,dio 把数据追加到了留有一部分旧数据的.part文件后面,最终文件比正常文件大了不少,而且完全打不开。从那以后我就特别注意 statusCode 的判断。
4. 暂停、取消与异常恢复:CancelToken 和 deleteOnError 的坑
4.1 CancelToken 的正确打开方式
dio 的download方法接受一个CancelToken。要暂停下载,就调用它:
task.cancelToken?.cancel();调用cancel后,正在进行的下载请求会抛出DioException,异常类型是DioExceptionType.cancel。捕获到这个类型,说明是用户主动暂停,逻辑上不应该做任何“删除文件”的操作,正常保留.part文件即可。
这里容易踩一个隐蔽的坑:一个CancelToken一旦被 cancel,就不能复用了。下次恢复下载必须 new 一个新的CancelToken。所以在任务模型里,cancelToken字段每次恢复下载时都要重新赋值:
void pause(DownloadTask task) { task.cancelToken?.cancel(); task.cancelToken = null; task.status = DownloadStatus.paused; } Future<void> resume(DownloadTask task) async { task.cancelToken = CancelToken(); task.status = DownloadStatus.downloading; // 调用第 3 章的 download 逻辑 }4.2 deleteOnError 默认行为是个大坑
再强调一次:dio.download的参数里,deleteOnError默认是true。也就是说,只要请求过程出现任何异常,包括主动取消、网络中断、超时,dio 都会把已经下载的临时文件删除掉。
我第一次实现断点续传的时候没有设置这个参数,调试时发现一个诡异现象:下载到 90% 时手动取消,.part文件竟然消失了,下次恢复又得从头开始。翻源码才发现是这个参数在作祟。这个坑的隐蔽性在于,你代码逻辑看着完全没问题,但底层默认行为把你背刺了。
断点续传场景下这个参数铁定要设成false。极端情况下你甚至可以自己接管文件删除策略,比如用户明确点击“取消并删除下载记录”时,再手动删掉.part文件。
4.3 断线后的自动重试与 416 处理
网络请求的异常类型很多:connectionTimeout、receiveTimeout、connectionError、badResponse等。
我的处理策略是:
- 用户主动取消(
DioExceptionType.cancel):暂停任务,保留文件,不做重试。 - 网络类异常(
connectionError、超时等):保留文件,做有限次数的自动重试。 - 服务端返回
416 Range Not Satisfiable:说明本地.part文件比服务端文件还大或 Range 不合法,多半是文件被替换过了。此时删除.part,从头下载。
一个简单的重试示例:
const maxRetries = 3; Future<void> runWithRetry(Future<void> Function() task) async { for (var attempt = 0; attempt < maxRetries; attempt++) { try { await task(); return; } on DioException catch (e) { if (e.type == DioExceptionType.cancel) { rethrow; } if (attempt == maxRetries - 1) { rethrow; } await Future.delayed(Duration(seconds: 2 * (attempt + 1))); } } }带退避的重试可以避免在网络恢复初期反复请求把服务端打挂。实际测试时,WiFi 断开再重连的场景下,这个策略能自动把下载续上。
5. 进阶方案:用 ResponseType.stream 自建下载器,把流程握在自己手里
5.1 为什么还要自建下载器
dio.download的封装虽然方便,但它把文件写入、删除策略、流处理都包在了黑盒里。以下几种场景下,它就不够用了:
- 需要先拿到响应头(statusCode、content-range、etag)再决定怎么写入;
- 服务端对 HEAD 请求支持不好,只能通过 GET 响应头获取完整信息;
- 需要精确控制文件的写入模式(是覆盖还是追加);
- 需要把下载数据同时做校验、存储、缓存等额外处理。
这时候,直接用dio.get<ResponseBody>配合ResponseType.stream来自己接收数据流,是最可控的方案。
5.2 流式写入与 append 模式实现
核心思路很简单:发起 GET 请求时带上 Range 头,拿到响应后判断 statusCode,然后打开文件写入流,把数据管道对接起来。
Future<void> downloadWithStream({ required String url, required String savePath, required int existingLength, CancelToken? cancelToken, void Function(int current, int total)? onProgress, }) async { final response = await _dio.get<ResponseBody>( url, cancelToken: cancelToken, options: Options( responseType: ResponseType.stream, headers: existingLength > 0 ? {'range': 'bytes=$existingLength-'} : null, ), ); final partFile = File('$savePath.part'); if (!partFile.existsSync()) { await partFile.create(recursive: true); } // 206:续传,从已有文件尾部追加写入 // 200:服务器忽略了 Range,从头写入,先清空旧文件 final mode = response.statusCode == 206 ? FileMode.append : FileMode.write; if (mode == FileMode.write) { // 从头下载时,必须把旧 part 干掉,否则会和完整新数据混在一起 await partFile.delete(); await partFile.create(); } final raf = partFile.openWrite(mode: mode); try { await response.data!.stream.pipe(raf); } catch (e) { // 取消或者网络错误都走到这里 // 注意:要把写入流关掉,否则文件句柄会被一直占用 await raf.close(); rethrow; } }这个方案的优势是全程透明:错误发生在流写入的哪个环节、文件处于什么状态、写入模式是什么,全都在代码里可见。
ResponseType.stream模式下,response.data!.stream是一个Stream<List<int>>,直接pipe到文件写入流即可,dio 不会额外做任何文件操作,不会偷偷删文件,也不会在取消时清理你的.part。
5.3 自定义方案的取消与进度统计
取消逻辑依然是围绕CancelToken:
try { await downloadWithStream(...); } on DioException catch (e) { if (e.type == DioExceptionType.cancel) { // 用户主动取消:什么都不做,保留 .part 文件 return; } // 网络错误:走重试逻辑 rethrow; }进度统计也变得更直观。因为你可以随时读取.part文件的大小作为准确值,或者用已有的existingLength加上本次流内收到的总字节数。
如果要从流内部统计字节数,可以加一个简单的转换器:
var receivedInThisConnection = 0; final subscription = response.data!.stream.listen( (chunk) { receivedInThisConnection += chunk.length; onProgress?.call(existingLength + receivedInThisConnection, totalSize); }, );注意大文件场景下进度回调不要刷得太频繁,否则 UI 端会卡顿。可以自己做个简单的节流:每收到 256 KB 或者每隔 300 毫秒回调一次。
5.4 两种方案怎么选
| 对比维度 | dio.download 方案 | ResponseType.stream 自建方案 |
|---|---|---|
| 代码量 | 少,封装度高 | 较多,需要自己处理写入流 |
| 控制力 | 弱,写入模式、删除行为依赖版本内部实现 | 强,文件模式完全可控 |
| 异常处理 | 需要依赖 deleteOnError 等参数 | 一切异常都显式暴露 |
| 适合场景 | 快速开发,服务器比较标准 | 服务器行为不标准、需要特殊校验或精细控制 |
通常情况下,优先用dio.download方案就能满足需求;如果你的下载对象是那种 CDN 行为很怪、响应头经常缺失的地址,直接切到自建方案。
6. 多任务下载与生命周期管理:从单文件到完整下载器
6.1 任务管理器:状态机与并发控制
单文件的断点续传写完,只能算完成了 30%。真实项目里,用户可能会同时下载好几个文件,在列表页面里来回点击暂停、恢复、取消。所以需要一个任务管理器来统筹。
我用一个简单的单例加ChangeNotifier:
class DownloadManager extends ChangeNotifier { DownloadManager._(); static final DownloadManager instance = DownloadManager._(); final Map<String, DownloadTask> _tasks = {}; final List<String> _runningQueue = []; DownloadTask? getTask(String url) => _tasks[url]; List<DownloadTask> get allTasks => _tasks.values.toList(); void startDownload(String url, String savePath) { final task = _tasks.putIfAbsent( url, () => DownloadTask(url: url, savePath: savePath), ); if (task.status == DownloadStatus.downloading) return; _runningQueue.add(url); _processQueue(); } void pauseDownload(String url) { final task = _tasks[url]; task?.cancelToken?.cancel(); task?.status = DownloadStatus.paused; _runningQueue.remove(url); notifyListeners(); } void resumeDownload(String url) { final task = _tasks[url]; if (task == null) return; task.status = DownloadStatus.downloading; _runningQueue.add(url); _processQueue(); } }并发控制不要忽略。如果用户一口气点了 10 个下载任务,不做限制会让 CPU 和网络双双吃紧。我在_processQueue里限制同时最多两个任务在跑:
Future<void> _processQueue() async { if (_activeCount >= 2) return; while (_runningQueue.isNotEmpty && _activeCount < 2) { final url = _runningQueue.first; _runningQueue.removeAt(0); _activeCount++; try { await _runTask(url); } finally { _activeCount--; _processQueue(); } } }_runTask内部会调用DownloadService的下载逻辑,并更新task.totalSize、task.downloadedSize、task.status。每次更新后notifyListeners(),UI 的 ProgressBar 跟着刷新。
6.2 App 前后台切换时的自动暂停与恢复
移动端的下载功能还有一个躲不开的问题:App 切到后台,或者被系统挂起时,网络请求可能会被系统杀掉。与其等系统杀,不如主动暂停。
在 Flutter 3.13 之后,可以用AppLifecycleListener监听生命周期:
late final AppLifecycleListener _lifecycleListener; void init() { _lifecycleListener = AppLifecycleListener( onStateChange: (state) { if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) { DownloadManager.instance.pauseAll(); } else if (state == AppLifecycleState.resumed) { DownloadManager.instance.resumeAllPaused(); } }, ); }pauseAll会遍历所有下载中的任务,分别 cancel 掉。由于我们保留了.part文件,回到前台后自动恢复,接着断点继续。
这里有个细节:真正杀进程的情况,AppLifecycleListener是拦不住的。所以任务管理器启动时,要主动扫描下载目录里的.part文件,根据文件名关联历史任务,把状态恢复为paused,等用户点击或者自动恢复。
恢复任务所需的元数据(url、savePath、totalSize),我建议单独存一份 JSON 清单。不要只靠扫描文件,因为.part文件本身不知道它对应哪个 URL。一个简单的 manifest.json 就够了:
{ "tasks": [ { "url": "https://example.com/video.mp4", "savePath": "/documents/downloads/video.mp4", "totalSize": 1048576000, "etag": "abc123" } ] }6.3 实测场景与边界测试
代码写完后,我在模拟器和真机上分别跑了这几组测试,强烈建议你也照着过一遍:
- 下载到一半手动暂停,再恢复:确认
.part文件大小变化和进度条计算正确。 - 下载中切后台再切回来:确认任务自动暂停并恢复,没有被系统杀掉导致文件损坏。
- 下载中断开 WiFi 再重新连接:自动重试能续传,不会从头开始。
- 服务端文件被替换成更小的文件:确认 416 处理逻辑能删除旧文件重下,而不是报一个莫名其妙的错误。
- 服务器忽略 Range 头,永远返回 200:确认程序能识别 statusCode,删除旧
.part全量重下。
我自己在实际测试里遇到过一个问题:部分 Android 机型在 App 进入后台后,dio 的底层 socket 会被系统回收,回到前台时,请求没有立刻抛异常,而是挂起一段时间后才报超时。这种情况下,自动恢复逻辑要多加一层判断:恢复前检查.part文件的大小是否和任务记录里的downloadedSize一致,如果不一致,以实际文件大小为准重新计算 Range 起始位置。
另外,File.rename在 Android 的某些文件系统上,如果目标路径和原路径跨越不同目录,会报错。稳妥做法是先copy到最终路径再删除原文件:
await partFile.copy(savePath); await partFile.delete();虽然多一次 IO,但兼容性最好。
多任务下载还有一个性能细节:同时下载两个大文件时,建议在DownloadService里给每个任务独立的 Dio 实例,而不是全局共享同一个。因为 Dio 的BaseOptions里的receiveTimeout如果给的是同一个实例,一个任务的超时设置可能会被另一个任务影响;多实例隔离更干净。
最后,如果下载的是安装包这类需要校验完整性的文件,建议在元数据里存服务端返回的ETag或Last-Modified,下载完成后比对一次。这个环节很多人会跳过,但真正上线后你会发现,CDN 节点内容不一致的情况虽然少,一旦碰上就是用户那边出现“下载完成但安装包损坏”的投诉,排查成本比多写几行校验代码高得多。
我在实际项目中踩过最深的坑,反而不是技术难点,而是“想当然”——想当然以为服务器支持 Range,想当然以为 dio 会自动追加写入,想当然以为取消后文件还在。断点续传本质上是一个“和服务器反复确认”的过程:发 Range 之前确认,收到响应后确认,写文件之前确认。每一步都显式判断,才能保证下载结果的可靠性。