☰
Flutter断点续传实战:基于dio的Range请求与文件追加实现
2026/10/18 21:49:04 网站建设 项目流程

断点续传这件事,做过的都知道,坑不在“断”上,在“续”上。你用Flutter做下载功能,网上搜方案十有八九会指向dio,但真把dio的DownloadStream、CancelToken、Range头这些揉在一起用,还是有不少细节值得掰扯。这篇文章我按实际项目里的实现思路来写,从原理解析到代码落地,再到排查问题,尽量把每一步为什么这么做讲清楚。

1. 方案选型与整体设计思路

1.1 为什么选dio做断点续传

Flutter生态里能做文件下载的库不少,原生有HttpClient,第三方有dio、http、flutter_download_manager这些。我自己在几个项目里对比下来,断点续传这个场景,dio是绕不开的选择。

原因有三点。第一,dio的download方法本身就支持接收流式响应,配合CancelToken可以主动中断请求,这是断点续传的前提。第二,dio拦截器体系成熟,你可以轻松拿到请求头、响应头、错误信息,再做统一的异常处理。第三,社区里对dio的踩坑记录最多,遇到问题搜起来资料最全,这对后期维护很重要。

不过要说明一点,dio本身并不提供“断点续传”这个开箱即用的功能。它提供的是底层能力:可取消的请求、流式写入、自定义请求头。真正的断点续传逻辑——比如记录已下载大小、拼接请求Range、校验文件完整性——都需要自己实现。这恰恰是这篇内容要讲的核心。

1.2 断点续传的核心模型

断点续传听起来玄乎,拆开看就三件事:

  • 记录进度:我要知道已经下载了多少字节,存到哪里去。
  • 告诉服务器:从哪一刻开始继续传,通过HTTP的Range头实现。
  • 拼接文件:把新下载的数据追加到已有文件末尾,而不是覆盖。

这三件事落实到Flutter代码里,分别对应:本地持久化存储(SharedPreferences或者自建文件)、dio请求的Headers参数、RandomAccessFile或IOSink的追加写入模式。

整个下载流程可以抽象成一个状态机:空闲 → 下载中 → 暂停/失败 → 恢复下载 → 完成。每个状态都需要明确当前下载的起始位置和结束位置,否则断点续传就会变成断点乱传。

1.3 适用范围与场景边界

断点续传的适用场景很典型:大文件下载、弱网环境、用户可能随时退出App的场景。比如视频缓存、安装包下载、离线地图包更新,这些文件少则几十MB,多则几个GB,没有断点续传基本不可用。

但也有不适合硬上断点续传的场景。服务端不返回Accept-Ranges或Content-Range头的情况下,说明服务器根本没实现分段传输,你发Range过去大概率返回200全量数据,续传就无从谈起。还有一些CDN服务对Range支持不完整,只认bytes=0-这种格式。这些在方案选型时要先探查清楚,否则代码写完了,实际跑起来还是全量下载。

2. 核心原理:HTTP Range与206状态码

2.1 Range请求头的工作机制

HTTP协议本身是支持断点续传的,靠的就是Range请求头。客户端发起请求时带上Range: bytes=1024-,意思就是“我已经有前1024个字节了,请从第1025个字节开始发”。服务器如果支持,会返回206 Partial Content,响应体里只包含从1024开始的那部分数据,同时用Content-Range: bytes 1024-2048/4096来告诉客户端本次返回的范围和文件总大小。

这里有几个细节必须注意。Range的单位永远是字节,而且是闭区间,bytes=0-表示从第0字节到末尾。如果你要请求从第1024字节开始的数据,Range头要写bytes=1024-,而下一次写入文件的offset就是1024,因为第1024字节是下一个要写入的内容。很多初次实现断点续传的同学都卡在这个边界上,多写一个offset或者少写一个,文件就废了。

还有一个容易忽略的点:有些服务器对Range的处理是“包含起始字节”,有些则习惯“排除起始字节”。虽然HTTP规范定义是前者,但实际对接时最好打印一次响应头确认一下,尤其是对接第三方服务或者内部老系统的时候。

2.2 200、206与416状态码的含义

请求带上Range之后,服务端可能返回几种不同的状态码,每一种都代表不同的语义,处理方式也完全不同。

返回200,且响应体是全量文件,这说明服务器忽略了Range头,不支持断点续传。这种情况下你只能接受全量下载,已有的临时文件直接删掉重来。

返回206,这是最理想的情况,服务器返回了指定范围的数据。你可以直接拿到Content-Range头的后半段来解析文件总大小,结合本次实际收到的字节数做校验。

返回416 Requested Range Not Satisfiable,说明你请求的范围超出文件长度了。常见原因是本地记录的下载进度比实际文件大,比如服务端文件更新替换成了更小的版本。这时候要重置断点信息,从零开始下载。

我项目里写了一个switch分支专门处理这三种情况,role不能省,因为不同的分支直接影响文件写入策略。省略的话,文件就可能在错误的位置写入,导致最终文件损坏。

2.3 Content-Range和Content-Length的坑

拿到206响应后,有两个响应头要重点看:Content-Range和Content-Length。

Content-Range的格式是bytes start-end/total。比如bytes 1024-2048/4096,start表示当前分片的起始字节,end表示结束字节,total是文件总大小。解析total时要注意,有的服务器在文件总大小未知时会写bytes 1024-2048/*,这种在动态生成文件的场景下会出现。遇到这种情况,你无法获取精确的总大小,进度条只能靠已下载字节数和某个预估值的比值来算,或者干脆不显示百分比,只显示已下载大小。

Content-Length表示本次响应体的字节数,不是文件总大小。很多人在写进度逻辑时会拿它直接除以一个固定值,这就是进度条突然跳变的原因之一。进度计算应该拿“已写入文件的字节数 + 本次响应的接收字节数”除以“文件总大小”来计算,而不是拿Content-Length除以某个值。

另一种做法是:先发一次HEAD请求或GET请求读取总的Content-Length,再把它作为文件总大小传入下载逻辑。这个方法更稳妥,也是我推荐的。因为断点续传时每次206响应里带的总大小都相同,拿它做基准就行,但HEAD请求可以在下载前就拿到元信息,便于校验。

3. 具体实现:基于dio的断点续传下载

3.1 项目结构与依赖引入

先理一下项目结构。我这里假设你已经有了一个Flutter工程,直接新增一个下载管理模块。按照功能分层,我习惯这样组织:

lib/ services/ download_service.dart // 下载服务,核心逻辑 download_record.dart // 断点信息持久化 models/ download_task.dart // 下载任务模型

依赖方面,除了dio,我还会引入path_provider来获取应用文档目录,用来放临时文件和断点记录。如果你要用SharedPreferences存断点信息,也可以加上shared_preferences,不过对于大文件的断点记录,我更推荐自定义JSON文件,因为断点信息是结构化的,用文件存储更直观,排查问题也方便。

dependencies: dio: ^5.3.0 path_provider: ^2.1.0 path: ^1.8.3

dio版本这块说明一下,5.x和4.x在download接口的API上有差异,5.x的DownloadStream不再直接返回Response对象,而是需要通过response.data拿到流。这个差异会影响下面代码的写法,我用5.x的语法来写,如果你还在用4.x,需要对照官方迁移文档调整。

3.2 下载任务模型设计

下载任务模型需要承载哪些信息?最少要有:任务ID、下载URL、保存路径、文件总大小、已下载大小、当前状态。

enum DownloadStatus { idle, downloading, paused, failed, completed } class DownloadTask { final String taskId; final String url; final String savePath; int totalBytes; int receivedBytes; DownloadStatus status; DownloadTask({ required this.taskId, required this.url, required this.savePath, this.totalBytes = 0, this.receivedBytes = 0, this.status = DownloadStatus.idle, }); }

这里要注意,totalBytes和receivedBytes必须独立记录。totalBytes来自Content-Range或HEAD请求,receivedBytes是实际写入文件的字节数,两者可能因为服务端的响应差异不完全一致。以receivedBytes为准来拼接文件,以totalBytes为准来计算进度,这个口径要统一,否则会出现进度条显示100%但文件还没写完的诡异现象。

3.3 断点信息持久化

断点续传最关键的“记忆”功能就靠这部分。如果用户下载到一半杀掉了App,再次打开时,断点信息必须还在,否则一切白搭。

我用一个JSON文件来存储断点信息,结构是这样的:

{ "taskId": "video_001", "url": "https://example.com/files/video.mp4", "savePath": "/data/user/0/com.example/app_flutter/video.mp4", "receivedBytes": 1048576, "totalBytes": 10485760, "updateTime": 1691234567890 }

读取和写入的逻辑封装在DownloadRecord类里:

class DownloadRecord { static Future<Map<String, dynamic>?> read(String taskId) async { final dir = await getApplicationDocumentsDirectory(); final file = File('${dir.path}/download_record.json'); if (!await file.exists()) return null; final jsonStr = await file.readAsString(); final data = jsonDecode(jsonStr) as Map<String, dynamic>; return data[taskId] as Map<String, dynamic>?; } static Future<void> write(String taskId, Map<String, dynamic> info) async { final dir = await getApplicationDocumentsDirectory(); final file = File('${dir.path}/download_record.json'); Map<String, dynamic> allData = {}; if (await file.exists()) { final jsonStr = await file.readAsString(); allData = jsonDecode(jsonStr) as Map<String, dynamic>; } allData[taskId] = info; await file.writeAsString(jsonEncode(allData)); } }

这里有几个设计上的考量。第一,为什么不用SharedPreferences?因为断点信息可能频繁更新,每次都序列化整个Preferences,效率不高。JSON文件可以按taskId做局部更新,虽然简单实现里我整盘重写,但至少结构上预留了扩展空间。第二,为什么不直接写在数据库里?对于这种单机、单任务断点,文件存储比数据库轻量得多,导入导出、Debug时直接看JSON内容也方便很多。第三,receivedBytes的更新频率要控制,建议每次写入文件的字节数累计超过1MB后做一次持久化,否则频繁写文件会损耗磁盘寿命。

3.4 核心下载逻辑:从零到一的完整代码

下载逻辑我拆成两部分:单一File的下载函数和DownloadTask驱动的流程控制函数。

先看核心的下载函数。这里用dio的DownloadStream来实现流式写入,关键点在于设置headers和取消标记:

class DownloadService { final Dio _dio = Dio(); Future<void> download(DownloadTask task, CancelToken cancelToken) async { String savePath = task.savePath; File file = File(savePath); String tmpPath = '$savePath.tmp'; // 如果临时文件不存在,说明没有历史进度 if (!await file.exists()) { await file.create(recursive: true); } if (!await File(tmpPath).exists()) { await File(tmpPath).create(recursive: true); } // 关键点:设置Range头,告诉服务器从哪里续传 final headers = { 'Range': 'bytes=${task.receivedBytes}-', }; try { final Response<dynamic> response = await _dio.get<dynamic>( task.url, options: Options( headers: headers, responseType: ResponseType.stream, followRedirects: false, ), cancelToken: cancelToken, ); if (response.statusCode == 200) { // 全量下载:服务器不认Range,重新开始 task.receivedBytes = 0; } else if (response.statusCode == 206) { // 断点续传:解析Content-Range拿到总大小 final contentRange = response.headers.value('content-range'); if (contentRange != null) { final match = RegExp(r'bytes \d+-(\d+)/(\d+)').firstMatch(contentRange); if (match != null) { task.totalBytes = int.parse(match.group(2)!); } } } else if (response.statusCode == 416) { // 范围不满足:重置进度重新下载 task.receivedBytes = 0; task.totalBytes = 0; await File(tmpPath).delete().catchError((_) {}); return download(task, cancelToken); } else { throw DioException( requestOptions: response.requestOptions, response: response, type: DioExceptionType.badResponse, ); } // 核心写入逻辑 final stream = response.data.stream as Stream<List<int>>; final raf = await File(tmpPath).open(mode: FileMode.append); int received = task.receivedBytes; await for (final chunk in stream) { await raf.writeFrom(chunk); // 追加写入 received += chunk.length; task.receivedBytes = received; } await raf.close(); // 下载完成,重命名临时文件为目标文件 if (await File(tmpPath).exists()) { await File(tmpPath).rename(savePath); } task.status = DownloadStatus.completed; } on DioException catch (e) { if (CancelToken.isCancel(e)) { // 用户取消,保存断点信息 task.status = DownloadStatus.paused; await DownloadRecord.write(task.taskId, { 'taskId': task.taskId, 'url': task.url, 'savePath': task.savePath, 'receivedBytes': task.receivedBytes, 'totalBytes': task.totalBytes, }); } else { // 其他错误 task.status = DownloadStatus.failed; rethrow; } } } }

这段代码几个关键点要拆开讲。

第一,为什么用临时文件?因为下载过程中文件是不完整的,如果直接把数据写到目标路径,中途出问题,用户会看到一个损坏的“最终文件”,并且它占用了最终路径,导致下次下载时逻辑混乱。用.tmp后缀的方案,下载完全成功后再rename成正式文件,这是一套很经典的处理手段。即使下载失败了,临时文件还可以保留,下次续传接着写。

第二,为什么用FileMode.append?因为我们是按追加的方式往文件末尾写数据,如果用FileMode.write,每次打开都会清空文件内容,断点续传就失效了。这里还有一种选择是RandomAccessFile配合position来写,但append模式更简单直观,因为它天然遵循“从末尾追加”的语义,避免了手动维护position状态。

第三,receivedBytes的累加逻辑。从Range头指定的位置开始,每收到一个chunk就累加对应字节数。注意task.receivedBytes会频繁变化,这个值要能及时反映到UI上,所以它最好是可监听的对象,典型做法是用ChangeNotifier或Stream。下面进度管理的部分会详细讲解。

3.5 进度管理与UI刷新

进度管理用一个简单的ChangeNotifier即可,监听task.receivedBytes和task.totalBytes的变化:

class DownloadTask extends ChangeNotifier { int _receivedBytes = 0; int _totalBytes = 0; int get receivedBytes => _receivedBytes; int get totalBytes => _totalBytes; double get progress { if (_totalBytes <= 0) return 0; return _receivedBytes / _totalBytes; } set receivedBytes(int value) { _receivedBytes = value; notifyListeners(); } set totalBytes(int value) { _totalBytes = value; notifyListeners(); } }

UI侧用AnimatedBuilder或者ValueListenableBuilder监听进度,刷新进度条。这里有一个实际经验:不要在下载循环里每接收一个chunk就notifyListeners一次,那样会疯狂触发UI重建,FPS会掉到没法看。合理做法是每写入至少256KB或1MB后刷新一次,再配合一个定时器兜底。

我一般用这种策略:在写入循环里维护一个_lastNotifyBytes变量,当received - _lastNotifyBytes >= 262144(256KB)时触发通知并更新_lastNotifyBytes。这样既保证了进度更新的及时性,又不会过度消耗UI性能。

3.6 取消下载与断点保存

dio的CancelToken提供了取消请求的能力。取消时机由外部控制,比如用户点了暂停按钮。我的处理方式是用一个全局CancellationToken,在需要取消时调用:

CancelToken _cancelToken = CancelToken(); void pauseDownload() { _cancelToken.cancel(); }

注意,dio的cancel并不会立即中断正在进行的IO操作,它会在合适的时机抛出CancelException。在dio 5.x中,这个异常的类型是DioException,可以通过CancelToken.isCancel(e)判断是否真的由取消导致。这个判断很关键,因为网络超时或者DNS解析失败也会抛DioException,但类型不同,不能用取消的逻辑去处理。

取消之后,我们在catch块里保存断点信息。这里的receivedBytes是在取消时的最新值,通过DownloadRecord持久化。下次发起下载时,读取这个值,设置Range头,就能精确续传。

3.7 下载完成后的校验逻辑

只靠“文件大小”来判定是否下载完成是不够的。曾经有个项目对接某CDN,下载过程中服务端悄悄改了文件,导致本地文件比服务器新文件少了几个字节,进度却显示完成了。所以要加一层校验。

校验方式有三种可选。

第一种是大小校验。下载完成后,比对本地文件字节数和服务器返回的Content-Length,如果一致就认为成功。这个最简单,但也是最脆弱的校验方式,只适用于文件内容在下载期间未变动的场景。

第二种是修改时间校验。下载开始前记录服务端的Last-Modified头,下载完成后检查这个时间是否变了。变了说明文件在下载过程中被更新过,需要重下。这种方式能检测到大小没变但内容变化的场景。

第三种是哈希校验。下载完成后,计算本地文件的MD5或SHA-1,和服务端提供的哈希值比对。这个最可靠,但需要服务端提供哈希信息,而且下载完成后的计算耗时也不容忽视。

我实践中常用的组合是:大小校验 + 修改时间校验。哈希校验一般用于安装包、版本升级包这类对完整性要求极高的文件。

Future<bool> verifyFile(DownloadTask task) async { final file = File(task.savePath); if (!await file.exists()) return false; final size = await file.length(); if (size != task.totalBytes) return false; final modified = await file.lastModified(); if (task.serverModifiedTime != null && modified.isBefore(task.serverModifiedTime!)) { return false; } return true; }

4. 异常场景与问题排查技巧

4.1 服务端不支持Range的降级处理

对接过的服务里,有的老系统压根不处理Range头,你发过去它照样返回200全量文件。这不算bug,但你需要一个降级策略。我建议在发起下载前,先发一个HEAD请求探测:

Future<bool> checkRangeSupport(String url) async { try { final response = await _dio.head(url); final acceptRanges = response.headers.value('accept-ranges'); return acceptRanges == 'bytes'; } catch (_) { return false; } }

如果不支持,直接走全量下载逻辑,不存断点信息,进度条也只能做单次会话内的展示。支持的话,再走断点续传的完整链路。这样至少保证功能不挂,用户在弱网环境下的体验好很多。

4.2 网络中断与超时重试策略

网络中断是断点续传最典型的触发场景。dio默认的connectTimeout和receiveTimeout需要根据文件大小和网络状况动态调整。我在项目里设置receiveTimeout为0,表示不超时。因为大文件下载可能持续很长时间,如果设置固定超时时间,中途经常断开。

但完全不设超时会带来另一个问题:长时间卡住不动,进度也不更新,用户体验很差。折中方案是:在写入循环里记录最后一次成功写入的时间,如果超过30秒无任何数据落地,就主动取消本次请求,保存断点,并提示用户网络异常。

DateTime _lastWriteTime = DateTime.now(); await for (final chunk in stream) { await raf.writeFrom(chunk); _lastWriteTime = DateTime.now(); } // 在另一个timer或isolate里检查 if (DateTime.now().difference(_lastWriteTime) > Duration(seconds: 30)) { _cancelToken.cancel(); }

重试策略上,我设置了最多3次自动重试,每次重试前指数退避等待,比如1秒、2秒、4秒。超过3次后不再自动重试,把状态置为failed,由用户手动触发恢复。

4.3 断点记录与实际文件不一致的处理

文件被外部系统清理、或者用户手动删除了临时文件,但断点记录仍然存在,这时续传会出问题。我遇到过最典型的场景:App更新了,存储目录变了(比如沙盒路径变化),断点记录里的路径指向不存在的位置,恢复下载时创建文件失败。

解决思路是每次启动时做一次全量校验:遍历断点记录,比对savePath指向的文件是否存在,不存在就把断点记录的receivedBytes置为0。还有一种情况,断点记录里的receivedBytes大于服务器文件的当前total,要重置为0并走416重试逻辑,这个逻辑我在前面代码里已经写了。

4.4 常见问题速查表

我把实际开发中遇到的典型问题整理成一个表格,方便你快速定位:

现象可能原因处理建议
下载进度突然跳到100%然后失败计算进度时错用Content-Length除以total进度计算要基于receivedBytes/totalBytes
下载完成后文件打不开文件写入时offset错位,多写或少写了一个字节检查Range头起始位置和append模式的使用
断点续传后文件越下载越大没有使用append模式,每次打开都从头写用FileMode.append而非FileMode.write
手机重启后断点信息丢失断点信息只存在内存里,没有落盘每次接收超过1MB就持久化一次
部分CDN下载到一半中断CDN回源或超时导致流中断增加自动重试机制,并设置合理的超时策略
下载暂停后恢复,进度倒退断点记录被覆盖为旧值取消时要同步保存最新断点信息,避免竞态
超大文件下载,内存暴涨stream的buffer过大调低dio的Stream接收buffer,或者用ResponseType.stream配合分块写入

4.5 多任务并发下载的场景

如果要做多个文件同时下载的任务队列,断点续传的逻辑需要加一个全局协调层。我的建议是,不要同时download太多任务,控制在2到3个并发即可。因为每一个下载任务都会占用一个文件句柄和一部分内存,Flutter单线程模型下,IO操作虽然是异步的,但大量任务同时流转还是会挤占UI线程的资源。

实现并发下载时,每个任务的CancelToken要独立,下载完成后通过一个回调通知任务队列服务,让它调度下一个任务。断点记录的读写,我建议所有任务共用一个JSON文件,写入时加一个简单的互斥锁,避免多个任务同时写导致文件损坏。

4.6 权限与存储目录的细节

最后提一个容易踩的坑:存储路径。path_provider提供三种目录:临时目录、文档目录、缓存目录。文档目录适合放用户明确需要长期保留的文件,比如下载的视频、安装包。缓存目录适合放可以被系统随时清除的临时文件。

断点续传的临时文件(.tmp)我放在文档目录下,而不是缓存目录。原因在于缓存目录可能被系统清理,一旦清理,断点记录还在,但临时文件没了,就会出问题。反正文档目录的空间占用问题可以通过清理逻辑来控制,下载完成后临时文件会被rename成正式文件,不会残留无关垃圾。当然你也可以定期扫描文档目录里的.tmp文件,超过一定时间的直接清掉,这属于洁癖级优化了,按需做就行。

5. 实操过程中的独家经验

5.1 别依赖一个Chunk就刷新一次UI

前面提过一次,再强调一遍:下载是IO密集型操作,dio吐出来的chunk可能非常频繁,尤其是在局域网环境下,一秒钟可能几千个chunk。如果你在每一轮循环里都notifyListeners,UI线程会被疯狂调度,实测下来进度条会变卡,甚至出现ANR。正确的做法是“节流”,无论按字节还是按时长,必须限制刷新频率。

我习惯写一个简单的节流工具:

class Throttle { final Duration interval; DateTime _last = DateTime.fromMillisecondsSinceEpoch(0); Throttle(this.interval); bool shouldUpdate() { final now = DateTime.now(); if (now.difference(_last) >= interval) { _last = now; return true; } return false; } }

在写入循环里调用throttle.shouldUpdate()来决定是否通知UI。实测下来,500ms刷新一次进度条,视觉上已经很平滑了。

5.2 持久化时机的选择

断点信息的持久化频率是个取舍问题。存的太频繁,磁盘压力大;存的太少,用户中途杀进程,丢失的进度太多。我做过压测,每接收1MB数据同步一次JSON,一个500MB的文件大概会写500次,时间开销在毫秒级别,完全可接受。

如果你对IO敏感,可以改成每5MB写一次,或者每20秒写一次。这个没有绝对标准,根据文件平均大小和用户使用习惯来定。但有一点是硬性的:下载完成一定要清掉断点记录,或者把状态标记为completed,否则下次启动时,任务会莫名其妙地又开始“续传”一个已经完整的文件。

5.3 处理App生命周期中的断点

App进入后台,或者被系统挂起,对正在进行的下载影响很大。Flutter里AppLifecycleListener可以监听应用生命周期状态。我通常在App进入paused状态时自动暂停所有下载任务并保存断点,在resumed状态时恢复符合条件的任务。这里要注意的是,自动恢复要加上网络状态判断,不要在移动数据下自动恢复大文件下载,尽量骗用户确认。

另外,在iOS上纯后台模式的下载支持是比较有限的,除非用BGTaskScheduler,否则App进入后台几秒后网络请求就会被系统挂起。我项目中处理这类情况的方式是:检测到进入后台立即取消请求并保存断点,等回到前台再继续。虽然体验上不如原生App的后台下载那么自然,但至少逻辑是可靠的。

5.4 日志记录对排查问题的巨大帮助

断点续传的本质是状态同步,状态一旦错乱,排查起来非常费劲。我强烈建议在核心节点打印日志,包括发起请求的URL、Range头、服务器返回的状态码、Content-Range、每次持久化的断点信息。这些日志可以放在debug模式下输出,release模式下关闭,避免影响性能。

我在自己的工具库里封装了一个简单的Logger,会输出类似这样的信息:

[Download] start: url=xxx, range=bytes=1048576-, timeout=0 [Download] response: status=206, content-range=bytes 1048576-2097152/5242880 [Download] write: chunk=65536, received=1114112 [Download] persist: received=1114112, total=5242880 [Download] finished: verify=pass, path=xxx

这些日志在线上问题排查里能救命。当用户反馈下载异常时,拿到这些日志,基本能一眼定位到是服务端的问题、断点记录的问题,还是网络的问题。

5.5 关于断点续传的测试心得

断点续传的自动化测试不太好写,因为要模拟“下载到一半网络断开”的场景。我分享一个土办法:搭一个本地HTTP服务,用Flutter代码发起下载,然后用代码主动取消请求再重新开始,反复验证文件和断点记录是否正确。另外也可以用Charles或任意代理工具,在下载过程中直接断开网络连接,观察App的表现。

更完整的测试矩阵包括:下载完成后校验文件哈希、暂停后再恢复、下载过程中杀掉进程再冷启动、服务器端文件在下载过程中被替换、弱网加延时、断点记录文件被篡改。这套测试跑下来,基本能覆盖绝大多数生产环境的异常场景。

6. 写在最后

断点续传下载功能,方案本身不复杂,核心就是Range头、206状态码、文件追加写入这三件事。但真正落地的时候,细节决定体验。服务端兼容性、临时文件策略、断点记录的持久化时机、UI刷新的节流控制,这些问题不亲手做一遍很难全想到。

我这里分享的实现,是多个项目迭代后的稳定版本,你可以直接复用,也可以根据自己的业务场景调整。比如断点记录从JSON改成数据库,任务队列从简单循环改成优先级调度,这些都可以平滑扩展。遇到问题的时候,记得用日志说话,把请求头和响应头打出来看,比漫无目的地猜快得多。

我个人体会最深的一点是:暂停和取消容易被当成同一个操作处理,但它们的意图完全不同。暂停要保存断点,下次能继续;取消则意味着放弃本次下载,下次从零开始。代码里区分好这两个语义,后面的逻辑会清晰很多。

最后再分享一个小技巧:做下载功能时,可以顺手写一个文件哈希校验的小工具,下载完成后自动比对MD5。虽然会多花几秒,但能避免很多“下载完成了但用户说不能打开”的售后问题。这个回报率极高,值得投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询