WorkManager 后台任务调度

后台任务要在 Doze、省电模式与厂商保活策略之间可靠执行,靠 Service 或定时器都撑不住。本文讲透 WorkManager 的 Worker 与 CoroutineWorker 写法、约束条件与触发时机、链式与并行任务、唯一工作与替换策略、重试退避、加急工作、前台服务长任务,以及 Doze 下的执行保证与测试调试手段。

引言

后台任务是 Android 上「看起来简单、上线就出问题」的典型区域。用 Service 常驻会被系统杀掉,用 AlarmManager 在 Doze 下不准,用 Timer 在进程被杀后彻底消失,用 Handler 更是不堪一击。WorkManager 是 Google 给出的统一答案:它把任务持久化进本地数据库,交给系统的 JobScheduler(API 23+)或 AlarmManager + BroadcastReceiver(更低版本)执行,进程被杀、设备重启后依然能恢复。

但 WorkManager 不是「随便扔进去就会跑」。它有三个容易误解的地方:约束条件满足之前任务会一直排队,可能等几小时;PeriodicWorkRequest 的最小间隔是 15 分钟,且不是精确定时;加急工作(Expedited)有配额限制,超额会降级成普通任务。

本文按「基本写法 → 调度语义 → 高级能力 → 系统约束 → 集成与测试」的顺序展开。与页面生命周期相关的内容不在这里展开,需要时看 Android 生命周期与 ViewModel ;本文聚焦任务本身的调度与可靠性。


目录

  1. Worker 与 CoroutineWorker
  2. 约束条件与触发时机
  3. 输入输出与进度上报
  4. 链式任务与并行
  5. 唯一工作与替换策略
  6. 重试与退避策略
  7. Expedited 加急工作
  8. 前台服务与长任务
  9. Doze 与执行保证
  10. 与 Hilt、Room、Retrofit 的集成
  11. 测试与调试

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()
约束方法说明
网络类型setRequiredNetworkTypeCONNECTED / 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 的语义可以收成四句话:

  1. 持久化是它的核心价值:任务写进数据库,进程被杀、设备重启都能恢复;代价是「什么时候跑」由系统决定。
  2. 约束是必要条件不是触发条件:约束满足只是允许执行,实际时机还要看系统调度与待机分桶。
  3. 重试要自己设上限:retry() 没有默认次数限制,必须用 runAttemptCount 兜底,并且任务逻辑要幂等。
  4. 加急与前台服务是稀缺资源:配额与时长都有硬限制,只留给用户明确等待的任务。

把「幂等」「上限」「兜底」三条写进后台任务的 Code Review 清单,绝大多数「同步丢数据、任务重复执行、耗电异常」的问题都能在合入前拦住。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试