Flutter 基础体系 · 第 61/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 文件上传:选择、分片、进度、取消、后台和断点续传

文件上传表面上是“把一个文件发给服务器”,但完整的上传系统至少包含以下环节:

  1. 从 Android、iOS、桌面或 Web 的文件选择器获得文件引用;
  2. 在应用进程仍然运行时读取文件;
  3. 通过 HTTP 发送完整文件或文件分片;
  4. 计算并展示真实进度;
  5. 支持用户主动取消和网络失败后的重试;
  6. 在应用进入后台、进程被挂起甚至被杀死时,决定上传是否继续;
  7. 重新启动后根据服务器已确认的偏移量继续上传,而不是从头开始;
  8. 在服务器端校验大小、哈希、权限和最终合并结果。

其中,“后台上传”和“断点续传”不是 Flutter 一个 API 就能解决的问题。前者依赖操作系统的后台执行模型,后者依赖客户端与服务器共同定义的上传协议。


一、先区分几个容易混淆的概念

1. 文件选择不等于文件上传

文件选择器只负责让用户选择一个资源,并返回以下信息中的一部分:

  • 文件名;
  • 文件大小;
  • MIME 类型;
  • 本地路径;
  • 内存中的字节;
  • 一个只能在当前生命周期内读取的流。

选择完成后,应用仍然需要打开资源、读取数据并发送 HTTP 请求。

在移动端,返回的路径也不一定是一个可以永久访问的普通文件路径。例如:

  • Android 可能返回 Storage Access Framework 提供的 URI;
  • iOS 可能返回应用沙盒外部的临时访问路径;
  • 用户可能在选择后立即删除或移动文件;
  • 桌面端路径通常更直接,但仍可能没有读取权限;
  • Web 没有可供 Dart 代码直接使用的本地绝对路径。

因此,上传任务不应只持久化一个字符串路径。可靠的任务记录至少应包含:

任务 ID
文件名
文件大小
文件类型
本地资源引用或复制后的应用私有文件路径
文件指纹
服务器上传会话 ID
服务器已确认偏移量
任务状态

如果文件来自外部临时 URI,应用通常应先把它复制到自己的持久目录,再创建上传任务。否则用户重新打开应用时,原始 URI 可能已经失效。

2. 进度不等于速度,也不等于服务器已经保存的字节数

“进度 50%”至少可能有三种含义:

  1. 客户端已经从磁盘读了 50%;
  2. 客户端已经把 50% 的数据交给操作系统网络栈;
  3. 服务器已经持久化并确认了 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:ioFileFile.openReadPlatform.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)

服务器应当验证:

  1. Upload-Offset 是否等于服务器当前已确认偏移量;
  2. Content-Length 是否等于 end - start
  3. Content-Range 的总大小是否与创建会话时一致;
  4. 身份是否仍然有效;
  5. 分片是否超过文件总大小;
  6. 分片校验和是否正确。

如果服务端使用 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

这两者可能短暂不同。比如:

  1. 分片大小为 4 MiB;
  2. 客户端回调显示已经发送 4 MiB;
  3. 网络连接在服务器响应返回前断开;
  4. 客户端并不知道服务器最终是否完整写入;
  5. 恢复时服务器返回 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 阶段应验证:

  1. 已确认字节数等于声明大小;
  2. 所有必要分片都存在;
  3. 文件整体哈希匹配;
  4. 临时对象已成功合并;
  5. 文件记录已经从临时状态切换为可用状态。

只有 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。例如,pausedfailed 的恢复策略不同,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 在后台无限运行。

因此,如果产品要求“应用退到后台甚至被系统终止后仍继续上传”,通常需要:

  1. Flutter 层创建上传任务并持久化元数据;
  2. 通过插件桥接给原生 URLSession
  3. 原生层接收系统回调;
  4. 应用重新启动或被唤醒后,把原生状态同步回 Flutter;
  5. 对无法由系统后台传输完成的分片,重新通过断点协议恢复。

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 官方文档重新梳理;正文与示例由 WR BLOG 编写。