WorkManager 可靠性实战:唯一任务、重试与幂等设计
很多后台任务的代码并不复杂:上传日志、同步数据、清理缓存,真正困难的是如何面对进程被杀、网络波动、重复点击和系统调度延迟。把一段协程放进 WorkManager 只是起点;要让任务在线上可靠运行,还需要明确任务约束、重复策略、重试边界和业务幂等。
本文以"离线订单同步"为例,从一个能运行的 Worker 开始,逐步补齐工程中最容易遗漏的可靠性设计。
WorkManager 解决的是什么问题
WorkManager 适合需要保证最终执行、允许系统选择时机的可延迟后台任务。任务提交后,即使应用退出或设备重启,调度信息仍会保留。
典型场景包括:
- 将本地待上传数据同步到服务器;
- 定期清理过期文件;
- 在联网后补传埋点或诊断日志;
- 下载后执行解压、校验等连续步骤。
它不适合精确到秒的闹钟,也不适合需要持续与用户交互的长时间操作。前者应考虑 AlarmManager,后者通常需要前台服务。WorkManager 保证的是满足条件后获得执行机会,不保证某个精确时刻立即运行。
从 CoroutineWorker 开始
订单同步需要调用挂起函数,使用 CoroutineWorker 最直接:
kotlin
class OrderSyncWorker(
appContext: Context,
workerParams: WorkerParameters,
private val repository: OrderRepository
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val orderId = inputData.getString(KEY_ORDER_ID)
?: return Result.failure()
return try {
repository.syncOrder(orderId)
Result.success()
} catch (error: IOException) {
Result.retry()
} catch (error: HttpException) {
Result.failure()
}
}
companion object {
const val KEY_ORDER_ID = "order_id"
}
}
构造参数中只传轻量标识,不要把完整订单序列化进 Data。Data 有大小限制,而且任务真正执行时,提交时的数据快照可能已经过期。更稳妥的做法是传入主键,Worker 再从 Room 读取最新记录。
如果项目使用 Hilt,可以配合 @HiltWorker 和 HiltWorkerFactory 注入仓库。无论采用哪种方式,都应避免在 Worker 中临时拼装一套网络层和数据库,因为这会绕开应用统一的超时、鉴权和日志策略。
用约束表达执行前提
同步订单至少需要网络。如果上传内容较大,还可以要求非低电量状态:
kotlin
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val request = OneTimeWorkRequestBuilder<OrderSyncWorker>()
.setInputData(workDataOf(OrderSyncWorker.KEY_ORDER_ID to orderId))
.setConstraints(constraints)
.build()
约束是调度条件,不是业务校验。网络条件满足时仍可能遇到 DNS 错误、连接超时或服务端限流,因此 doWork() 内依然要处理失败。相反,也不要设置不必要的约束,例如小体积 JSON 同步通常无需强制未计量网络,否则任务可能长期等待 Wi-Fi。
唯一任务避免重复排队
用户连续点击提交、页面重建或应用启动补偿都可能触发调度。如果每次都调用 enqueue(),同一订单会进入多份工作请求。
可以把业务主键放入唯一工作名称:
kotlin
fun enqueueOrderSync(context: Context, orderId: String) {
val request = OneTimeWorkRequestBuilder<OrderSyncWorker>()
.setInputData(workDataOf(OrderSyncWorker.KEY_ORDER_ID to orderId))
.setConstraints(syncConstraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.addTag("order-sync")
.addTag("order-sync:$orderId")
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"order-sync:$orderId",
ExistingWorkPolicy.KEEP,
request
)
}
KEEP 表示已有未完成任务时忽略新请求,适合"同一对象最终同步一次"的场景。REPLACE 会取消旧任务并创建新任务,适合参数变化后只关心最新结果的场景。APPEND 用于必须顺序执行的任务链,但如果前置任务失败,后续工作也可能无法运行,选择时要结合业务语义。
唯一工作只能控制客户端队列,无法消除所有重复请求。例如 Worker 已把请求发到服务器,进程在收到响应前被终止,恢复后仍可能再次提交。因此可靠性还必须依赖业务幂等。
幂等是最终一致性的核心
同步接口应接受稳定的幂等键,例如本地生成的订单 UUID:
kotlin
interface OrderApi {
@POST("orders/sync")
suspend fun syncOrder(
@Header("Idempotency-Key") operationId: String,
@Body request: SyncOrderRequest
): SyncOrderResponse
}
服务端保存幂等键与处理结果。相同键再次到达时,返回已有结果,而不是重复创建订单。客户端则在数据库事务中更新同步状态:
kotlin
database.withTransaction {
val order = orderDao.findById(orderId) ?: return@withTransaction
val response = api.syncOrder(order.operationId, order.toRequest())
orderDao.markSynced(orderId, response.remoteId)
}
现实中不能把数据库事务覆盖到网络请求上,否则事务会长时间占用连接。更常见的实现是先读取待同步快照,请求成功后再开启短事务,并通过版本号检查本地数据是否已变化。若用户在上传期间修改了订单,应保留新的"待同步"状态,而不是用旧响应把它覆盖。
区分可重试与永久失败
并非所有异常都应该 Result.retry()。无条件重试会让无效任务长期占用队列,还会掩盖真实问题。
可以按失败性质分类:
| 失败类型 | 建议结果 | 原因 |
|---|---|---|
| 连接超时、临时断网 | retry() |
环境恢复后可能成功 |
| 服务端限流或暂时不可用 | retry() |
配合退避降低压力 |
| 参数错误、资源不存在 | failure() |
重试不会改变结果 |
| 登录失效 | 视业务处理 | 通常先刷新凭证,无法恢复再失败 |
| 本地记录已删除 | success() |
目标状态已经无需同步 |
HTTP 状态码也不能简单按范围处理。429 和部分 5xx 通常可重试,400 多半是永久失败;401 是否重试取决于鉴权层能否刷新令牌。
kotlin
private fun mapFailure(error: Throwable): Result = when (error) {
is IOException -> Result.retry()
is HttpException -> when (error.code()) {
429, 502, 503, 504 -> Result.retry()
else -> Result.failure(
workDataOf("error_code" to error.code())
)
}
else -> Result.failure()
}
指数退避能让多次失败后的等待逐渐增长,避免服务器恢复前出现请求风暴。还可以检查 runAttemptCount 设置上限:超过合理次数后记录失败原因并返回 failure(),交给用户操作或专门的补偿机制处理。
进度和状态如何传给界面
界面不应该持有 Worker 实例。使用唯一工作名称或标签观察 WorkInfo:
kotlin
val syncState: Flow<WorkInfo?> = workManager
.getWorkInfosForUniqueWorkFlow("order-sync:$orderId")
.map { works -> works.maxByOrNull(WorkInfo::getRunAttemptCount) }
Worker 可通过 setProgress() 上报适合展示的短期进度:
kotlin
setProgress(workDataOf("uploaded" to uploaded, "total" to total))
需要注意,WorkInfo 不是业务事实来源。任务成功后,真正的订单状态仍应以数据库为准。推荐让界面主要观察 Room 中的同步状态,WorkInfo 只补充"正在排队""正在重试"等调度信息。这样即使工作记录被清理,业务状态仍然完整。
长任务、取消与资源释放
CoroutineWorker 被取消时,协程会收到取消信号。网络库和挂起函数应正确传播 CancellationException,不要用宽泛的 catch (Throwable) 把取消误判为失败重试。
kotlin
return try {
repository.syncOrder(orderId)
Result.success()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
mapFailure(error)
}
对于运行时间较长且用户可感知的工作,应调用 setForeground() 提供前台通知,并遵循目标 Android 版本对前台服务类型和权限的要求。大文件上传还应支持分片和断点续传,不能只依赖 WorkManager 重启整个请求。
测试调度与 Worker 结果
可靠性逻辑适合拆成两层测试。业务层测试仓库的幂等和状态转换,Worker 测试异常到 Result 的映射。使用 work-testing 可以在测试中构建 Worker:
kotlin
@Test
fun network_error_returns_retry() = runTest {
repository.nextError = IOException("offline")
val worker = TestListenableWorkerBuilder<OrderSyncWorker>(context)
.setInputData(workDataOf(OrderSyncWorker.KEY_ORDER_ID to "order-a"))
.setWorkerFactory(testWorkerFactory)
.build()
assertEquals(ListenableWorker.Result.retry(), worker.doWork())
}
还应覆盖这些边界:缺少输入参数、记录已删除、服务端永久错误、达到重试上限、任务取消、相同业务键重复提交。唯一工作策略可以通过 WorkManager 的测试驱动器验证约束满足前后的状态变化。
一套可落地的检查清单
上线前可以逐项确认:
- 输入只包含主键等轻量信息,执行时读取最新数据;
- 约束与实际需求一致,没有过度限制调度;
- 唯一工作名称包含稳定业务标识;
- 客户端防重复之外,服务端接口也支持幂等;
- 临时失败和永久失败有明确分类;
- 重试使用退避策略,并设置合理上限;
- 取消信号能够传播,资源会及时释放;
- Room 保存业务事实,WorkInfo 只表示调度状态;
- 日志包含工作 ID、业务 ID、尝试次数和失败类型;
- 测试覆盖重复提交、进程恢复和异常映射。
总结
WorkManager 的价值不只是"应用退出后继续执行",而是提供持久化调度、约束、任务链、重试和状态观察等基础能力。要把这些能力变成可靠业务,还需要唯一任务控制客户端队列,用幂等键解决不确定执行,用明确的错误分类控制重试,并把最终状态落到数据库。
当 Worker 只负责调度与编排,仓库负责业务状态和幂等,界面通过数据库观察最终事实时,后台同步才能在网络波动、进程重启和重复操作下保持可预测。