引言
后台任务是 Android 上「看起来简单、上线就出问题」的典型区域。用 Service 常驻会被系统杀掉,用 AlarmManager 在 Doze 下不准,用 Timer 在进程被杀后彻底消失,用 Handler 更是不堪一击。WorkManager 是 Google 给出的统一答案:它把任务持久化进本地数据库,交给系统的 JobScheduler(API 23+)或 AlarmManager + BroadcastReceiver(更低版本)执行,进程被杀、设备重启后依然能恢复。
但 WorkManager 不是「随便扔进去就会跑」。它有三个容易误解的地方:约束条件满足之前任务会一直排队,可能等几小时;PeriodicWorkRequest 的最小间隔是 15 分钟,且不是精确定时;加急工作(Expedited)有配额限制,超额会降级成普通任务。
本文按「基本写法 → 调度语义 → 高级能力 → 系统约束 → 集成与测试」的顺序展开。与页面生命周期相关的内容不在这里展开,需要时看 Android 生命周期与 ViewModel ;本文聚焦任务本身的调度与可靠性。
目录
- Worker 与 CoroutineWorker
- 约束条件与触发时机
- 输入输出与进度上报
- 链式任务与并行
- 唯一工作与替换策略
- 重试与退避策略
- Expedited 加急工作
- 前台服务与长任务
- Doze 与执行保证
- 与 Hilt、Room、Retrofit 的集成
- 测试与调试
1. Worker 与 CoroutineWorker
WorkManager 的核心抽象是 Worker。Kotlin 项目一律用 CoroutineWorker,因为它直接给你一个 suspend fun doWork(),不需要自己管 ListenableFuture。
dependencies {
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.hilt:hilt-work:1.2.0") // 需要注入时
ksp("androidx.hilt:hilt-compiler:1.2.0")
testImplementation("androidx.work:work-testing:2.10.0")
}
class SyncWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val since = inputData.getLong(KEY_SINCE, 0L)
return try {
Result.success(workDataOf(KEY_SYNCED to repository.syncSince(since)))
} catch (e: IOException) {
Result.retry() // 可恢复错误:交给退避策略重试
} catch (e: Exception) {
Result.failure() // 不可恢复错误:直接失败
}
}
companion object { const val KEY_SINCE = "since"; const val KEY_SYNCED = "synced" }
}
三种返回值的语义必须分清:success() 表示完成(后续链式任务可以继续),retry() 表示稍后重试(受退避策略控制),failure() 表示永久失败(后续链式任务被取消)。把网络超时返回成 failure() 是最常见的误用——它会让整条链断掉,而用户只是当时没网。
入队用构建器,OneTimeWorkRequestBuilder<T>() 是 KTX 提供的扩展:
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf(SyncWorker.KEY_SINCE to lastSyncAt))
.addTag("sync")
.build()
WorkManager.getInstance(context).enqueue(request)
2. 约束条件与触发时机
约束(Constraints)是 WorkManager 最有价值的能力,也是最容易被误用的能力:约束只表示「必须满足」,不表示「满足就立刻执行」。
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // 仅 Wi-Fi
.setRequiresCharging(true)
.setRequiresBatteryNotLow(true)
.build()
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(constraints)
.build()
| 约束 | 方法 | 说明 |
|---|---|---|
| 网络类型 | setRequiredNetworkType | CONNECTED / UNMETERED / NOT_ROAMING / NOT_REQUIRED |
| 充电中 | setRequiresCharging(true) | 大文件上传、模型下载 |
| 设备空闲 | setRequiresDeviceIdle(true) | API 23+,仅在 Doze 维护窗口满足 |
| 电量不低 | setRequiresBatteryNotLow(true) | 避免低电量时耗电 |
| 存储不低 | setRequiresStorageNotLow(true) | 需要写文件的同步 |
任务的实际触发时机是「所有约束满足 + 系统愿意调度」的交集。约束全满足也可能被推迟,因为系统要综合电量、内存与前台应用的优先级。因此任务逻辑必须是幂等的,并且不能假设「入队后 5 分钟内一定跑」。
延迟执行用 setInitialDelay,周期任务用 PeriodicWorkRequestBuilder:
val periodic = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.setInitialDelay(1, TimeUnit.HOURS)
.build()
周期任务的最小间隔是 15 分钟,这是系统硬限制,写更小的值会被静默提升到 15 分钟。周期任务也不是精确定时:它只在「上一轮结束后 + 间隔」且约束满足时执行,实际间隔经常大于 15 分钟。
3. 输入输出与进度上报
Data 是 WorkManager 传递参数的容器,底层是 Bundle,因此只支持基本类型、String、数组与 Parcelable,且有大小限制(约 10KB)。
// 输入
val input = workDataOf("url" to url, "retries" to 0)
// 输出:在 doWork 中返回
Result.success(workDataOf("bytes" to 4096))
读取输出需要观察 WorkInfo,用 WorkManager.getWorkInfoByIdFlow(id) 拿到 Flow<WorkInfo?>,再按 info.state 分派(SUCCEEDED 读 outputData、FAILED 展示错误)。进度上报用 setProgress,它同样走 Data:
override suspend fun doWork(): Result {
repeat(100) { i -> setProgress(workDataOf("percent" to i)); delay(50) }
return Result.success()
}
setProgress 在 CoroutineWorker 里是挂起安全的,但注意进度更新频率别太高:每次更新都会写数据库,100 毫秒一次已经偏密,UI 层用 WorkInfo.progress 观察即可。
WorkInfo.State 有六种取值,理解它们的流转才能正确写 UI:
| 状态 | 含义 | 会转入 |
|---|---|---|
ENQUEUED | 已入队,等待约束 | RUNNING / CANCELLED |
RUNNING | 正在执行 | SUCCEEDED / FAILED / ENQUEUED(retry) |
SUCCEEDED | 成功 | 终态 |
FAILED | 失败 | 终态 |
BLOCKED | 被前置任务阻塞 | ENQUEUED |
CANCELLED | 被取消 | 终态 |
4. 链式任务与并行
beginWith().then() 构成有向无环图,WorkManager 保证顺序与依赖。
// 串行:下载 → 解析 → 上传
WorkManager.getInstance(context)
.beginWith(downloadRequest).then(parseRequest).then(uploadRequest)
.enqueue()
// 并行:两个下载同时跑,都完成后再合并
WorkManager.getInstance(context)
.beginWith(listOf(downloadA, downloadB)).then(mergeRequest)
.enqueue()
链式任务的语义细节:
- 前一个任务的
outputData会作为后一个任务的inputData的基础,后者的setInputData会覆盖同名字段。这条规则让「下载任务的输出直接喂给解析任务」成为可能。 - 任一任务返回
failure()或Result.failure(),后续任务全部被标记为CANCELLED。 - 任一任务返回
retry(),整条链暂停,直到它重试成功。 - 并行分支中只要有一个失败,其他分支会被取消。
因此链式任务适合「步骤之间有数据依赖」的场景;纯粹的批量任务用 enqueue 多次独立入队更合适,避免一个失败连累全部。
5. 唯一工作与替换策略
同一个任务被多次触发时(如用户反复点「同步」),需要唯一工作(Unique Work)来避免重复入队。
WorkManager.getInstance(context).enqueueUniqueWork(
"sync",
ExistingWorkPolicy.KEEP, // KEEP / REPLACE / APPEND / APPEND_OR_REPLACE
request,
)
| 策略 | 行为 | 适用 |
|---|---|---|
KEEP | 已有同名任务则忽略本次 | 幂等同步,防重复触发 |
REPLACE | 取消旧的,换新的 | 参数变了要重新执行 |
APPEND | 追加到现有链尾 | 需要串行处理多个请求 |
APPEND_OR_REPLACE | 失败时替换,否则追加 | 队列语义 + 容错 |
周期任务的唯一策略是另一套枚举 ExistingPeriodicWorkPolicy,几乎总是应该用 UPDATE:它保留原有的下次执行时间,不会因为 App 每次启动都重新入队而不断推迟;而 REPLACE 会重置整个周期。
6. 重试与退避策略
Result.retry() 配合退避配置决定重试节奏,runAttemptCount 用来做次数上限。
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS) // 初始延迟最小 10 秒
.build()
// Worker 内自己设重试上限
override suspend fun doWork(): Result {
if (runAttemptCount >= 5) return Result.failure()
return try { repository.sync(); Result.success() }
catch (e: IOException) { Result.retry() }
}
退避的默认值是「指数退避 + 30 秒初始延迟」,上限 5 小时。指数退避的实际间隔序列是 10s → 20s → 40s → 80s…(乘以 2 再叠加随机抖动),上限封顶在 5 小时。
两个必须注意的点:
- 重试次数没有默认上限。
retry()会一直重试直到成功或达到退避上限,必须在业务里用runAttemptCount设阈值,否则一个永久失败的任务会永远留在队列里。 - 重试不是立即的。即使初始延迟设为 10 秒,实际执行还要等约束满足与系统调度,可能间隔数十分钟。
7. Expedited 加急工作
普通任务在约束满足后仍可能被系统推迟很久,加急工作(Expedited Work)用来表达「这个任务需要尽快跑」。
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
OutOfQuotaPolicy 只有两个取值:
| 取值 | 配额耗尽时的行为 |
|---|---|
RUN_AS_NON_EXPEDITED_WORK_REQUEST | 降级为普通任务继续排队 |
DROP_WORK_REQUEST | 直接丢弃 |
加急工作的实现细节随版本变化,理解它对排错很重要:
- API 31+:走 JobScheduler 的 expedited job,系统按应用待机分桶(standby bucket)分配配额,前台应用配额宽松、受限应用几乎没有。
- API 30 及以下:WorkManager 会启动一个前台服务(
SystemForegroundService),因此CoroutineWorker必须实现getForegroundInfo(),否则抛IllegalStateException。 - 加急工作不能有约束条件(网络、充电等),也不能设置初始延迟,否则入队时抛异常。
class UrgentSyncWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun getForegroundInfo(): ForegroundInfo =
ForegroundInfo(NOTIFICATION_ID, buildNotification(), FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
配额是有限的,把加急当默认选项会导致大部分任务被降级,反而更慢。加急只应留给「用户明确等待结果」的场景,如消息发送。
8. 前台服务与长任务
超过 10 分钟的任务在 Android 12+ 会被系统视为「长任务」而可能被杀,WorkManager 的应对方式是允许 Worker 升级为前台服务。
override suspend fun doWork(): Result {
setForeground(getForegroundInfo()) // 声明为前台服务
return Result.success() // 长时间工作,如大文件上传
}
配套的 manifest 需要声明 androidx.work.impl.foreground.SystemForegroundService 并设置 android:foregroundServiceType="dataSync"(Android 14 起 foregroundServiceType 是必需项),同时申请 FOREGROUND_SERVICE、FOREGROUND_SERVICE_DATA_SYNC 与 POST_NOTIFICATIONS 权限。
几条实践约束:
dataSync类型在 Android 15 起有每日运行时长上限(约 6 小时),超时会被系统停止。- 前台服务必须展示通知,用户可感知,因此只适合「用户发起且期望完成」的任务,不适合后台静默同步。
setForeground必须在doWork()开始后尽早调用,超过 10 分钟未调用会被Stopped中断。
9. Doze 与执行保证
WorkManager 的执行保证可以概括成一句话:任务不会丢,但时间不确定。
| 系统状态 | 普通任务 | 加急任务 |
|---|---|---|
| 前台 / 活跃 | 正常调度 | 立即 |
| Doze(设备静止熄屏) | 推迟到维护窗口 | 配额内可执行 |
| App Standby 受限桶 | 大幅推迟 | 配额极少 |
| 省电模式 | 推迟,且约束更严 | 受限 |
| 应用被强行停止 | 全部取消,重启后不恢复 | 同左 |
几个必须知道的事实:
- Doze 期间网络被切断,
NetworkType.CONNECTED约束在维护窗口之外不会满足,任务自然等待。这是设计行为,不是 bug。 - 周期任务在 Doze 下会被合并到维护窗口,多个周期任务可能在同一时间一起执行,后端接口要能承受突发。
- 用户强行停止应用会取消所有任务,且不会在设备重启后恢复。这是 WorkManager 也无法突破的系统限制,重要数据必须有「下次启动时补同步」的兜底。
- 设备重启后,WorkManager 通过
RescheduleReceiver恢复未完成的任务(前提是应用未被强停)。 - 定位到具体任务的调试命令:
adb shell dumpsys jobscheduler | grep -A 20 com.example.app # JobScheduler 中的任务
adb shell am get-standby-bucket com.example.app # 应用当前待机分桶
如果任务「不执行」,排查顺序是:约束是否满足(网络/充电)、应用是否在受限待机桶、是否被用户强停、加急配额是否耗尽。
10. 与 Hilt、Room、Retrofit 的集成
Worker 由系统反射实例化,因此不能直接用 @Inject constructor 拿依赖,需要 @HiltWorker 与 @AssistedInject:
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val repository: ArticleRepository, // 来自依赖图
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
repository.refresh()
return Result.success()
}
}
配置方式是让 Application 实现 Configuration.Provider 并提供 HiltWorkerFactory,同时在 manifest 中移除默认的 WorkManagerInitializer,细节见 Android 依赖注入与 Hilt
的 Worker 一节。
Worker 内部调用的数据层通常就是 Room 与 Retrofit 的组合:api.listSince(since) 拉取远端,dao.upsertAll(remote.map { it.toEntity() }) 落库。@Upsert 提供的幂等写入是「任务可能重复执行」这一前提的必要保障,具体写法见 Android 数据持久化与 Room
。网络层的超时与重试交给 OkHttp,业务层的重试交给 WorkManager 的退避策略,两者不要叠三层——常见错误是 OkHttp 重试 3 次、Retrofit 再重试、WorkManager 又重试 5 次,一次失败实际发起 15 次请求。
11. 测试与调试
WorkManager 提供了完整的测试基础设施,关键是用 WorkManagerTestInitHelper 替换真实调度器。
@Before
fun setUp() {
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setExecutor(SynchronousExecutor()) // 让任务同步执行,便于断言
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@Test
fun sync_retriesOnNetworkError() {
val request = OneTimeWorkRequestBuilder<SyncWorker>().build()
val manager = WorkManager.getInstance(context)
manager.enqueue(request).result.get()
val testDriver = WorkManagerTestInitHelper.getTestDriver(context)!!
testDriver.setAllConstraintsMet(request.id) // 手动满足约束
testDriver.setInitialDelayMet(request.id) // 手动跳过延迟
val info = manager.getWorkInfoById(request.id).get()
assertEquals(WorkInfo.State.SUCCEEDED, info.state)
}
Worker 逻辑本身可以用 TestListenableWorkerBuilder<SyncWorker>(context).setInputData(...).build() 单测,再直接断言 worker.doWork() 的返回值,不依赖 WorkManager 的调度。调试线上问题时,WorkInfo 的 stopReason 字段能说明任务为何被停止:
stopReason | 含义 |
|---|---|
STOP_REASON_CONSTRAINT_CONSTRAINTS_NOT_MET | 约束不再满足 |
STOP_REASON_TIMEOUT | 超过 10 分钟未调用 setForeground |
STOP_REASON_APP_STANDBY | 应用进入受限待机桶 |
STOP_REASON_FOREGROUND_SERVICE_TIMEOUT | 前台服务时长超限 |
STOP_REASON_CANCELLED_BY_APP | 应用主动取消 |
把 stopReason 打点上报,能让「任务没跑」这类问题从猜测变成有据可查。
权衡取舍
| 方案 | 可靠性 | 时间精度 | 适用场景 |
|---|---|---|---|
WorkManager | 高(持久化、可恢复) | 分钟级 | 绝大多数后台任务 |
| 前台服务 | 高(用户可感知) | 秒级 | 用户发起的长任务 |
AlarmManager | 中 | 秒级(setExactAndAllowWhileIdle) | 精确到点的提醒 |
Handler / Timer | 无(进程死即消失) | 毫秒级 | 进程内的短时轮询 |
| 服务端推送驱动 | 高 | 秒级 | 实时性要求高的同步 |
选型顺序建议是:能用推送驱动的同步就用推送,剩下的交给 WorkManager;只有「必须精确到某分钟」的闹钟类需求才用 AlarmManager;前台服务留给用户明确等待的任务,不要为了保活而常驻。
常见坑清单
| 坑 | 现象 | 规避方式 |
|---|---|---|
把网络错误返回 failure() | 链式任务全断、无法重试 | 可恢复错误返回 retry() |
retry() 不设次数上限 | 任务永远留在队列 | 用 runAttemptCount 设阈值 |
| 周期任务写小于 15 分钟 | 被静默提升到 15 分钟 | 接受系统限制,用 WorkManager 之外的手段处理短周期 |
周期任务用 REPLACE | 每次启动都重置周期 | 用 ExistingPeriodicWorkPolicy.UPDATE |
用 Data 传大对象 | 入队抛异常或截断 | 只传标识,大数据走数据库或文件 |
| 加急任务带约束 | 入队直接抛异常 | 加急任务不加约束与初始延迟 |
| 加急当默认选项 | 配额耗尽后大面积降级 | 只用于用户等待结果的场景 |
未实现 getForegroundInfo() | API 30 及以下崩溃 | 加急 Worker 必须实现 |
Android 14 未声明 foregroundServiceType | 启动前台服务失败 | manifest 补类型与权限 |
| 假设任务必然执行 | 强停后任务丢失 | 关键数据加「启动时补同步」兜底 |
| 重试叠了三层 | 一次失败发出十几次请求 | 只在一层做重试 |
| 任务逻辑不幂等 | 重复执行导致数据重复 | 用 @Upsert 或唯一约束 |
小结
WorkManager 的语义可以收成四句话:
- 持久化是它的核心价值:任务写进数据库,进程被杀、设备重启都能恢复;代价是「什么时候跑」由系统决定。
- 约束是必要条件不是触发条件:约束满足只是允许执行,实际时机还要看系统调度与待机分桶。
- 重试要自己设上限:
retry()没有默认次数限制,必须用runAttemptCount兜底,并且任务逻辑要幂等。 - 加急与前台服务是稀缺资源:配额与时长都有硬限制,只留给用户明确等待的任务。
把「幂等」「上限」「兜底」三条写进后台任务的 Code Review 清单,绝大多数「同步丢数据、任务重复执行、耗电异常」的问题都能在合入前拦住。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。