Flutter 基础体系 · 第 61/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 文件上传:选择、分片、进度、取消、后台和断点续传
文件上传表面上是“把一个文件发给服务器”,但完整的上传系统至少包含以下环节:
- 从 Android、iOS、桌面或 Web 的文件选择器获得文件引用;
- 在应用进程仍然运行时读取文件;
- 通过 HTTP 发送完整文件或文件分片;
- 计算并展示真实进度;
- 支持用户主动取消和网络失败后的重试;
- 在应用进入后台、进程被挂起甚至被杀死时,决定上传是否继续;
- 重新启动后根据服务器已确认的偏移量继续上传,而不是从头开始;
- 在服务器端校验大小、哈希、权限和最终合并结果。
其中,“后台上传”和“断点续传”不是 Flutter 一个 API 就能解决的问题。前者依赖操作系统的后台执行模型,后者依赖客户端与服务器共同定义的上传协议。
一、先区分几个容易混淆的概念
1. 文件选择不等于文件上传
文件选择器只负责让用户选择一个资源,并返回以下信息中的一部分:
- 文件名;
- 文件大小;
- MIME 类型;
- 本地路径;
- 内存中的字节;
- 一个只能在当前生命周期内读取的流。
选择完成后,应用仍然需要打开资源、读取数据并发送 HTTP 请求。
在移动端,返回的路径也不一定是一个可以永久访问的普通文件路径。例如:
- Android 可能返回 Storage Access Framework 提供的 URI;
- iOS 可能返回应用沙盒外部的临时访问路径;
- 用户可能在选择后立即删除或移动文件;
- 桌面端路径通常更直接,但仍可能没有读取权限;
- Web 没有可供 Dart 代码直接使用的本地绝对路径。
因此,上传任务不应只持久化一个字符串路径。可靠的任务记录至少应包含:
任务 ID
文件名
文件大小
文件类型
本地资源引用或复制后的应用私有文件路径
文件指纹
服务器上传会话 ID
服务器已确认偏移量
任务状态
如果文件来自外部临时 URI,应用通常应先把它复制到自己的持久目录,再创建上传任务。否则用户重新打开应用时,原始 URI 可能已经失效。
2. 进度不等于速度,也不等于服务器已经保存的字节数
“进度 50%”至少可能有三种含义:
- 客户端已经从磁盘读了 50%;
- 客户端已经把 50% 的数据交给操作系统网络栈;
- 服务器已经持久化并确认了 50% 的数据。
普通 HTTP 客户端的发送进度通常更接近第二种,而断点续传所依据的必须是第三种。
因此,上传 UI 可以展示客户端发送进度,但恢复任务时必须使用服务器返回的确认偏移量,而不能只相信客户端上次保存的进度。
二、不同平台的文件选择模型
Flutter 本身提供跨平台 UI 和生命周期能力,但文件选择器通常由第三方插件调用各平台原生能力。常见做法是使用 file_picker 或类似插件;具体插件版本应根据当前 Flutter SDK 和平台要求选择。
一个移动端和桌面端的基本选择示例:
import 'package:file_picker/file_picker.dart';
Future<PlatformFile?> pickOneFile() async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
withData: false,
);
if (result == null || result.files.isEmpty) {
return null; // 用户取消选择
}
return result.files.single;
}
使用结果时不能无条件断言 path 非空:
final picked = await pickOneFile();
if (picked == null) {
return;
}
print('name=${picked.name}');
print('size=${picked.size}');
print('path=${picked.path}');
Android
Android 的现代存储模型通常通过系统文档选择器返回 URI。应用不应假设:
- 所有文件都位于公共文件系统目录;
- 可以通过拼接路径访问 URI;
- 应用重启后一定还能访问原始 URI;
- 读取某个路径就一定不需要权限。
如果上传任务需要延迟执行,应在选择成功后立即复制到应用自己的持久目录,或者使用插件提供的持久 URI 授权能力,并验证该能力在目标 Android 版本上的行为。
iOS
iOS 应用只能访问系统授予的资源。通过文档选择器获得的文件可能需要在选择回调期间读取或复制。若任务要在之后继续执行,复制到应用沙盒通常比长期保存外部引用更容易控制。
iOS 的后台网络传输还依赖 URLSession 的 background session。单纯在 Dart isolate 中继续执行,不能保证应用被系统挂起或终止后任务仍然存在。
Windows、macOS 和 Linux
桌面端通常能获得普通文件路径,但仍需处理:
- 文件被其他程序锁定;
- 文件在上传过程中被修改;
- 权限不足;
- 网络盘或可移动磁盘突然断开;
- 路径在应用重启后失效。
上传开始前获取文件大小,并在每次创建任务时保存文件指纹,可以检测文件是否已经发生变化。
Web
Web 端不能使用 dart:io 的 File、File.openRead、Platform.pathSeparator 等 API。浏览器通过 <input type="file"> 暴露 File 对象,通常由插件转换为:
Uint8List;Blob;- 浏览器可读取的流。
Web 还存在几个重要边界:
- 浏览器不会给应用一个可长期使用的本地绝对路径;
- 页面关闭后,普通 JavaScript 上传任务通常会终止;
- CORS 必须允许上传请求及其自定义请求头;
- 断点续传应使用服务器会话,而不是依赖浏览器保留的临时路径;
- 大文件直接读取为
Uint8List可能造成较高内存峰值。
因此,移动端/桌面端的 dart:io 上传实现与 Web 实现通常应通过条件导入或不同的数据源抽象分开。
三、完整上传的数据流
一个支持分片和断点续传的系统可以拆成四个角色:
sequenceDiagram
participant UI as Flutter UI
participant M as UploadManager
participant S as Server
participant D as Disk
UI->>M: 选择文件并创建任务
M->>D: 复制或验证本地文件
M->>S: POST /uploads
S-->>M: uploadId、chunkSize、已确认 offset
loop 每个待上传分片
M->>D: 读取 [offset, end)
M->>S: PATCH/PUT 分片
S-->>M: confirmedOffset
M-->>UI: 更新进度
end
M->>S: POST /uploads/{id}/complete
S-->>M: 文件完成
M-->>UI: completed
这里有一个关键原则:
服务器返回的
confirmedOffset才是客户端恢复时可信的进度。
客户端本地可以保存“最后一次发送到哪里”,但这只是优化信息。请求可能在客户端认为成功后、服务器响应丢失前完成,也可能在客户端发送完成前被服务器拒绝。恢复时必须重新向服务器查询。
四、普通整文件上传与分片上传
1. 整文件上传
整文件上传通常是一个 multipart/form-data 请求:
POST /api/files
Content-Type: multipart/form-data; boundary=...
--boundary
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg
(binary data)
--boundary--
它实现简单,适合:
- 文件较小;
- 上传失败后从头重试可以接受;
- 不需要跨进程、跨应用重启恢复;
- 服务端已经提供标准 multipart 接口。
它的主要问题是:如果一个 500 MiB 文件在 499 MiB 处断网,重新上传通常需要从第 0 字节开始。
2. 分片上传
分片上传将文件拆成连续区间。设:
- 文件总大小为
S; - 分片大小为
C; - 第
i个分片从start_i开始; - 区间右端点使用半开区间
[start_i, end_i)。
则:
start_i = i × C
end_i = min(start_i + C, S)
length = end_i - start_i
分片数量为:
N = ceil(S / C)
例如,文件大小为 10 MiB,分片大小为 4 MiB:
第 0 片:[0, 4 MiB) 长度 4 MiB
第 1 片:[4, 8 MiB) 长度 4 MiB
第 2 片:[8, 10 MiB) 长度 2 MiB
最后一个分片不一定等于固定分片大小,这是实现中最常见的边界错误之一。
3. Content-Range 不是通用上传保证
HTTP 的 Content-Range 常用于描述响应或请求中的字节范围,但服务器是否支持通过它拼接上传,取决于服务器协议。下面这种接口是一个自定义协议示例:
PATCH /api/uploads/u_123
Upload-Offset: 4194304
Content-Length: 4194304
Content-Range: bytes 4194304-8388607/10485760
Content-Type: application/octet-stream
Idempotency-Key: u_123-4194304
(binary chunk)
服务器应当验证:
Upload-Offset是否等于服务器当前已确认偏移量;Content-Length是否等于end - start;Content-Range的总大小是否与创建会话时一致;- 身份是否仍然有效;
- 分片是否超过文件总大小;
- 分片校验和是否正确。
如果服务端使用 Tus、S3 Multipart Upload 或自定义协议,客户端必须遵循对应协议,不能把某个头部名称当成跨服务通用标准。
五、一个可工作的客户端上传核心
下面示例使用 dio 演示移动端和桌面端的分片发送。它假设服务器实现了如下协议:
POST /api/uploads
请求 JSON:name、size、contentType、fileHash
响应 JSON:uploadId、chunkSize、offset
HEAD /api/uploads/{uploadId}
响应头:Upload-Offset
PATCH /api/uploads/{uploadId}
请求头:Upload-Offset、Content-Length、Content-Range
响应头:Upload-Offset
POST /api/uploads/{uploadId}/complete
响应 JSON:fileId、url
PATCH 只是示例,服务器也可能约定使用 PUT。
1. 数据结构
class UploadSession {
final String uploadId;
final int chunkSize;
int confirmedOffset;
UploadSession({
required this.uploadId,
required this.chunkSize,
required this.confirmedOffset,
});
}
2. 分片上传器
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
class UploadProgress {
final int confirmedBytes;
final int totalBytes;
const UploadProgress(this.confirmedBytes, this.totalBytes);
double get fraction {
if (totalBytes <= 0) return 0;
return confirmedBytes / totalBytes;
}
}
class ChunkUploader {
final Dio dio;
final CancelToken cancelToken;
ChunkUploader({
required this.dio,
required this.cancelToken,
});
Future<UploadSession> createSession({
required String name,
required int size,
required String contentType,
String? fileHash,
}) async {
final response = await dio.post<Map<String, dynamic>>(
'/api/uploads',
data: {
'name': name,
'size': size,
'contentType': contentType,
if (fileHash != null) 'fileHash': fileHash,
},
cancelToken: cancelToken,
);
final data = response.data!;
return UploadSession(
uploadId: data['uploadId'] as String,
chunkSize: data['chunkSize'] as int,
confirmedOffset: data['offset'] as int? ?? 0,
);
}
Future<int> queryOffset(String uploadId) async {
final response = await dio.head(
'/api/uploads/$uploadId',
cancelToken: cancelToken,
);
final value = response.headers.value('upload-offset');
if (value == null) {
throw StateError('服务器没有返回 Upload-Offset');
}
return int.parse(value);
}
Future<int> uploadFile({
required File file,
required UploadSession session,
required void Function(UploadProgress progress) onProgress,
}) async {
final total = await file.length();
var offset = session.confirmedOffset;
if (offset < 0 || offset > total) {
throw StateError('服务器 offset 越界:$offset / $total');
}
while (offset < total) {
cancelToken.throwIfCancellationRequested();
final start = offset;
final end = (start + session.chunkSize).clamp(0, total);
final length = end - start;
final stream = file.openRead(start, end);
final response = await dio.patch(
'/api/uploads/${session.uploadId}',
data: stream,
options: Options(
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': length,
'Upload-Offset': start,
'Content-Range': 'bytes $start-${end - 1}/$total',
},
// 某些 Dio/适配器场景下需要显式提供长度。
contentType: 'application/octet-stream',
),
cancelToken: cancelToken,
onSendProgress: (sent, chunkTotal) {
// sent 是当前分片的客户端发送进度,不是服务器确认进度。
final displayed = (start + sent).clamp(0, total);
onProgress(UploadProgress(displayed, total));
},
);
final serverOffsetHeader =
response.headers.value('upload-offset');
if (serverOffsetHeader == null) {
throw StateError('服务器没有返回新的 Upload-Offset');
}
final serverOffset = int.parse(serverOffsetHeader);
// 不接受客户端自行推断的 end,必须以服务端确认值为准。
if (serverOffset < start || serverOffset > end) {
throw StateError(
'服务器返回非法 offset:$serverOffset,期望范围 [$start, $end]',
);
}
if (serverOffset != end) {
// 这表示服务器只确认了部分数据,或者协议支持部分写入。
// 当前示例按严格整片协议处理,避免静默跳过字节。
throw StateError(
'分片未完整确认:confirmed=$serverOffset, expected=$end',
);
}
offset = serverOffset;
session.confirmedOffset = offset;
// 这里的进度才可以作为“服务器已确认进度”持久化。
onProgress(UploadProgress(offset, total));
}
return offset;
}
Future<Map<String, dynamic>> complete({
required String uploadId,
required int totalSize,
}) async {
final response = await dio.post<Map<String, dynamic>>(
'/api/uploads/$uploadId/complete',
data: {'size': totalSize},
cancelToken: cancelToken,
);
return response.data!;
}
}
前置条件是服务器确实实现了上述接口,并且 PATCH 请求体可以被服务器按字节流读取。这个实现按顺序发送分片,因此不会产生多个请求同时写入同一个偏移量的问题。
3. 调用示例
import 'dart:io';
import 'package:dio/dio.dart';
Future<void> startUpload(String path) async {
final dio = Dio(BaseOptions(
baseUrl: 'https://example.com',
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(minutes: 10),
headers: {
'Authorization': 'Bearer <access-token>',
},
));
final cancelToken = CancelToken();
final uploader = ChunkUploader(
dio: dio,
cancelToken: cancelToken,
);
final file = File(path);
final size = await file.length();
final session = await uploader.createSession(
name: file.uri.pathSegments.last,
size: size,
contentType: 'application/octet-stream',
);
try {
await uploader.uploadFile(
file: file,
session: session,
onProgress: (progress) {
final percent = progress.fraction * 100;
print(
'${percent.toStringAsFixed(1)}% '
'(${progress.confirmedBytes}/${progress.totalBytes})',
);
},
);
final result = await uploader.complete(
uploadId: session.uploadId,
totalSize: size,
);
print('上传完成:$result');
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
print('用户取消上传');
} else {
print('网络或服务器错误:${e.message}');
rethrow;
}
}
}
取消操作是:
cancelToken.cancel('user cancelled');
取消的因果关系是:Dio 终止当前请求,并让等待中的 Future 以取消异常结束。它不会自动删除服务器上已经确认的分片,也不会自动删除客户端的任务记录。因此取消后是否允许“继续上传”,应由产品状态模型决定:
cancelled:用户明确取消,不自动恢复;paused:用户暂停,保留会话,下次继续;failed:发生错误,可重试;completed:服务器已经完成合并。
六、进度计算必须区分当前分片和整体任务
当前分片的发送进度为:
chunkSent
当前分片开始前服务器确认的偏移量为:
confirmedBefore
则客户端展示的整体发送进度可以近似计算为:
displayedBytes = confirmedBefore + chunkSent
progress = displayedBytes / totalBytes
但服务器确认进度应使用:
confirmedBytes = serverReturnedOffset
这两者可能短暂不同。比如:
- 分片大小为 4 MiB;
- 客户端回调显示已经发送 4 MiB;
- 网络连接在服务器响应返回前断开;
- 客户端并不知道服务器最终是否完整写入;
- 恢复时服务器返回 0、2 MiB 或 4 MiB 都有可能,取决于服务器的原子性和协议实现。
因此,进度 UI 可以平滑显示 displayedBytes,但持久化恢复点只能在收到服务器确认后更新。
如果并发上传多个分片,进度计算应按每个分片的实际字节数求和:
progress =
(已确认字节数 + 所有正在发送分片的已发送字节数)
/ 文件总字节数
不能简单使用“已完成分片数 / 总分片数”,因为最后一个分片可能更小,而且并发分片可能大小不同。
七、取消、暂停、重试和超时
1. 取消不等于暂停
取消通常表示放弃任务。暂停则表示保留上传会话和本地文件,以便之后继续。
服务端应提供清理接口,例如:
DELETE /api/uploads/u_123
Authorization: Bearer ...
但删除也可能失败,例如客户端已经断网。此时客户端可以把任务标记为“待清理”,在下一次网络可用时再次删除。
2. 重试不能盲目重复写入
假设客户端发送了分片 [4 MiB, 8 MiB),但没有收到响应。直接重试可能导致:
- 服务器再次追加同一分片,文件损坏;
- 服务器返回 offset 冲突;
- 服务器根据幂等键识别为同一个请求;
- 服务器只接受当前 offset,并返回冲突状态。
可靠协议通常采用以下一种或多种机制:
服务器按 offset 严格校验
服务器只接受:
请求 offset == 当前 confirmed offset
如果不相等,返回当前 offset,客户端重新同步。
使用幂等键
例如:
Idempotency-Key: uploadId + "-" + startOffset
服务器对相同幂等键返回相同结果,避免网络响应丢失造成重复处理。
每个分片使用哈希
客户端发送:
Upload-Checksum: sha256 <base64-value>
服务器验证该分片内容,避免传输损坏或读取错误。
3. 重试的边界
连接超时、临时 DNS 失败、HTTP 408、429、502、503、504 通常可以考虑重试,但应使用指数退避:
delay_n = min(maxDelay, baseDelay × 2^n) + randomJitter
其中:
n是第几次重试;baseDelay是初始等待时间;maxDelay防止等待无限增长;randomJitter避免大量客户端同时重试。
HTTP 401、403、413、415、422 通常不是简单重试能解决的:
- 401:令牌过期,需要刷新认证;
- 403:权限不足;
- 413:文件过大;
- 415:媒体类型不被接受;
- 422:文件或分片参数不合法。
上传请求的超时不能设置得过短。一个大分片在弱网下可能需要较长时间;但也不能无限等待,否则任务无法检测网络已经失效。实际值应依据分片大小、网络环境和服务端代理超时配置共同决定。
八、断点续传的协议和状态机
1. 创建上传会话
不要直接把“文件路径”当作服务端任务 ID。服务端应先创建会话:
POST /api/uploads
Content-Type: application/json
{
"name": "video.mp4",
"size": 10485760,
"contentType": "video/mp4",
"fileHash": "optional-sha256"
}
响应:
{
"uploadId": "u_123",
"chunkSize": 4194304,
"offset": 0,
"expiresAt": "2026-01-01T00:00:00Z"
}
服务端可以在这里完成:
- 用户权限校验;
- 文件大小限制;
- 配额检查;
- 分片大小协商;
- 创建临时对象;
- 判断是否存在同哈希文件;
- 返回已有上传会话。
2. 查询服务器进度
应用重启后,流程不应是“读取本地 lastOffset 然后直接上传”,而应是:
读取本地任务
↓
验证本地文件仍然存在且大小未变
↓
HEAD /api/uploads/{uploadId}
↓
使用服务器 offset 覆盖本地 offset
↓
从服务器 offset 继续发送
如果服务器返回 404,可能表示:
- 会话已经过期;
- 服务端临时对象被清理;
- 用户无权访问该会话;
- uploadId 本身错误。
此时不能把 404 当作 offset 为 0;应该重新创建会话,或者提示用户重新开始。
3. 完成上传
所有分片发送完毕后,还需要显式完成:
POST /api/uploads/u_123/complete
Content-Type: application/json
{
"size": 10485760
}
服务器在 complete 阶段应验证:
- 已确认字节数等于声明大小;
- 所有必要分片都存在;
- 文件整体哈希匹配;
- 临时对象已成功合并;
- 文件记录已经从临时状态切换为可用状态。
只有 complete 成功,客户端才应把任务标记为 completed。最后一个分片返回成功,不等于最终文件已经发布。
状态机可以表示为:
stateDiagram-v2
[*] --> selected
selected --> preparing: 复制/验证文件
preparing --> creating: 创建上传会话
creating --> uploading
uploading --> paused: 用户暂停
uploading --> cancelling: 用户取消
uploading --> retryWait: 可重试错误
uploading --> failed: 不可重试错误
retryWait --> syncing: 查询服务器 offset
syncing --> uploading
paused --> syncing: 恢复任务
cancelling --> cancelled
uploading --> completing: 全部分片已确认
completing --> completed
completing --> failed
failed --> syncing: 用户重试
completed --> [*]
cancelled --> [*]
这个状态机避免了把所有异常都简化成一个布尔值 isUploading。例如,paused 与 failed 的恢复策略不同,cancelling 期间也不应再次启动新的分片请求。
九、服务器端必须防御的错误
客户端分片逻辑正确,并不意味着服务器一定安全。服务器至少需要处理以下情况:
1. 不信任客户端文件名和类型
客户端传来的文件名、扩展名和 MIME 类型都可以被伪造。服务器应:
- 对文件名进行编码和过滤;
- 不把原始文件名直接拼接到磁盘路径;
- 使用服务端生成的对象键;
- 根据文件内容或安全扫描结果判断真实类型;
- 限制压缩炸弹、脚本文件和危险格式。
2. 防止 offset 越界和覆盖
请求中的:
start < 0
end <= start
end > total
Content-Length != end - start
都应拒绝。
如果服务器允许并发分片,必须为每个分片编号或范围建立唯一索引,避免两个请求同时写同一范围。简单的顺序协议则可以只接受当前 offset,逻辑更容易验证。
3. 防止会话盗用
uploadId 不能单独充当授权凭证。服务器应把上传会话绑定到:
- 用户或租户;
- 创建者;
- 目标文件类型;
- 声明大小;
- 过期时间;
- 必要时绑定设备或一次性令牌。
每个分片请求都应重新做权限校验,不能只在创建会话时验证一次。
4. 临时文件清理
网络中断后,服务器可能留下永远不会完成的临时上传。服务端应通过过期时间和定时清理任务删除:
- 过期会话;
- 长期没有进度的会话;
- 已取消会话;
- 合并失败的临时对象。
客户端的 DELETE 是主动清理,服务端过期清理是最终兜底,两者不能互相替代。
十、真正的后台上传:Flutter 进程被挂起后会发生什么
1. 前台 Dart 上传
最简单的情况是:
Flutter 页面可见
→ Dart isolate 正在运行
→ dart:io 或 HTTP 客户端发送数据
此时可以使用 CancelToken、进度回调和 AppLifecycleState 更新 UI。
但当应用进入后台时,操作系统可能:
- 暂停 Dart isolate;
- 限制网络活动;
- 回收应用进程;
- 在低电量或资源紧张时终止任务。
所以“监听 paused 后继续 await 上传 Future”并不能保证后台上传。
2. Android
Android 后台执行通常需要结合系统组件:
WorkManager:适合可延迟、可重试、受约束的后台任务;- 前台服务:适合用户明确可见的长时间任务,需要通知栏和相应权限;
- 原生网络库:在服务或原生任务中执行实际上传。
如果 Flutter engine 或 Dart isolate 被销毁,原先的 Dart Future 不会神奇地继续运行。后台任务需要由原生组件持有,并在需要时重新初始化 Flutter 或直接使用原生 HTTP 客户端。
Android 还可能受到:
- Doze;
- 电池优化;
- 后台数据限制;
- 厂商自定义进程回收策略;
- 前台服务类型和通知要求;
的影响。不能只在模拟器锁屏后测试一次就认为后台上传可靠。
3. iOS
iOS 的后台网络传输通常应使用原生 URLSession background configuration。其特点是:
- 系统可以在应用进程不运行时继续处理部分网络任务;
- 完成后由系统唤醒应用处理事件;
- 任务生命周期由系统调度;
- 不等价于让 Dart isolate 在后台无限运行。
因此,如果产品要求“应用退到后台甚至被系统终止后仍继续上传”,通常需要:
- Flutter 层创建上传任务并持久化元数据;
- 通过插件桥接给原生
URLSession; - 原生层接收系统回调;
- 应用重新启动或被唤醒后,把原生状态同步回 Flutter;
- 对无法由系统后台传输完成的分片,重新通过断点协议恢复。
iOS 不保证任意时长、任意网络条件下的后台执行。后台传输是系统管理的能力,不是应用获得无限 CPU 和网络时间。
4. 桌面端
桌面应用通常没有移动端那么严格的后台限制,但仍可能遇到:
- 用户关闭窗口后进程退出;
- 操作系统休眠;
- 笔记本网络切换;
- 文件系统被卸载。
如果要支持关闭 UI 后继续上传,应把上传管理器放到独立进程、系统服务或桌面平台的后台任务中,并持久化任务状态。
5. Web
Web 页面关闭后,普通上传请求通常会中止。Service Worker、Background Sync 和浏览器后台能力受浏览器、权限、请求类型和平台限制,不能视为与 Android/iOS 后台服务等价。
Web 端比较可靠的策略通常是:
- 使用分片协议;
- 每个分片尽快完成;
- 每完成一个分片就保存服务器 offset;
- 页面重新打开后查询并继续;
- 不承诺页面关闭后一定继续上传。
十一、进程重启后的恢复算法
可以把恢复过程写成明确的算法:
输入:
本地任务 T
本地文件 F
服务器上传会话 U
1. 检查 F 是否存在。
2. 读取 F 的当前大小 size_now。
3. 如果 size_now != T.expectedSize:
将任务标记为 invalidFile,不继续上传。
4. 使用 U 查询 serverOffset。
5. 如果 U 不存在或已过期:
创建新的 U,serverOffset = 0。
6. offset = serverOffset。
7. while offset < size_now:
end = min(offset + chunkSize, size_now)
读取 F[offset, end)
发送分片
服务器确认新的 offset
只有确认成功后才持久化 offset
8. 请求 complete。
9. complete 成功后标记 completed。
其中第 3 步不能省略。假设用户在第一次上传后修改了文件,但客户端仍沿用旧会话:
旧文件:A B C D
新文件:A B X Y
服务器已有:A B
客户端从 offset=2 继续发送新文件的 X Y
这次上传可能看起来“成功”,但服务器得到的是混合内容。解决办法包括:
- 任务创建时计算文件哈希;
- 至少记录大小、修改时间和抽样哈希;
- 上传前重新校验;
- 服务端在 complete 时校验整体哈希。
对大型文件,完整 SHA-256 会增加读取开销,但它提供了强一致性判断。只记录修改时间和大小属于经验性优化,不是内容未变化的数学保证。
十二、分片大小与内存使用
使用 File.openRead(start, end) 的好处是可以按分片读取,而不是一次把整个文件读入内存:
final stream = file.openRead(start, end);
理论上的应用内存占用主要由以下部分构成:
应用内存 ≈ HTTP 缓冲区
+ 当前分片缓冲区
+ TLS/系统网络缓冲区
+ UI 和业务对象
如果使用:
final bytes = await file.readAsBytes();
则至少需要把整个文件加载到内存,文件较大时可能触发内存压力甚至进程终止。
分片大小没有跨平台固定答案:
- 太小:请求数量多,请求头和握手开销增大;
- 太大:单个请求失败后的重传成本增加,内存和超时压力增大;
- 移动网络:通常更重视可恢复性;
- 稳定 Wi-Fi 或桌面网络:可以使用更大的分片;
- 服务端代理和网关:可能对单请求大小、超时和请求体缓冲有上限。
因此,分片大小最好由服务器协商或由配置统一控制,而不是把一个数字硬编码在所有平台。
十三、并发分片上传的收益与代价
顺序上传的逻辑是:
上传 0 → 上传 1 → 上传 2 → ...
它最容易保证 offset 连续性,但在高延迟网络中可能没有充分利用带宽。
并发上传可以同时发送多个不重叠区间:
请求 A:[0, 4 MiB)
请求 B:[4 MiB, 8 MiB)
请求 C:[8 MiB, 12 MiB)
此时服务器不能只维护一个简单的“当前 offset”,而应维护:
uploadId → {
chunkIndex → received/hash/status
}
完成时再验证所有分片是否齐全并按顺序合并。
并发还会带来:
- 更多内存和 socket;
- 更复杂的取消;
- 进度回调乱序;
- 同一任务的多个请求同时失败;
- 移动网络下更高的功耗;
- 服务端存储和锁竞争。
因此,断点续传的第一版通常先实现顺序分片;只有在协议、监控和服务端并发合并逻辑明确后,再增加有限并发。
十四、哈希、去重与完整性校验
分片校验和整体哈希解决的是不同问题。
分片哈希
分片上传前计算:
hash_i = SHA-256(chunk_i)
服务器收到分片后验证 hash_i,可以发现当前分片在传输或读取过程中损坏。
整体哈希
对整个文件计算:
fileHash = SHA-256(file)
服务器在合并后验证最终内容,能够发现:
- 客户端文件在上传过程中被修改;
- 分片顺序错误;
- 某个分片重复;
- 某个分片缺失;
- 服务端合并逻辑错误。
哈希不能替代权限校验,也不能证明文件内容安全。恶意用户可以主动计算任意恶意文件的哈希,所以病毒扫描、内容类型检查和访问控制仍然需要独立完成。
十五、常见错误与诊断路径
错误一:只保存本地进度,不查询服务器
表现: 应用重启后出现重复数据、offset 冲突或最终文件损坏。
原因: 本地记录的是“请求发出到哪里”,不是“服务器确认到哪里”。
诊断:
记录每次请求:
uploadId
start
end
HTTP 状态码
响应 Upload-Offset
本地持久化 offset
比较这些值即可判断是客户端状态错误还是服务器确认错误。
错误二:把最后一个分片也按固定大小发送
表现: 服务端报告请求体长度不匹配、文件多出零字节或 complete 失败。
原因: 使用了:
end = start + chunkSize;
而没有限制 end <= total。
正确计算是:
final end = (start + chunkSize).clamp(0, total);
并且 HTTP 区间的最后一个字节应写成 end - 1,因为内存读取区间是右开区间 [start, end)。
错误三:把 onSendProgress 当成服务器确认
表现: UI 显示 100%,但恢复后服务器只确认到 80%。
原因: 发送进度回调反映的是客户端发送过程,响应确认尚未到达。
处理: 用发送进度更新临时 UI,用服务端返回的 offset 更新可恢复状态。
错误四:只测试前台,不测试进程被杀死
表现: 开发环境正常,真实设备锁屏、切后台或系统回收后任务丢失。
原因: 前台 Dart Future 不能代表系统级后台任务。
诊断:
- Android 测试强制停止、锁屏、开启省电模式和切换网络;
- iOS 测试挂起、系统回收和重新唤醒;
- 桌面测试关闭窗口和系统休眠;
- Web 测试刷新、关闭标签页和重新打开页面。
错误五:没有检测本地文件变化
表现: 分片上传成功但服务端文件内容不正确。
原因: 文件在任务创建后被修改,客户端仍从旧 offset 继续。
处理: 至少比较大小;对高价值文件使用整体哈希或服务端完成阶段的哈希验证。
错误六:服务器返回 200 就直接认为文件可用
表现: 客户端显示完成,但下载文件不存在或内容不完整。
原因: 分片接收成功与最终对象发布是两个状态。
处理: 将 complete 作为独立的服务端事务步骤,并只根据 complete 成功结果标记完成。
十六、如何组织 Flutter 代码
上传功能不应直接写在页面的 onPressed 中。一个可维护的分层可以是:
UploadPage
↓ 观察状态、触发暂停/取消
UploadController
↓ 管理状态机、生命周期和错误
UploadManager
↓ 管理队列、并发数、持久化
UploadRepository
↓ 调用 HTTP 或原生后台上传接口
FileSource
↓ File.openRead、Web Blob、原生 URI
Server Upload Protocol
FileSource 的抽象尤其重要,因为移动端/桌面端和 Web 的输入不同。例如:
abstract interface class ByteRangeSource {
int get length;
Stream<List<int>> openRead(int start, int end);
}
dart:io File 可以实现这个接口;Web 则应使用浏览器提供的 Blob 切片或插件适配层实现。这样分片算法不必依赖 dart:io。
任务状态应持久化到 SQLite、文件数据库或其他可靠存储,而不是只放在内存中的 Provider、Bloc 或 StateNotifier 里。UI 状态可以丢失,上传任务状态不能因为页面销毁而丢失。
十七、整文件上传、分片上传和原生后台的取舍
可以按需求选择实现:
只需要上传小文件
使用 multipart 整文件上传即可。实现成本低,服务端接口也简单。
需要显示进度和取消
使用支持发送进度与取消令牌的 HTTP 客户端,并在前台维护任务状态。仍然要处理请求失败和认证过期。
需要弱网恢复或大文件上传
使用服务端上传会话、分片、offset 查询、幂等重试和 complete 步骤。断点续传的核心在协议,不在 Flutter UI。
需要应用退后台后继续
Android、iOS 分别接入系统后台执行能力,并让原生任务与服务端断点协议结合。Flutter 层只负责创建、观察和恢复任务。
需要 Web 支持
避免依赖本地路径,使用浏览器文件对象和分片协议;明确告知用户页面关闭后任务可能暂停,重新打开后通过 uploadId 继续。
最终可以用一句话概括这些机制之间的关系:
文件选择解决“数据从哪里来”
分片解决“失败后从哪里重试”
进度解决“当前传了多少”
取消解决“是否主动停止”
后台解决“应用不在前台时谁继续执行”
断点续传解决“进程或网络中断后如何恢复”
服务器协议解决“恢复点和最终文件是否可信”
只实现其中一个环节,并不能自动获得完整的可靠上传能力。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 安全存储:Keychain、Keystore、密钥、备份和设备迁移
- 下一篇:Flutter 相机与媒体:权限、生命周期、编码、预览和资源释放
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论