Android 后台任务可靠性排查:从 WorkManager 观测到失败重试闭环
后台同步最难处理的部分,往往不是把任务提交给 WorkManager,而是回答"它为什么没有按预期完成"。本文以一个离线优先的资料同步任务为例,梳理约束条件、任务状态、失败分类、重试策略和幂等落库,建立一条可以在本地与线上复用的排查闭环。
先定义任务的完成语义
"同步成功"不能只等同于接口返回 HTTP 200。一个完整任务至少要明确四个结果:
- 服务端数据已经拉取并通过基本校验。
- 本地事务已经提交,下一次读取能够看到新数据。
- 当前任务不会因为重复执行产生重复记录或错误覆盖。
- 失败时能够判断是应该重试、等待约束满足,还是直接结束。
如果这些语义没有先定义,排查时看到 SUCCEEDED 也可能只是"请求成功",并不代表本地数据可用。
约束决定任务何时有机会运行
WorkManager 会综合网络、电量、充电和存储等约束决定任务是否可以启动。约束不满足时,任务通常停留在 ENQUEUED,这不是执行失败。
kotlin
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.setInputData(workDataOf("reason" to "manual_refresh"))
.addTag("profile-sync")
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"profile-sync",
ExistingWorkPolicy.KEEP,
request
)
这里有三个容易被忽略的点:
CONNECTED只表示存在网络连接,不保证接口可达,也不保证服务端健康。- 指数退避的最短间隔会受到 WorkManager 限制,不能把它当作精确的定时器。
KEEP会保留已经存在的唯一任务。用户点击刷新后没有新任务,不一定是提交失败,可能是旧任务仍在队列中。
用唯一任务和标签保留可观测性
任务名适合表达业务上的唯一工作,标签适合按业务维度查询。不要只保存 WorkRequest 对象,因为进程重启后它不会替你保留诊断上下文。
kotlin
suspend fun observeSync(workManager: WorkManager): SyncSnapshot {
val work = workManager
.getWorkInfosForUniqueWork("profile-sync")
.firstOrNull()
return when {
work == null -> SyncSnapshot.NotScheduled
work.state == WorkInfo.State.RUNNING -> SyncSnapshot.Running
work.state == WorkInfo.State.ENQUEUED ->
SyncSnapshot.Waiting(work.runAttemptCount, work.constraints)
work.state == WorkInfo.State.SUCCEEDED -> SyncSnapshot.Success
work.state == WorkInfo.State.FAILED ->
SyncSnapshot.Failed(work.outputData.getString("error"))
else -> SyncSnapshot.Cancelled
}
}
真实项目中建议把以下信息写入日志或本地诊断表:任务唯一名、请求原因、创建时间、开始时间、结束时间、运行次数、最后一次错误类型和数据版本。日志中不要写入 token、完整用户资料或接口响应原文。
正确区分失败和重试
CoroutineWorker.doWork() 返回 Result.retry() 时,WorkManager 会按照退避策略重新调度;返回 Result.failure() 则表示任务终止。判断标准应该是错误是否具有临时性,而不是简单地"所有异常都重试"。
kotlin
class SyncWorker(
appContext: Context,
params: WorkerParameters,
private val repository: SyncRepository
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
return try {
repository.syncOnce()
Result.success()
} catch (e: IOException) {
Result.retry()
} catch (e: HttpException) {
if (e.code() == 408 || e.code() == 429 || e.code() >= 500) {
Result.retry()
} else {
Result.failure(workDataOf("error" to "http_${e.code()}"))
}
} catch (e: InvalidPayloadException) {
Result.failure(workDataOf("error" to "invalid_payload"))
}
}
}
常见分类可以这样处理:网络断开、连接超时、服务端 5xx 和限流通常适合重试;鉴权失效、参数错误和无法解析的协议变更应该失败并触发业务告警。对于重试次数,还要考虑服务端是否支持幂等,以及任务是否会被系统重启后再次执行。
重试必须建立在幂等之上
假设同步接口返回同一条资料两次,直接 insert 可能造成重复数据。更稳妥的做法是让服务端对象携带稳定主键,在 Room 中使用唯一索引,并在一个事务中完成版本判断与写入。
kotlin
@Entity(
tableName = "profile",
indices = [Index(value = ["remoteId"], unique = true)]
)
data class ProfileEntity(
@PrimaryKey(autoGenerate = true) val localId: Long = 0,
val remoteId: String,
val version: Long,
val name: String
)
@Transaction
suspend fun replaceIfNewer(items: List<ProfileEntity>) {
items.forEach { item ->
val old = profileDao.findByRemoteId(item.remoteId)
if (old == null || item.version > old.version) {
profileDao.upsert(item)
}
}
}
如果接口支持增量同步,建议把服务端游标和资料更新放到同一个本地事务中。只有资料写入成功后才推进游标,避免游标先前移、数据却没有落库,最终造成不可恢复的漏同步。
从状态反推故障位置
排查可以按下面顺序进行:
- 先查询唯一任务是否存在,确认是没有提交、被
KEEP合并,还是已经完成。 - 如果状态是
ENQUEUED,查看约束、初始延迟、重试次数和是否被暂停。 - 如果状态是
RUNNING,检查 Worker 是否卡在网络、数据库事务或锁等待。 - 如果状态是
FAILED,读取受控的错误码,并关联最近一次运行日志。 - 如果状态是
SUCCEEDED,直接查询本地数据和同步游标,不要只看任务状态。 - 检查任务是否被取消,以及取消来源是用户操作、应用登出还是系统策略。
可以在调试页面展示脱敏后的任务快照,给测试和客服一个稳定的证据入口。线上则将任务名、错误码、运行次数和耗时接入统一日志,不依赖开发者手工复现。
常见误区
把 WorkManager 当作实时执行器
它适合可延迟、可保证最终执行的后台工作,不适合秒级刷新、持续音频处理或必须立即完成的交互。实时场景应选择前台服务、推送或应用内主动请求等更匹配的机制。
在 Worker 中无限等待
网络请求、互斥锁和数据库操作都应该有超时边界。无限挂起会让任务长期处于 RUNNING,同时占用系统调度资源,最终让后续诊断失去方向。
只在 UI 层观察任务
UI 进程可能被销毁,界面观察也可能因为页面离开而停止。任务诊断应沉淀在 repository 或诊断表,UI 只是读取快照并展示。
小结
可靠的后台任务不是"提交一个 Worker"这么简单,而是一套可解释的系统:约束说明何时执行,唯一任务说明如何合并,状态和日志说明发生了什么,错误分类决定是否重试,幂等事务保证重复执行不会破坏数据。把这些信息串起来后,后台同步从"偶尔失效"变成可以定位、修复和验证的工程问题。