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 实现监听方式
NetworkTypeNetworkStateTrackerConnectivityManager NetworkCallback
BatteryNotLowBatteryNotLowTrackerBroadcastReceiver (ACTION_BATTERY_CHANGED)
StorageNotLowStorageNotLowTrackerBroadcastReceiver (ACTION_DEVICE_STORAGE_OK/LOW)
RequiresChargingBatteryChargingTrackerBroadcastReceiver
RequiresDeviceIdleDeviceIdleTrackerBroadcastReceiver (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,对于同步类任务这是更合适的策略。

几条可以直接落地的实践原则:

  1. 约束要保守设置:只加真正必要的约束。每多一个约束就多一层触发门槛,在 Doze 环境下叠加效果会让任务极难调度。
  2. Worker 里必须设超时:Doze Maintenance Window 时间极短,不加超时可能占满窗口导致其他任务饿死。withTimeout 配合 Result.retry() 是标准做法。
  3. dumpsys jobscheduler 调试:比起在代码里埋点,这个命令能给出系统视角的约束状态,定位速度快一个数量级。