Flutter Android 后台同步配置:验证 WorkManager 约束与重试
列表页已经能拉到数据、手动下拉刷新也没有问题,并不表示退出 App 后同步已经可用。真实项目里常见的症状是:用户切到后台后待上传操作没有补发;网络从 Wi-Fi 切到移动数据时任务重复排队;日志里出现 ENQUEUED,但第二天服务端仍没有记录。把一个 Dart Future 放在页面生命周期里,无法替代 Android 对后台调度、约束和进程回收的管理。
这篇文章只处理一个核心问题:Flutter 发起可延后、可中断、需要网络的账户资料同步,由 Android 的 WorkManager 持久化调度,再把任务 ID、状态和失败原因回传给 Flutter 界面。它建立在已有网络 Repository、认证令牌刷新和错误模型之上;如果这些基础还没有,请先完成 Flutter Android 刷新竞态修复:取消旧请求与页面状态对齐,不要把竞态、鉴权失败和后台调度混在一处排查。
Android 官方将 WorkManager 定位为持久后台工作的推荐方案;Worker 返回 success、failure、retry 会决定后续调度,而执行的准确时刻仍受约束和系统优化影响。WorkManager 入门文档和后台数据传输选型说明都强调这一点。因此本文不承诺某分钟必跑,只构建可观察、可重试的同步闭环。
先划清 WorkManager 的适用边界
本例适合用户修改资料后、可等到网络可用再同步的任务;任务可以被系统推迟,服务端接口具备幂等键;单次工作能在有限时间内完成,且中断后可以安全重试;Flutter 只负责发起和展示状态,真正的 HTTP 调用放在 Android Worker。
不适合的情形也要写在设计前面。闹钟式的精确时刻、正在被用户看见的超长上传、必须立刻完成的支付确认,不能因为后台三个字就塞进 WorkManager。Android 的 WorkManager API 文档说明,后台工作在约束满足后才会在合适时间执行,Worker 也有执行时限;长传输或明确的用户发起任务,需要依照 Android 的前台服务或数据传输选型重新设计。
这一区分能避免一个常见误判:看到任务没有立即启动,就把网络代码、token 或 Flutter 引擎全部重写。先确认业务是否允许延后,再检查约束与状态,排查路径会短很多。
在 Flutter 与 Android 之间只传调度意图
不要把 Dart 的 Repository 直接搬进 Worker,也不要从 Worker 启动 Dart isolate 再调用业务接口。这里的边界是:Dart 传入稳定的业务标识和幂等键;Kotlin 负责持久化任务、读取本地凭据并调用 Android 侧同步客户端;界面只读取任务 ID 和状态。Flutter 的平台通道文档说明,通道用于 Dart 与 Kotlin/Java 之间的异步消息交换;它不是后台保活机制。
建议目录如下,避免把调度代码散在 MainActivity:
lib/features/profile/data/profile_sync_scheduler.dart
android/app/src/main/kotlin/cn/stepnex/app/sync/ProfileSyncWorker.kt
android/app/src/main/kotlin/cn/stepnex/app/sync/BackgroundSyncChannel.kt
android/app/src/main/kotlin/cn/stepnex/app/sync/AndroidProfileSyncClient.kt
android/app/src/test/kotlin/cn/stepnex/app/sync/ProfileSyncWorkerTest.kt
Dart 侧只提供两个动作:排队与查询。dedupeKey 应来自一次用户编辑的本地版本号或 UUID,不能用当前时间戳替代,否则重试和重复点击都会生成不同任务。
import 'package:flutter/services.dart';
class ProfileSyncScheduler {
static const _channel = MethodChannel('cn.stepnex/background_sync');
Future<String> enqueue({required String profileVersion, required String dedupeKey}) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'enqueueProfileSync',
{'profileVersion': profileVersion, 'dedupeKey': dedupeKey},
);
return result!['workId'] as String;
}
Future<Map<String, dynamic>> state(String workId) async {
return (await _channel.invokeMapMethod<String, dynamic>(
'profileSyncState', {'workId': workId},
))!;
}
}
界面拿到 workId 后保存到自己的状态对象,展示等待网络、同步中、稍后重试等可解释文案。不要在点击按钮后乐观地写成已同步;那只能表示请求已排队。
配置唯一任务、网络约束和退避重试
以下示例的验证环境是 Flutter stable、Android Gradle Plugin 9 迁移后的 Kotlin DSL 工程、Android API 24 及以上真机;依赖以 Android 官方入门页当前展示的 androidx.work:work-runtime-ktx:2.11.2 为基线。团队应在 gradle/libs.versions.toml 或版本目录统一管理版本,升级前先运行现有构建和测试。
// android/app/build.gradle.kts
dependencies {
implementation("androidx.work:work-runtime-ktx:2.11.2")
androidTestImplementation("androidx.work:work-testing:2.11.2")
}
在通道处理器中用唯一任务名收口重复排队。这里选择 KEEP:同一个账户已有等待或运行中的同步时,新点击不再创建第二个 Worker。若业务要求只保留最新编辑,可以改用 REPLACE,但必须让旧 Worker 的服务端请求可取消或由幂等键拒绝旧版本。
private const val UNIQUE_PROFILE_SYNC = "profile-sync-v1"
fun enqueueProfileSync(context: Context, profileVersion: String, dedupeKey: String): UUID {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<ProfileSyncWorker>()
.setInputData(workDataOf(
"profile_version" to profileVersion,
"dedupe_key" to dedupeKey,
))
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("profile-sync")
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
UNIQUE_PROFILE_SYNC, ExistingWorkPolicy.KEEP, request
)
return request.id
}
CONNECTED 表示需要任何可用网络,不等于 Wi-Fi。若同步会消耗大量流量,再评估 UNMETERED;若数据很小却强制 Wi-Fi,用户会长期看见 ENQUEUED,这不是代码卡死。退避从 30 秒起步只是示例,最终值要结合接口限流和产品允许的延迟。任务的可见标签、唯一名、输入版本和 workId 都应写进日志,后续才能对应到一次编辑。
在 Worker 内按失败类型返回结果
Worker 必须把可恢复故障和不可恢复故障分开。网络超时、DNS、5xx 通常返回 Result.retry();请求体校验失败、当前版本已被服务端接受、明确的 4xx 业务拒绝通常应返回 Result.failure() 并输出可脱敏的错误码。认证过期不要无止境重试:先由原生认证组件进行一次受控刷新,仍失败则标记失败并要求用户重新登录。
class ProfileSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val version = inputData.getString("profile_version") ?: return Result.failure()
val key = inputData.getString("dedupe_key") ?: return Result.failure()
return try {
AndroidProfileSyncClient(applicationContext).sync(version, key)
Log.i("ProfileSyncWorker", "sync_ok version=$version workId=$id")
Result.success(workDataOf("synced_version" to version))
} catch (e: IOException) {
Log.w("ProfileSyncWorker", "sync_retry workId=$id type=network")
Result.retry()
} catch (e: HttpException) {
if (e.code() >= 500) Result.retry() else Result.failure(
workDataOf("http_code" to e.code())
)
}
}
}
不要把 access token、手机号、完整响应体写进日志或 Data。WorkManager 输出数据适合小型状态,不是传输大 JSON 的缓存。服务端还要以 dedupe_key 实现幂等:同一键第二次抵达时返回已有结果,而不是再次写入。否则即使客户端正确重试,网络在服务端写入后断开也会造成重复记录。
让 Flutter 能读到状态,而不阻塞主线程
WorkManager 状态至少分为 ENQUEUED、RUNNING、SUCCEEDED、FAILED、CANCELLED。通道查询不要在 Android 主线程直接等待一个 Future;应由协程或后台 executor 查询后再 result.success。对列表页来说,最有用的返回字段是 state、runAttemptCount、outputData、workId,并把它们转换为用户语言。
建议记录两类指标:
- 客户端日志:enqueue、约束未满足、每次 runAttemptCount、最终状态和 workId。
- 服务端指标:按 dedupe_key 统计收到次数、成功次数和拒绝原因,观察 24 小时内是否出现重复写入。
使用下面命令先锁定构建与日志基线:
flutter test
cd android; .\gradlew.bat :app:testDebugUnitTest; cd ..
adb logcat -v time | Select-String -Pattern 'ProfileSyncWorker|WM-'
日志中一次正常路径应至少能看到 enqueue、Worker 开始、sync_ok 和服务端同一 dedupe_key 的一条成功记录。若停在 ENQUEUED,先查看当前网络、是否错误配置为 UNMETERED、省电模式和任务约束;若多次 RUNNING 后失败,再看 runAttemptCount、HTTP 状态与 token 刷新日志,而不是先怀疑 Flutter 页面。
用测试和真机场景验证,而不是只看一次成功
先为 ProfileSyncWorker 写单元测试:缺少输入数据应返回失败;模拟 IOException 应返回重试;模拟 400 应失败;模拟 503 应重试。WorkManager 提供 work-testing 工具,测试中可以初始化测试配置、触发约束并断言状态。然后在 Android 模拟器或真机跑一次从 Flutter 点击到原生 Worker 的集成测试。Flutter 官方的集成测试概览指出,这类测试运行在真实设备或模拟器上,适合验证 Dart 与原生代码一起工作。
验收不要只连着 USB、开着 App。至少覆盖四组场景:
- 断网点击同步:任务进入 ENQUEUED,恢复网络后只执行一次。
- 服务端返回 503:runAttemptCount 增加,退避后重试,幂等键不产生重复数据。
- 服务端返回 400:任务进入 FAILED,Flutter 展示可操作错误,不无限重试。
- 排队后划掉 App、等待一段时间再打开:页面通过 workId 能读到最终状态。
最后把构建版本、设备 API、网络条件、workId、日志片段和服务端去重结果放入发布前记录。与 Flutter Android 崩溃监控配置:补齐 Isolate 漏报 一样,关键不是多装一个 SDK,而是把发生了什么、在哪一层失败、能否复现变成可查询证据。
常见失败边界与下一步
如果业务要求每天 09:00 准点刷新,WorkManager 不是精确闹钟;如果用户主动上传数百 MB 文件,也不应把无界传输伪装成短 Worker。若任务需要在 Android 原生侧执行但 Flutter 已销毁,原生 Worker 可以继续运行;反过来,Worker 不应依赖 Flutter Engine 存活。
完成这套配置后,下一阶段不是继续堆更多后台任务,而是把不同业务的唯一名、幂等键、失败码和可观测指标收进统一的移动端任务注册表。那时再评估周期任务、前台数据传输或更细的网络策略,才不会把每个同步问题都做成一次独立补丁。
补充验收时,把一次任务从排队到服务端确认的耗时按网络类型分别记录。只有在相同版本、相同设备 API 和相近网络条件下比较,指标才可用于判断约束是否过严、退避是否过长或接口是否出现新的不稳定。遇到异常峰值,保留 workId 与脱敏日志,再回放同一编辑版本;不要为了追求更快的表面数字删除重试、取消约束或跳过服务端幂等校验。