Flutter 基础体系 · 第 64/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 后台任务:平台限制、调度、网络、续传和省电
Flutter 中的“后台任务”不是一种统一能力。它至少包含四个不同问题:
- 进程是否仍然存在:应用切到后台后,Flutter Engine 和 Dart isolate 是否还活着。
- 操作系统是否允许执行:Android、iOS、桌面和 Web 对后台执行有不同规则。
- 网络操作如何完成:任务运行时如何请求、超时、重试和处理网络变化。
- 任务中断后如何恢复:进程被杀死、设备重启、网络断开或应用崩溃后,如何从已完成的位置继续。
因此,下面这句话通常是不准确的:
“启动一个 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 不能直接共享普通对象,只能通过 SendPort 和 ReceivePort 传递消息。
后台 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 不能只在内存中累加。更稳妥的顺序是:
- 将网络数据写入临时文件;
- 文件写入成功后获得实际文件长度;
- 将实际长度写入任务数据库;
- 定期或在关键阶段更新校验信息;
- 完成后将临时文件原子地改名为正式文件。
如果第 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 是否存活。
典型流程是:
- Flutter 创建下载任务;
- 原生 iOS 层使用后台配置创建
URLSession; - 系统在后台执行下载;
- 下载完成或发生错误;
- 系统唤醒应用或调用指定的生命周期入口;
- 原生层处理回调;
- 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,不自动重试 |
重试次数不能代替幂等设计。上传请求若没有幂等键,客户端不知道请求是否已经在服务器端成功,就可能重复创建资源。
一个常见的指数退避模型是:
其中:
- 是第 次重试前的等待时间;
- 是初始等待时间;
- 是最大等待时间;
- 是随机抖动;
- 从 0 开始计数。
例如取 秒、 秒:
第 1 次:2 秒 + 随机抖动
第 2 次:4 秒 + 随机抖动
第 3 次:8 秒 + 随机抖动
第 4 次:16 秒 + 随机抖动
后续:最多 60 秒 + 随机抖动
随机抖动的作用是避免大量设备同时恢复网络后形成请求洪峰。
9. HTTP 断点续传的协议条件
“续传”不是客户端保存了文件长度就自动成立。它至少需要服务器正确支持以下 HTTP 行为:
- 客户端使用
Range: bytes=N-请求剩余内容; - 服务器对有效范围返回
206 Partial Content; - 返回
Content-Range: bytes N-M/T; - 服务器资源没有在续传期间发生变化;
- 客户端校验最终文件长度或摘要。
如果服务器忽略 Range 并返回 200 OK,客户端不能把完整响应追加到已有临时文件,否则文件会被拼接成错误内容。
如果资源可能变化,客户端应保存 ETag 或 Last-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"
客户端步骤:
- 校验
Content-Range起点是5242880; - 将响应写入临时文件的第
5242880字节; - 写入结束后,文件长度应为
10485760; - 校验文件摘要;
- 将
.part改名为正式文件; - 持久化
completed。
情况二:资源已经变化
响应可能是:
200 OK
ETag: "new456"
Content-Length: 10485760
客户端必须:
- 丢弃旧临时文件;
- 使用新响应从偏移 0 开始写;
- 更新 ETag;
- 重新校验完整文件。
情况三:服务器声明范围不合法
例如本地已有 5 MiB,但返回:
206 Partial Content
Content-Range: bytes 0-5242879/10485760
这不是可追加的响应。客户端若直接追加,会产生重复内容。应关闭响应、记录协议错误,并重新开始或标记失败。
情况四:返回 416
416 Range Not Satisfiable 可能意味着临时文件已经等于完整文件,也可能意味着本地偏移超过了服务器文件大小。
客户端应读取服务器给出的总长度信息并判断:
- 本地长度等于总长度:进入最终校验;
- 本地长度大于总长度:删除临时文件,从头下载;
- 无法判断:不要继续追加,进入恢复流程。
12. 上传续传与下载续传不是同一个问题
下载通常依赖 HTTP Range。上传续传则需要服务器提供上传协议,常见方式包括:
- 分片上传;
- 客户端为每个分片生成编号;
- 服务端记录已接收分片;
- 客户端重启后查询已完成分片;
- 最后调用合并或完成接口。
例如一个 100 MiB 文件按 5 MiB 分片:
客户端状态可以保存:
{
"uploadId": "u-789",
"fileSha256": "...",
"completedParts": [1, 2, 3, 5, 6]
}
重启后只需重传第 4 个分片和剩余分片,而不是从头上传。
每个分片请求最好带有幂等标识,例如:
uploadId + partNumber
服务器收到相同的 uploadId 和 partNumber 时,应覆盖或确认同一分片,而不是创建重复分片。否则客户端在超时后重试时无法判断服务端是否已经成功。
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 秒请求一次:
唤醒网络 -> 建立连接 -> 请求 -> 等待 -> 休眠
更省电的策略通常是:
- 合并多个小任务;
- 使用系统提供的网络约束;
- 让任务在一次唤醒中完成更多工作;
- 使用持久连接或合理的连接复用;
- 避免无意义轮询;
- 只传输变化内容;
- 下载大文件时使用续传,避免重复传输;
- 将重试延迟并加入随机抖动;
- 低电量或计费网络下暂停大任务。
可以用一个简化模型理解功耗:
其中:
- 是总能耗;
- 是唤醒和网络激活次数;
- 是传输字节数;
- 是 CPU 工作时间。
这个公式不是设备级精确测量模型,但能说明三个方向:
- 减少唤醒次数;
- 减少传输字节;
- 减少无效计算和重复校验。
续传同时降低了 和失败恢复时间,但它也会增加状态记录、协议校验和磁盘写入,因此小文件不一定值得复杂续传。
15. 重试、暂停和取消的实现原则
15.1 暂停不是取消
暂停意味着保留临时文件和任务元数据,之后可以继续:
running -> paused -> queued -> running
取消通常意味着:
- 标记任务为
cancelled; - 停止当前网络请求;
- 删除临时文件,或明确保留为用户可恢复草稿;
- 不再自动重试。
如果只依赖内存中的 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,客户端却追加; - 文件大小看起来正确,但视频无法播放;
- 摘要校验失败;
- 资源更新后旧内容和新内容混合。
修复方向:检查 206、Content-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”,而是:
- 任务是否成功注册;
- 约束是否满足;
- 系统是否启动任务;
- 任务是否因超时或停止而结束;
- 进程终止后是否重新恢复;
- 回调是否在 Flutter 未运行时被正确保存。
18.3 网络和文件证据
应记录:
- HTTP 状态码;
Content-Length;Content-Range;- ETag;
- 实际文件长度;
- 最终摘要;
- 磁盘可用空间;
- 认证状态;
- 网络类型和是否计费。
如果文件损坏,只知道“下载失败”是不够的。必须能回答:
客户端从哪个偏移请求?
服务器返回了什么范围?
实际写入了多少?
最后一次持久化进度是多少?
资源是否换过 ETag?
19. 生产设计的验证场景
后台任务至少应验证以下故障路径:
- 请求开始后立即切后台;
- 传输中断网;
- 传输中应用被系统杀死;
- 设备重启后恢复;
- 服务端忽略 Range;
- 服务端返回错误的 Content-Range;
- 文件下载完成但摘要不匹配;
- 磁盘空间不足;
- 凭证在后台过期;
- 用户连续点击开始、暂停、取消;
- 同一个任务被两个执行器同时启动;
- 用户关闭后台刷新或强制结束应用;
- Android 低电量或 Doze;
- iOS 长时间不打开应用;
- Web 页面隐藏或浏览器回收页面。
每次测试都应检查最终状态、临时文件、数据库记录和服务器端结果是否一致,而不只是检查页面上的进度条。
20. 如何选择实现方案
可以按任务性质选择:
用户主动操作、几秒到几分钟完成
使用 Flutter 前台异步代码:
Flutter UI
-> Dart HTTP 请求
-> 本地临时文件
-> 进度展示
-> 完成后原子改名
需要处理超时、取消和失败,但不必引入完整系统调度。
可延迟的同步或清理
使用 Android WorkManager、iOS 后台刷新或后台处理等系统调度机制。Flutter 负责业务逻辑,平台层负责被系统唤醒。
大文件且要求应用退出后继续
优先使用平台托管的文件传输能力:
- iOS 重点考虑后台 URLSession;
- Android 结合系统下载能力、WorkManager、前台服务和通知要求;
- 两个平台都要实现持久化状态和完成后的结果同步。
桌面长期运行任务
如果应用必须在用户关闭窗口后继续,考虑操作系统服务,而不是单纯依赖 Flutter 窗口进程。
Web 后台任务
遵守浏览器页面、Service Worker、CORS 和下载模型限制,不把移动端后台服务的假设直接移植到浏览器。
21. 最终的设计模型
一个跨平台后台任务系统可以抽象为:
任务意图
-> 持久化任务记录
-> 平台调度或平台传输
-> 恢复时读取任务记录
-> 网络请求带有幂等和续传信息
-> 写入临时文件
-> 校验内容
-> 原子提交结果
-> 更新 completed 或可恢复失败
其中最重要的不是某个 Flutter 插件,而是四个不变量:
- 进度以持久化数据为准,而不是以 isolate 内存为准。
- 任务可以被重复启动,而不会产生重复副作用。
- 网络中断后能根据协议恢复,而不是盲目追加。
- 调度由操作系统决定,应用只声明约束并准备随时暂停。
Flutter 适合统一 UI、任务模型和业务代码,但后台执行的最终边界由 Android、iOS、桌面系统和浏览器决定。只有把平台生命周期、网络协议、文件一致性和功耗约束放在同一个设计中,后台任务才不仅能“偶尔跑起来”,而能在真实设备环境中正确恢复、正确失败并合理消耗资源。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 推送通知:Token、前后台、点击路由、权限和送达率
- 下一篇:Flutter Platform Channel 深入:Codec、线程、错误和性能
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论