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

Flutter 后台任务:平台限制、调度、网络、续传和省电

Flutter 中的“后台任务”不是一种统一能力。它至少包含四个不同问题:

  1. 进程是否仍然存在:应用切到后台后,Flutter Engine 和 Dart isolate 是否还活着。
  2. 操作系统是否允许执行:Android、iOS、桌面和 Web 对后台执行有不同规则。
  3. 网络操作如何完成:任务运行时如何请求、超时、重试和处理网络变化。
  4. 任务中断后如何恢复:进程被杀死、设备重启、网络断开或应用崩溃后,如何从已完成的位置继续。

因此,下面这句话通常是不准确的:

“启动一个 Dart isolate,就可以让 Flutter 在后台持续下载。”

Dart isolate 只能解决 Dart 代码之间的并发和隔离问题,不能阻止操作系统暂停或终止应用进程。真正可靠的后台任务必须同时设计 操作系统调度、持久化状态、可恢复协议和功耗策略


1. 先区分四种“后台”

1.1 前台异步任务

用户正在使用应用时,Flutter 页面中发起的网络请求属于前台任务。例如:

final response = await httpClient.getUrl(uri);

await 不会阻塞 UI isolate,但任务仍然依赖当前应用进程。应用被系统杀死后,请求也会消失。

这类任务适合:

  • 页面加载;
  • 用户主动点击的上传或下载;
  • 需要立即显示进度的交互;
  • 持续时间较短的网络操作。

它不保证应用退到后台后继续执行。

1.2 后台 isolate

Dart isolate 是独立的 Dart 执行环境,拥有自己的堆和事件循环。不同 isolate 不能直接共享普通对象,只能通过 SendPortReceivePort 传递消息。

后台 isolate 可以避免 CPU 密集计算阻塞 UI isolate,例如解析大量 JSON、计算哈希或压缩文件:

final result = await Isolate.run(() {
  // 在另一个 isolate 中计算
  return expensiveCalculation();
});

但它有三个重要边界:

  • isolate 仍然运行在应用进程中;
  • 应用进程被操作系统终止时,所有 isolate 都会终止;
  • isolate 本身不会获得 Android 或 iOS 的后台执行资格。

所以 isolate 是应用内并发工具,不是操作系统级后台任务工具

1.3 操作系统托管的后台任务

操作系统托管的后台任务由 Android 或 iOS 负责何时启动、暂停和终止应用代码。应用只能提交约束和任务意图,不能保证精确执行时间。

典型用途:

  • 定期同步;
  • 上传日志;
  • 清理缓存;
  • 在满足网络、电量条件时刷新数据;
  • 用户离开应用后继续传输文件。

1.4 操作系统托管的后台传输

文件上传和下载有时不应由 Dart 代码直接持有整个网络生命周期,而应交给操作系统的网络传输服务。

例如:

  • iOS 的后台 URLSession 传输;
  • Android 中结合系统调度、前台服务或专用下载组件实现的长时间传输。

这种方案的特点是:网络传输可以在应用 UI 不存在时继续,完成后操作系统再唤醒或通知应用处理结果。它与“Flutter isolate 继续运行”是两种不同模型。


2. Flutter、Dart 和操作系统分别负责什么

一个后台任务通常包含以下组件:

flowchart LR
    UI[Flutter UI isolate]
    BG[Dart background isolate]
    BR[平台桥接层]
    OS[Android/iOS 调度器或传输服务]
    NET[网络服务器]
    DB[本地持久化状态]

    UI -->|创建任务| DB
    UI -->|提交平台任务| BR
    BR --> OS
    OS -->|启动或恢复| BR
    BR --> BG
    BG -->|读取状态| DB
    BG -->|HTTP 请求| NET
    BG -->|写入进度| DB
    OS -->|后台传输回调| BR
    BR --> UI

各层的责任不同:

  • Flutter UI isolate:展示状态、接受用户操作、取消任务。
  • Dart isolate:执行 Dart 业务代码,例如读取数据库、构造请求、校验文件。
  • 平台桥接层:通过插件或自定义 Platform Channel 调用 Android/iOS API。
  • 操作系统调度器:决定什么时候允许运行,并施加时间、电量、网络和后台限制。
  • 网络服务器:是否支持断点续传、幂等请求、认证续期和校验。
  • 本地持久化状态:保存任务状态、文件偏移、ETag、重试次数和错误原因。

一个可靠的设计不能只写 Dart 代码,因为 Dart 代码无法决定操作系统何时重新启动进程,也无法替服务器保证支持 Range 请求。


3. 生命周期:应用退到后台后究竟发生什么

应用进入后台通常会经历以下状态,但这些状态不是所有平台的严格统一协议:

stateDiagram-v2
    [*] --> Foreground
    Foreground --> BackgroundVisible: 用户切换应用
    BackgroundVisible --> Suspended: 系统挂起进程
    BackgroundVisible --> Terminated: 系统杀死进程
    Suspended --> Foreground: 用户返回
    Suspended --> Terminated: 内存回收或系统终止
    Terminated --> StartedByOS: 调度器或后台传输唤醒
    Terminated --> Foreground: 用户重新打开
    StartedByOS --> Suspended: 后台时间用尽
    StartedByOS --> Terminated: 任务完成或进程被终止

关键点是:

  • 后台不等于终止:进程可能暂时仍在运行。
  • 仍在运行不等于可持续运行:系统可能随时暂停或杀死进程。
  • 被杀死后不能依赖内存状态:队列、进度和重试次数必须已经写入持久存储。
  • 重新启动时不能假设上次代码执行到了哪一行:任务必须设计为可重复执行和可恢复。

因此,后台任务的状态应当存储为显式状态机,而不是只保存在一个 Dart 对象中。


4. 用状态机设计可恢复任务

假设任务是下载一个文件,可以定义如下状态:

queued       已创建,等待调度
running      正在传输
paused       主动暂停或暂时不满足条件
retry_wait   等待下一次重试
completed    已完成并通过校验
failed       不可恢复失败
cancelled    用户取消

状态转移不能只由“请求是否返回”决定。例如:

stateDiagram-v2
    [*] --> queued
    queued --> running: 调度器允许执行
    running --> completed: 文件校验成功
    running --> retry_wait: 超时或临时网络错误
    retry_wait --> running: 到达重试时间且条件满足
    running --> paused: 用户暂停或省电策略
    paused --> queued: 用户恢复
    running --> failed: 认证失败或协议错误
    running --> cancelled: 用户取消
    completed --> [*]
    failed --> [*]
    cancelled --> [*]

每次状态变化都应持久化至少以下字段:

{
  "id": "asset-42",
  "url": "https://example.com/assets/video.mp4",
  "tempPath": "/data/user/0/app/files/asset-42.part",
  "finalPath": "/data/user/0/app/files/asset-42.mp4",
  "bytesWritten": 5242880,
  "totalBytes": 10485760,
  "etag": "\"abc123\"",
  "state": "retry_wait",
  "retryCount": 2,
  "nextAttemptAt": "2025-01-01T12:05:00Z"
}

这里的 bytesWritten 不能只在内存中累加。更稳妥的顺序是:

  1. 将网络数据写入临时文件;
  2. 文件写入成功后获得实际文件长度;
  3. 将实际长度写入任务数据库;
  4. 定期或在关键阶段更新校验信息;
  5. 完成后将临时文件原子地改名为正式文件。

如果第 2 步和第 3 步之间进程崩溃,重启时应以临时文件的实际长度为准,并重新校正数据库中的偏移量。


5. Android 的后台限制与调度

5.1 普通后台进程不适合长期运行

Android 会根据内存压力、后台限制、Doze、省电模式和厂商策略暂停或终止应用。应用切到后台后,普通 Dart 线程、isolate 和网络请求都不能获得永久运行保证。

Android 上常见的能力边界如下:

需求 常见 Android 机制 特点
延迟或周期同步 WorkManager 由系统择机执行,不能保证精确时间
短时间后台工作 JobScheduler 或 WorkManager 受系统配额和约束影响
用户可感知的持续任务 Foreground Service 通常需要持续通知,并遵守服务类型及权限要求
大文件下载 系统下载能力、前台服务或专用实现 重点是进程存活、通知和断点续传
精确闹钟 AlarmManager 有严格权限和使用场景限制,不应当用于普通同步

WorkManager 更适合“最终要执行一次”的可延迟任务,而不是“每隔五分钟精确执行一次”。系统可能合并任务、推迟任务,或者在电量、网络条件不满足时不启动。

5.2 WorkManager 的语义不是定时器

如果要求“每天 8 点准时同步”,WorkManager 并不能提供这种实时保证。更准确的模型是:

提交约束:
    网络可用
    设备未处于低电量状态
    任务允许延迟

系统行为:
    在未来某个合适时间启动
    任务执行一段时间
    任务可能被停止
    之后可能再次启动

因此,任务函数必须满足:

  • 可重复执行;
  • 能从数据库状态恢复;
  • 被停止时不破坏临时文件;
  • 返回可重试或不可重试结果;
  • 不依赖某个 isolate 永远存在。

5.3 Foreground Service 不是“绕过限制”

Android 前台服务适合用户明确知道正在进行的长任务,例如导航、持续播放、文件传输。它通常需要:

  • 显示持续通知;
  • 声明符合场景的前台服务类型;
  • 根据 Android 版本声明相应权限;
  • 处理用户从通知或系统设置中停止服务的情况。

前台服务提高了任务存活机会,但也不是绝对保证。它仍可能因系统策略、权限变化、应用崩溃或用户强制停止而终止。

如果任务只是后台刷新少量数据,却启动前台服务,通常会带来通知打扰、功耗增加和合规风险。应优先使用系统调度器。


6. iOS 的后台执行与传输

iOS 对后台执行的限制通常比 Android 更严格。普通应用退到后台后,系统可能很快挂起应用;挂起后 Dart isolate 不再继续运行。

6.1 后台刷新和后台处理

iOS 提供后台刷新、后台处理等系统调度能力。应用可以提交任务,并声明网络、电量等条件,但不能控制精确执行时间。

系统可能因为以下原因推迟任务:

  • 用户很少打开应用;
  • 设备电量不足;
  • 最近任务执行时间过长;
  • 网络或系统资源不合适;
  • 用户关闭了后台 App 刷新;
  • 用户从任务切换器强制结束应用。

后台刷新适合短小的数据刷新。后台处理适合系统认为可以延迟的较重工作,但仍受时间和资源限制。

6.2 URLSession 后台传输

iOS 的后台 URLSession 传输由系统网络栈管理,适合文件上传和下载。其关键优势是:传输任务的生命周期不必完全依赖 Flutter Engine 是否存活。

典型流程是:

  1. Flutter 创建下载任务;
  2. 原生 iOS 层使用后台配置创建 URLSession
  3. 系统在后台执行下载;
  4. 下载完成或发生错误;
  5. 系统唤醒应用或调用指定的生命周期入口;
  6. 原生层处理回调;
  7. Flutter 页面重新打开后从持久化状态读取结果。

这不是普通 Dart HttpClient 请求的自动升级版。要使用它,需要原生 iOS 配置、后台 session 标识、任务恢复和 Flutter 与原生层之间的状态同步。插件能否完整支持这些能力,取决于插件实现,不能仅凭“支持后台下载”的名称判断。

6.3 iOS 的特殊边界

以下行为不能当作可靠方案:

  • Timer.periodic 期待应用挂起后继续触发;
  • 用后台 isolate 保证长期执行;
  • 在应用进入后台时启动一个无限循环;
  • 依赖用户不关闭应用来维持下载;
  • 把后台刷新当作精确定时器。

如果用户主动从任务切换器强制结束应用,许多后台执行和回调路径都会受到影响。生产设计必须允许任务延迟、丢失唤醒机会并在下次用户打开应用时恢复。


7. 桌面和 Web 的差异

7.1 桌面平台

Windows、macOS 和 Linux 上,Flutter 应用通常是普通桌面进程。只要进程仍在运行,Dart 异步任务和 isolate 可以继续执行;但计算机睡眠、用户退出应用、系统关机或进程崩溃时,任务仍会中断。

桌面应用如果需要真正的后台常驻,通常要使用平台服务:

  • Windows Service;
  • macOS LaunchAgent 或 LaunchDaemon;
  • Linux systemd service。

这类能力不是 Flutter 框架自动提供的,通常需要原生代码、安装器配置以及服务升级和卸载逻辑。

7.2 Web

Flutter Web 运行在浏览器页面中,受浏览器生命周期和同源策略限制。页面切到后台后,浏览器可能降低定时器频率、暂停页面或回收资源。

Service Worker 可以处理部分缓存、推送和后台事件,但它不是一个可随意运行的永久 Dart 后台进程。还要考虑:

  • 浏览器对后台执行时间的限制;
  • 用户关闭标签页后的行为;
  • Service Worker 生命周期短且由浏览器管理;
  • 跨域请求、CORS 和认证 Cookie;
  • 浏览器无法像移动原生应用一样访问任意文件系统路径。

因此,“后台下载到用户指定路径”在 Web 上通常受浏览器下载模型限制,不能直接套用 Android 或 iOS 方案。


8. 网络请求与后台任务的正确边界

后台任务最容易失败的地方不是 await,而是网络假设。

网络请求可能遇到:

  • DNS 失败;
  • TCP 连接超时;
  • TLS 握手失败;
  • HTTP 认证过期;
  • 服务端返回 429;
  • 服务端返回 5xx;
  • 网络切换导致连接中断;
  • 连接成功但响应体传输中断;
  • 文件下载完成但本地写入失败。

应先区分错误类型:

错误 通常处理
DNS、连接超时、连接重置 延迟重试
HTTP 408、429、部分 5xx 延迟重试,遵守 Retry-After
HTTP 401 刷新凭证或要求重新登录
HTTP 403、404 通常不可盲目重试
内容类型、文件长度、签名校验失败 标记协议或数据错误
本地磁盘空间不足 暂停并通知用户
用户取消 进入 cancelled,不自动重试

重试次数不能代替幂等设计。上传请求若没有幂等键,客户端不知道请求是否已经在服务器端成功,就可能重复创建资源。

一个常见的指数退避模型是:

dn=min(dmax,d0×2n)+Jd_n = \min(d_{\max}, d_0 \times 2^n) + J

其中:

  • dnd_n 是第 nn 次重试前的等待时间;
  • d0d_0 是初始等待时间;
  • dmaxd_{\max} 是最大等待时间;
  • JJ 是随机抖动;
  • nn 从 0 开始计数。

例如取 d0=2d_0=2 秒、dmax=60d_{\max}=60 秒:

第 1 次:2 秒 + 随机抖动
第 2 次:4 秒 + 随机抖动
第 3 次:8 秒 + 随机抖动
第 4 次:16 秒 + 随机抖动
后续:最多 60 秒 + 随机抖动

随机抖动的作用是避免大量设备同时恢复网络后形成请求洪峰。


9. HTTP 断点续传的协议条件

“续传”不是客户端保存了文件长度就自动成立。它至少需要服务器正确支持以下 HTTP 行为:

  1. 客户端使用 Range: bytes=N- 请求剩余内容;
  2. 服务器对有效范围返回 206 Partial Content
  3. 返回 Content-Range: bytes N-M/T
  4. 服务器资源没有在续传期间发生变化;
  5. 客户端校验最终文件长度或摘要。

如果服务器忽略 Range 并返回 200 OK,客户端不能把完整响应追加到已有临时文件,否则文件会被拼接成错误内容。

如果资源可能变化,客户端应保存 ETagLast-Modified,续传时发送:

Range: bytes=5242880-
If-Range: "abc123"

当资源仍匹配时,服务器可以返回剩余部分;资源已变化时,服务器应返回完整新资源,客户端需要从头开始。


10. 一个 Dart 续传下载示例

下面的代码演示核心协议流程,适用于支持 dart:io 的 Flutter 原生移动端和桌面端。它不适用于 Flutter Web,因为 Web 没有 dart:io 文件 API。

import 'dart:io';

Future<void> downloadResumable({
  required Uri uri,
  required File tempFile,
  String? etag,
}) async {
  final client = HttpClient()
    ..connectionTimeout = const Duration(seconds: 15);

  try {
    final currentLength =
        await tempFile.exists() ? await tempFile.length() : 0;

    final request = await client.getUrl(uri);

    if (currentLength > 0) {
      request.headers.set(
        HttpHeaders.rangeHeader,
        'bytes=$currentLength-',
      );

      if (etag != null && etag.isNotEmpty) {
        request.headers.set(HttpHeaders.ifRangeHeader, etag);
      }
    }

    final response = await request.close();

    // 有本地临时文件,但服务器没有按 Range 返回。
    // 不能把完整响应追加到旧文件,否则内容会重复。
    if (currentLength > 0 && response.statusCode == HttpStatus.ok) {
      await response.drain();
      await tempFile.delete();
      return downloadResumable(uri: uri, tempFile: tempFile);
    }

    if (currentLength > 0 &&
        response.statusCode != HttpStatus.partialContent) {
      await response.drain();
      throw HttpException(
        'Expected 206 for resume, got ${response.statusCode}',
        uri: uri,
      );
    }

    if (currentLength == 0 &&
        response.statusCode != HttpStatus.ok &&
        response.statusCode != HttpStatus.partialContent) {
      await response.drain();
      throw HttpException(
        'Unexpected status ${response.statusCode}',
        uri: uri,
      );
    }

    final file = await tempFile.open(mode: FileMode.writeOnly);
    try {
      // 服务器确认了 Range,定位到已有内容末尾。
      if (response.statusCode == HttpStatus.partialContent) {
        await file.setPosition(currentLength);
      } else {
        // 200 表示从头开始写。
        await file.setPosition(0);
      }

      await for (final chunk in response) {
        await file.writeFrom(chunk);
      }

      await file.flush();
    } finally {
      await file.close();
    }
  } finally {
    client.close(force: true);
  }
}

调用示例:

await downloadResumable(
  uri: Uri.parse('https://example.com/video.mp4'),
  tempFile: File('/path/to/video.mp4.part'),
  etag: '"abc123"',
);

这个示例的关键判断是:

  • 临时文件长度为 0:允许 200 OK
  • 临时文件长度大于 0:期望 206 Partial Content
  • 得到 200 OK:先丢弃响应并从头开始;
  • 得到其他状态:进入错误处理,不继续写文件。

示例为了突出协议,省略了生产代码还必须补上的部分:

  • 解析并验证 Content-Range 的起始偏移;
  • 检查最终文件大小;
  • ETag 和进度写入数据库;
  • 对响应体计算 SHA-256;
  • 处理磁盘空间不足;
  • 处理取消;
  • 处理认证刷新;
  • 将临时文件原子改名;
  • 避免两个执行器同时下载同一个任务。

尤其要注意并发问题。若 UI isolate 和后台执行器同时恢复同一个任务,两个请求可能同时写入 .part 文件,最终得到损坏文件。应使用任务锁、数据库状态版本号或平台级唯一任务 ID。


11. 续传下载的完整故障路径

假设本地已有 5 MiB,目标文件大小为 10 MiB。

情况一:服务器支持续传

请求:

GET /file
Range: bytes=5242880-
If-Range: "abc123"

响应:

206 Partial Content
Content-Range: bytes 5242880-10485759/10485760
ETag: "abc123"

客户端步骤:

  1. 校验 Content-Range 起点是 5242880
  2. 将响应写入临时文件的第 5242880 字节;
  3. 写入结束后,文件长度应为 10485760
  4. 校验文件摘要;
  5. .part 改名为正式文件;
  6. 持久化 completed

情况二:资源已经变化

响应可能是:

200 OK
ETag: "new456"
Content-Length: 10485760

客户端必须:

  1. 丢弃旧临时文件;
  2. 使用新响应从偏移 0 开始写;
  3. 更新 ETag;
  4. 重新校验完整文件。

情况三:服务器声明范围不合法

例如本地已有 5 MiB,但返回:

206 Partial Content
Content-Range: bytes 0-5242879/10485760

这不是可追加的响应。客户端若直接追加,会产生重复内容。应关闭响应、记录协议错误,并重新开始或标记失败。

情况四:返回 416

416 Range Not Satisfiable 可能意味着临时文件已经等于完整文件,也可能意味着本地偏移超过了服务器文件大小。

客户端应读取服务器给出的总长度信息并判断:

  • 本地长度等于总长度:进入最终校验;
  • 本地长度大于总长度:删除临时文件,从头下载;
  • 无法判断:不要继续追加,进入恢复流程。

12. 上传续传与下载续传不是同一个问题

下载通常依赖 HTTP Range。上传续传则需要服务器提供上传协议,常见方式包括:

  • 分片上传;
  • 客户端为每个分片生成编号;
  • 服务端记录已接收分片;
  • 客户端重启后查询已完成分片;
  • 最后调用合并或完成接口。

例如一个 100 MiB 文件按 5 MiB 分片:

分片数=1005=20\text{分片数} = \left\lceil \frac{100}{5} \right\rceil = 20

客户端状态可以保存:

{
  "uploadId": "u-789",
  "fileSha256": "...",
  "completedParts": [1, 2, 3, 5, 6]
}

重启后只需重传第 4 个分片和剩余分片,而不是从头上传。

每个分片请求最好带有幂等标识,例如:

uploadId + partNumber

服务器收到相同的 uploadIdpartNumber 时,应覆盖或确认同一分片,而不是创建重复分片。否则客户端在超时后重试时无法判断服务端是否已经成功。


13. 后台任务的调度条件

调度不是“什么时候执行”,而是“在什么条件允许执行”。任务通常要声明:

  • 是否要求网络;
  • 是否要求非计费网络;
  • 是否要求设备充电;
  • 是否允许低电量执行;
  • 是否允许设备重启后恢复;
  • 是否可以延迟;
  • 是否需要用户可见。

例如一个视频同步任务可以定义:

网络:必须可用
网络类型:优先非计费网络
电量:低电量时暂停
充电:文件超过 500 MiB 时要求充电
执行:允许延迟
恢复:设备重启后重新排队

这些条件不能只在 Flutter 中检查一次,因为网络和电量会在任务执行期间变化。后台执行器启动后还要再次检查,传输过程中也要允许任务被暂停。

一个合理的数据流是:

sequenceDiagram
    participant U as 用户
    participant F as Flutter
    participant D as 本地数据库
    participant S as 系统调度器
    participant N as 网络服务

    U->>F: 点击下载
    F->>D: 写入 queued
    F->>S: 提交后台任务及约束
    S-->>F: 某个时间允许运行
    F->>D: 原子更新 running
    F->>N: Range 请求
    N-->>F: 206 数据流
    F->>D: 持久化偏移和 ETag
    N--xF: 网络中断
    F->>D: 写入 retry_wait
    S-->>F: 下次允许运行
    F->>N: 从持久化偏移恢复
    N-->>F: 剩余数据
    F->>D: completed

“原子更新 running”很重要。若上次执行器崩溃后数据库仍是 running,下次启动时必须有恢复规则,例如:

running 且 heartbeat 超时
    -> queued

否则任务会永久卡在运行状态。


14. 省电的核心:减少唤醒、连接和重复传输

移动设备耗电不只来自 CPU。网络无线模块从休眠进入活跃状态也有成本。频繁的小请求会造成多次唤醒:

每 10 秒请求一次:
    唤醒网络 -> 建立连接 -> 请求 -> 等待 -> 休眠

更省电的策略通常是:

  • 合并多个小任务;
  • 使用系统提供的网络约束;
  • 让任务在一次唤醒中完成更多工作;
  • 使用持久连接或合理的连接复用;
  • 避免无意义轮询;
  • 只传输变化内容;
  • 下载大文件时使用续传,避免重复传输;
  • 将重试延迟并加入随机抖动;
  • 低电量或计费网络下暂停大任务。

可以用一个简化模型理解功耗:

EEwake×k+Enetwork×b+Ecpu×tE \approx E_{\text{wake}} \times k + E_{\text{network}} \times b + E_{\text{cpu}} \times t

其中:

  • EE 是总能耗;
  • kk 是唤醒和网络激活次数;
  • bb 是传输字节数;
  • tt 是 CPU 工作时间。

这个公式不是设备级精确测量模型,但能说明三个方向:

  • 减少唤醒次数;
  • 减少传输字节;
  • 减少无效计算和重复校验。

续传同时降低了 bb 和失败恢复时间,但它也会增加状态记录、协议校验和磁盘写入,因此小文件不一定值得复杂续传。


15. 重试、暂停和取消的实现原则

15.1 暂停不是取消

暂停意味着保留临时文件和任务元数据,之后可以继续:

running -> paused -> queued -> running

取消通常意味着:

  1. 标记任务为 cancelled
  2. 停止当前网络请求;
  3. 删除临时文件,或明确保留为用户可恢复草稿;
  4. 不再自动重试。

如果只依赖内存中的 bool cancelled,进程重启后取消状态会丢失。

15.2 取消必须覆盖所有等待点

一个任务可能正在:

  • 等待调度;
  • 等待网络连接;
  • 读取响应体;
  • 写磁盘;
  • 等待重试计时器;
  • 等待认证刷新。

取消逻辑必须在这些阶段都能生效。否则用户点击取消后,任务可能仍在后台继续写入文件,随后又把数据库状态改回 completed

15.3 失败需要分类

建议至少区分:

retryable_network_error
server_rate_limited
authentication_required
insufficient_storage
invalid_response
checksum_mismatch
user_cancelled
permanent_server_error

不同错误的恢复入口不同。网络错误可以重试,摘要不匹配通常应删除临时文件或重新获取资源,认证错误应等待凭证恢复,磁盘空间不足则需要用户处理。


16. Flutter 与平台 API 的选择

Flutter SDK 本身提供 UI、Dart 运行时和平台通信基础,但 Android 的 WorkManager、iOS 的 BGTaskScheduler、iOS 的后台 URLSession 等不是 Flutter SDK 的统一跨平台 API。

实际项目通常有三种集成方式:

16.1 使用已有插件

优点是开发快,缺点是平台能力可能不完整。应重点核对:

  • Android 任务是否真正由 WorkManager 或前台服务执行;
  • iOS 是否使用后台 URLSession,而不是只启动 Dart isolate;
  • 应用被杀死后任务是否能恢复;
  • 回调是否能在冷启动时送达;
  • 插件是否支持当前 Android/iOS 版本;
  • 任务状态是否持久化;
  • 是否支持取消、重试和网络约束。

“插件能在后台打印日志”不等于“插件能可靠完成文件传输”。

16.2 自定义 Platform Channel

适合平台差异明显的场景:

  • Android 使用 WorkManager 或前台服务;
  • iOS 使用后台 URLSession;
  • Flutter 只负责创建任务、读取任务状态和展示结果。

此时要定义稳定的跨平台任务协议,例如:

createTask
pauseTask
resumeTask
cancelTask
getTaskStatus

平台层应将任务状态写入共享持久化存储,而不是只通过一次事件发送给 Flutter。因为事件发送时 Flutter 可能根本不在运行。

16.3 使用纯 Dart 方案

适合:

  • 前台下载;
  • 桌面应用进程常驻;
  • 任务不要求应用被杀死后继续;
  • Web 中受浏览器规则约束的短任务。

纯 Dart 方案代码简单,但不能伪装成移动端的系统级后台方案。


17. 常见误解及其失败表现

误解一:Timer.periodic 可以定时同步

失败原因:应用挂起后 Dart 事件循环不再按预期运行。

表现:

  • 前台运行正常;
  • 切到后台后日志停止;
  • 返回应用时多个定时器回调集中触发或直接丢失;
  • iOS 上长时间不再执行。

修复方向:使用 Android/iOS 系统调度能力,并让任务可重复、可恢复。

误解二:后台 isolate 可以防止应用被杀

失败原因:isolate 与 UI isolate 仍属于同一个应用进程。

表现:

  • isolate 启动时可以执行;
  • 系统回收进程后任务消失;
  • 下次打开应用时内存中的队列为空。

修复方向:将任务状态写入持久化存储,并通过操作系统重新唤醒。

误解三:保存文件长度就等于支持断点续传

失败原因:续传是客户端和服务器共同遵守的协议。

表现:

  • 服务端返回 200,客户端却追加;
  • 文件大小看起来正确,但视频无法播放;
  • 摘要校验失败;
  • 资源更新后旧内容和新内容混合。

修复方向:检查 206Content-Range、ETag 和最终摘要。

误解四:重试次数越多越可靠

失败原因:永久错误、认证错误和协议错误不适合无限重试。

表现:

  • 账号失效后持续请求;
  • 服务端返回 429,客户端继续加压;
  • 文件损坏后重复使用同一临时内容;
  • 电量和流量持续消耗。

修复方向:按错误类型分类,使用退避、上限和人工恢复入口。

误解五:前台服务就是后台无限执行

失败原因:前台服务仍受系统、权限、用户操作和应用错误影响。

表现:

  • Android 新版本启动服务失败;
  • 没有通知或服务类型不匹配;
  • 用户停止服务后任务没有恢复;
  • 厂商系统仍然清理应用。

修复方向:只为用户可感知的长任务使用,并实现服务停止后的恢复。


18. 诊断后台任务失败的方法

诊断时不要只看 Flutter 控制台,因为应用不在前台时可能没有 Dart 日志输出。应同时记录三类信息。

18.1 任务状态日志

每条日志至少包含:

taskId
state
attempt
bytesWritten
totalBytes
platform
errorType
nextAttemptAt
timestamp

状态变化应记录为事件,例如:

queued -> running
running -> retry_wait, reason=timeout
retry_wait -> running
running -> failed, reason=checksum_mismatch

18.2 平台调度日志

Android 需要查看 WorkManager、JobScheduler、前台服务和通知权限相关日志;iOS 需要检查后台任务提交、系统是否实际唤醒、URLSession 任务状态和应用生命周期回调。

诊断重点不是“代码是否调用了 submit”,而是:

  1. 任务是否成功注册;
  2. 约束是否满足;
  3. 系统是否启动任务;
  4. 任务是否因超时或停止而结束;
  5. 进程终止后是否重新恢复;
  6. 回调是否在 Flutter 未运行时被正确保存。

18.3 网络和文件证据

应记录:

  • HTTP 状态码;
  • Content-Length
  • Content-Range
  • ETag;
  • 实际文件长度;
  • 最终摘要;
  • 磁盘可用空间;
  • 认证状态;
  • 网络类型和是否计费。

如果文件损坏,只知道“下载失败”是不够的。必须能回答:

客户端从哪个偏移请求?
服务器返回了什么范围?
实际写入了多少?
最后一次持久化进度是多少?
资源是否换过 ETag?

19. 生产设计的验证场景

后台任务至少应验证以下故障路径:

  1. 请求开始后立即切后台;
  2. 传输中断网;
  3. 传输中应用被系统杀死;
  4. 设备重启后恢复;
  5. 服务端忽略 Range;
  6. 服务端返回错误的 Content-Range;
  7. 文件下载完成但摘要不匹配;
  8. 磁盘空间不足;
  9. 凭证在后台过期;
  10. 用户连续点击开始、暂停、取消;
  11. 同一个任务被两个执行器同时启动;
  12. 用户关闭后台刷新或强制结束应用;
  13. Android 低电量或 Doze;
  14. iOS 长时间不打开应用;
  15. Web 页面隐藏或浏览器回收页面。

每次测试都应检查最终状态、临时文件、数据库记录和服务器端结果是否一致,而不只是检查页面上的进度条。


20. 如何选择实现方案

可以按任务性质选择:

用户主动操作、几秒到几分钟完成

使用 Flutter 前台异步代码:

Flutter UI
  -> Dart HTTP 请求
  -> 本地临时文件
  -> 进度展示
  -> 完成后原子改名

需要处理超时、取消和失败,但不必引入完整系统调度。

可延迟的同步或清理

使用 Android WorkManager、iOS 后台刷新或后台处理等系统调度机制。Flutter 负责业务逻辑,平台层负责被系统唤醒。

大文件且要求应用退出后继续

优先使用平台托管的文件传输能力:

  • iOS 重点考虑后台 URLSession;
  • Android 结合系统下载能力、WorkManager、前台服务和通知要求;
  • 两个平台都要实现持久化状态和完成后的结果同步。

桌面长期运行任务

如果应用必须在用户关闭窗口后继续,考虑操作系统服务,而不是单纯依赖 Flutter 窗口进程。

Web 后台任务

遵守浏览器页面、Service Worker、CORS 和下载模型限制,不把移动端后台服务的假设直接移植到浏览器。


21. 最终的设计模型

一个跨平台后台任务系统可以抽象为:

任务意图
  -> 持久化任务记录
  -> 平台调度或平台传输
  -> 恢复时读取任务记录
  -> 网络请求带有幂等和续传信息
  -> 写入临时文件
  -> 校验内容
  -> 原子提交结果
  -> 更新 completed 或可恢复失败

其中最重要的不是某个 Flutter 插件,而是四个不变量:

  1. 进度以持久化数据为准,而不是以 isolate 内存为准。
  2. 任务可以被重复启动,而不会产生重复副作用。
  3. 网络中断后能根据协议恢复,而不是盲目追加。
  4. 调度由操作系统决定,应用只声明约束并准备随时暂停。

Flutter 适合统一 UI、任务模型和业务代码,但后台执行的最终边界由 Android、iOS、桌面系统和浏览器决定。只有把平台生命周期、网络协议、文件一致性和功耗约束放在同一个设计中,后台任务才不仅能“偶尔跑起来”,而能在真实设备环境中正确恢复、正确失败并合理消耗资源。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。