WorkManager 可靠性实战:唯一任务、重试与幂等设计

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"
    }
}

构造参数中只传轻量标识,不要把完整订单序列化进 DataData 有大小限制,而且任务真正执行时,提交时的数据快照可能已经过期。更稳妥的做法是传入主键,Worker 再从 Room 读取最新记录。

如果项目使用 Hilt,可以配合 @HiltWorkerHiltWorkerFactory 注入仓库。无论采用哪种方式,都应避免在 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 只负责调度与编排,仓库负责业务状态和幂等,界面通过数据库观察最终事实时,后台同步才能在网络波动、进程重启和重复操作下保持可预测。

相关推荐
fsssb1 小时前
Chromium 源码学习笔记(五):一次点击,在进入 JS 之前先经历了什么?
前端
鹿目1 小时前
使用 Vant CLI 搭建 UI 组件库:从 H5 到小程序跨平台
前端·javascript
默_笙1 小时前
😭 我花了两小时找了一个数据结构 bug,才把"迷你 Cursor"跑起来,绝望(bushi)
前端·javascript
程序员老刘1 小时前
Android 17的10个AI功能,国内用户真正能用的有几个?
android·flutter·ai编程
竹林8181 小时前
wagmi v2 监听合约事件踩坑记:从 `useContractEvent` 到 `watchContractEvent`,我重构了三版才搞定实时数据流
前端·javascript
天若有情6732 小时前
纯HTML+Tailwind单页作品集网站——零框架静态前端完整源码
前端·html
WebGirl2 小时前
H5唤起app前端实现方案
前端
heimeiyingwang2 小时前
【架构实战】SQL注入与XSS防御:常见Web漏洞的系统性修复
前端·sql·架构
花开彼岸天~2 小时前
鸿蒙应用开发实战【94】— 实战多卡号场景管理最佳实践
android·华为·harmonyos·鸿蒙系统