Android WorkManager 深度解析:从 Constraint 调度引擎到 Doze 模式兼容的后台任务全链路架构实践
做性能优化时遇到过一个让我困惑了很久的问题:明明用 WorkManager 调度了一个网络同步任务,约束条件设置的是有网络时执行,但在某些设备上任务始终不触发。抓日志发现任务确实进了队列,状态也是 ENQUEUED,就是不跑。
后来定位到根因:那批设备开了激进的省电策略,Doze 模式把网络完全断掉,WorkManager 内部的约束检查永远拿到 false,任务就这样躺在数据库里。
这篇文章从这个场景出发,把 WorkManager 的调度机制拆开讲清楚。
WorkManager 的定位和底层选型逻辑
WorkManager 解决的不是”立刻做某件事”,而是”在合适的条件下,保证某件事最终会被做完”。这个”保证”是它区别于 Handler、ThreadPool 的核心。
在实现层,WorkManager 根据 API 版本自动选择底层调度器:
- API 23+:优先使用 JobScheduler
- API 23 以下(现在基本是历史了):退回到 AlarmManager + BroadcastReceiver
JobScheduler 是系统级服务,即使应用进程被杀,任务元数据保存在系统中,重启后仍然可以继续执行。WorkManager 在这之上加了一层 Room 数据库,把任务状态、约束、输入输出数据全部持久化,构成了双重可靠性保障。
// WorkManager 内部简化的调度器选择逻辑(源自 WorkManagerImpl)
val scheduler = when {
Build.VERSION.SDK_INT >= 23 -> SystemJobScheduler(context, ...)
else -> SystemAlarmScheduler(context, ...)
}
每个 WorkRequest 都映射为一条 WorkSpec 记录存入数据库,包含任务的完整描述:Worker 类名、约束集合、输入数据、重试策略、链式关系。调度器调度的单位是 WorkSpec,而不是 Worker 对象本身。
Constraint 调度引擎的工作原理
约束(Constraint)是 WorkManager 最常被浅用的功能,大多数人只知道设 setRequiredNetworkType,但不清楚约束是怎么被检测和触发的。
WorkManager 的约束检测走的是 ConstraintTracker 体系,每种约束类型对应一个 Tracker:
| 约束类型 | Tracker 实现 | 监听方式 |
|---|---|---|
| NetworkType | NetworkStateTracker | ConnectivityManager NetworkCallback |
| BatteryNotLow | BatteryNotLowTracker | BroadcastReceiver (ACTION_BATTERY_CHANGED) |
| StorageNotLow | StorageNotLowTracker | BroadcastReceiver (ACTION_DEVICE_STORAGE_OK/LOW) |
| RequiresCharging | BatteryChargingTracker | BroadcastReceiver |
| RequiresDeviceIdle | DeviceIdleTracker | BroadcastReceiver (ACTION_DEVICE_IDLE_MODE_CHANGED) |
这些 Tracker 由 WorkConstraintsTracker 聚合管理,只有所有约束同时满足,才会触发 onAllConstraintsMet() 回调,进而分发任务。
// 设置约束的典型写法
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build()
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
约束的满足是实时动态检测的,不是在任务入队时一次性判断。如果任务入队时网络不可用,WorkManager 会持续监听网络变化,一旦网络恢复就重新评估。
约束评估的时机
JobScheduler 提供了原生的约束支持(比如 setRequiredNetworkType),所以在 API 23+ 上,WorkManager 会把约束条件直接翻译给 JobScheduler,让系统层做约束检测,效率更高,也更省电。
JobScheduler 支持的约束粒度有限,NetworkType.UNMETERED 这样的细粒度约束,WorkManager 会在 JobScheduler 触发后再做一次应用层的二次校验。这个机制有一个实际影响:任务被唤醒不等于约束全部满足,依然可能在二次校验阶段被拦截。
Doze 模式与 WorkManager 的兼容策略
Doze 模式(Android 6.0 引入)是后台任务最常遇到的拦路虎。设备静置、息屏一段时间后,系统会周期性进入深度睡眠,期间:
- 网络访问被完全封锁
- WakeLock 不生效
- AlarmManager 普通 alarm 被推迟
- JobScheduler 的 job 被推迟到 Maintenance Window
Maintenance Window 是理解 Doze 兼容的关键。系统不是永远不执行任务,而是在 Doze 周期中留出短暂的窗口期(几秒到几十秒),在这个窗口里网络和 CPU 短暂恢复,积压的 job 可以集中执行。
WorkManager 基于 JobScheduler 运行,天然遵循这个机制。设置了网络约束的任务,在 Doze 状态下会等到 Maintenance Window 才执行——约束中的”网络可用”就是在那个窗口里短暂成立的。
// 在 Worker 里做网络请求,要考虑 Doze 窗口时间有限
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
// 设置合理的超时,避免占满整个 Maintenance Window
withTimeout(20_000) {
syncData()
}
Result.success()
} catch (e: TimeoutCancellationException) {
// 超时不算失败,下个窗口继续
Result.retry()
} catch (e: Exception) {
Result.failure()
}
}
}
setExpedited 和前台服务的取舍
任务对时效性有要求、不能等 Doze 窗口,有两条路可选。
路径一:setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
Android 12 引入的加速任务,系统给予更高优先级,Doze 期间也有一定保障。但每个应用有配额限制,滥用会被降级。
val urgentRequest = OneTimeWorkRequestBuilder<UrgentWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
路径二:绑定前台服务(ForegroundService)
WorkManager 支持在 Worker 中提升为前台服务,系统对前台服务的限制更宽松:
override suspend fun getForegroundInfo(): ForegroundInfo {
val notification = buildNotification()
return ForegroundInfo(NOTIFICATION_ID, notification)
}
在实际项目中我更倾向于用 Expedited Work,因为绑定前台服务要持续显示通知,对用户体验有侵入。只有真正长时间、用户感知的任务(比如文件上传进度)才值得走前台服务。
任务链与 WorkContinuation 的执行模型
WorkManager 支持任务链式编排,这是它比 JobScheduler 原生 API 强得多的地方。
// 串行链
WorkManager.getInstance(context)
.beginWith(fetchDataWork)
.then(processDataWork)
.then(uploadResultWork)
.enqueue()
// 并行 + 汇聚
val parallel = WorkManager.getInstance(context)
.beginWith(listOf(fetchUserWork, fetchProductWork))
.then(mergeWork) // 等两个并行任务都完成后执行
.enqueue()
任务链的依赖关系同样持久化在数据库里,WorkSpec 记录包含 prerequisite(前置任务)的 ID 列表。每当一个任务完成,WorkManager 会查询所有以它为前置的任务,检查是否所有前置都已完成,满足则进入调度队列。
这里有一个踩过的坑:链中某个任务返回 Result.failure(),默认行为是整条链停止执行,后续任务状态变为 CANCELLED。如果希望某个任务失败后链路继续,需要用 Result.success() 加错误标记在 OutputData 里传递,由下游任务判断处理。
// 用 OutputData 传递软失败状态
override suspend fun doWork(): Result {
return try {
val data = fetchOptionalData()
Result.success(workDataOf("data" to data))
} catch (e: Exception) {
// 不中断链路,把错误信息往下传
Result.success(workDataOf("error" to e.message))
}
}
调试和状态观测
WorkManager 提供了 LiveData 观测任务状态,在 UI 层做进度展示时很实用:
WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(syncRequest.id)
.observe(lifecycleOwner) { workInfo ->
when (workInfo?.state) {
WorkInfo.State.RUNNING -> showProgress()
WorkInfo.State.SUCCEEDED -> showSuccess()
WorkInfo.State.FAILED -> showError()
else -> Unit
}
}
调试 Doze 相关问题时,ADB 命令比日志更直接:
# 强制进入 Doze 模式(测试用)
adb shell dumpsys deviceidle force-idle
# 查看当前所有 WorkManager 任务状态
adb shell dumpsys jobscheduler | grep <your.package.name>
# 退出 Doze 测试模式
adb shell dumpsys deviceidle unforce
在实际项目中我发现,dumpsys jobscheduler 的输出能直接看到约束满足情况,比在代码里加 log 定位问题快得多。“Ready: false”后面跟着的约束列表会直接告诉你是哪个条件没满足,定位一个 Doze 相关问题可能只需要一条命令。
回到最开始的问题:那批设备的根因是厂商定制的省电策略把 Maintenance Window 压缩到极短,加上网络约束的存在,窗口期内网络还没完全恢复,任务就被跳过了。最终解法是把任务改成 Expedited,对于同步类任务这是更合适的策略。
几条可以直接落地的实践原则:
- 约束要保守设置:只加真正必要的约束。每多一个约束就多一层触发门槛,在 Doze 环境下叠加效果会让任务极难调度。
- Worker 里必须设超时:Doze Maintenance Window 时间极短,不加超时可能占满窗口导致其他任务饿死。
withTimeout配合Result.retry()是标准做法。 - 用
dumpsys jobscheduler调试:比起在代码里埋点,这个命令能给出系统视角的约束状态,定位速度快一个数量级。