深入 Android JobScheduler 全链路:从 JobSchedulerService Binder 调度到 Doze/AppStandby 约束的延迟任务执行引擎

排查一个后台任务不执行的问题时,我在 WorkManager 日志里看到它最终调用了 JobScheduler.schedule()。继续往系统层追,发现 WorkManager 到 JobSchedulerService 之间的这一段在多数文章里是空白:约束怎么触发、Job 何时真正启动、Doze 与 App Standby 如何拦截,只能靠猜。

从 WorkManager 到 Binder 入口

WorkManager 在 API 23 以上默认用 SystemJobScheduler 执行。schedule 把 WorkSpec 映射成 JobInfo:Constraints.requiresCharging 对应 setRequiresCharging,网络类型映射到 setRequiredNetworkType,setExpedited 在 Android 12+ 走 JobInfo 的 expedited 标记。映射完成后调用:

JobScheduler scheduler = (JobScheduler)
    context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
scheduler.schedule(jobInfo);

getSystemService 返回的是 Binder 代理,真正实现是 system_server 里的 JobSchedulerService。schedule() 跨进程进入 scheduleAsPackage,入口参数带上调用方 uid 和包名。Job 从这一刻归属系统服务,与发起进程解耦。之后即使应用进程被杀,系统仍持有这个 Job。

JobStore 持久化与状态控制器链

JobSchedulerService 收到 Job 后先交给 JobStore。JobStore 把 Job 序列化成 XML,写入 /data/system/job/jobs.xml,重启后从文件恢复。内存里另维护一份 Job 映射表,状态机用它做快速查询。

决定执行时机的是 StateController 链。系统启动时注册一批控制器,每个负责一类约束:

  • TimeController:最小延迟、截止时间、周期
  • ConnectivityController:网络类型与网络可用性
  • BatteryController:充电、电量不足
  • StorageController:存储空间不足
  • DeviceIdleJobsController:Doze 深度空闲
  • AppIdleController:App Standby 应用闲置
  • QuotaController:应用执行配额
  • BackgroundJobsController:后台运行限制

每个控制器实现 StateChangedListener。约束条件变化时,对应系统服务回调控制器,控制器更新 Job 的 satisfiedConstraints 位掩码,再触发 evaluateControllerStatesLocked。评估逻辑把所有控制器当作「与」关系:只要有一项约束未满足,Job 就留在 pending 队列。实际项目里我遇到的后台任务「不执行」,多数停在这一层——不是约束没满足,就是控制器没收到状态变化回调。

Doze 与 App Standby 的拦截逻辑

DeviceIdleJobsController 订阅 DeviceIdleController 状态。设备息屏进入 deep doze 后,网络和 CPU 冻结,普通 Job 全部挂起,只有 maintenance window 会放出一段短窗口。这里有个容易踩的点:setRequiresDeviceIdle(true) 是要求设备空闲才执行,而 Doze 恰恰推迟大多数 Job,两者语义相反。

AppIdleController 读取 App Standby 的 idle bucket。处于 rare 或 restricted bucket 的应用,系统把 Job 推迟到维护窗口,且执行次数受限。WorkManager 的 expedited 任务能部分绕过 App Standby,但受 QuotaController 配额约束,不能滥用。

实测技巧:后台任务异常时,用 adb shell dumpsys jobscheduler 看 Job 的 unsatisfied constraints 和 bucket,再用 adb shell cmd jobscheduler run -f <package> <jobId> 强制触发,比加日志快。

执行链路:JobServiceContext 到 JobService 回调

约束全部满足后,Job 进入 ready 队列。JobConcurrencyManager 按包名和 uid 分配并发额度,避免单个应用占满 Job 槽位。随后 JobSchedulerService 通过 JobServiceContext 请求 ActivityManagerService 拉起目标进程,并绑定应用声明的 JobService。

class MyJobService : JobService() {
    override fun onStartJob(params: JobParameters): Boolean {
        // 异步任务返回 true,完成后必须 jobFinished
        doAsyncWork { jobFinished(params, false) }
        return true
    }
}

onStartJob 跑在主线程,超时未返回会触发 ANR,耗时操作必须切子线程。异步任务完成后调用 jobFinished(params, false) 释放占用,否则系统一直认为 Job 运行中,持续占用配额。JobServiceContext 内部用 BinderCallback 跟踪应用侧回调,应用崩溃或超时后,系统强制回收。

两个判断与一条选择

判断任务延迟是约束不满足还是 Doze 拦截,看 dumpsys 里 Job 的 Ready 状态、pending 标记和 unsatisfied constraints,比猜靠谱。

约束设计上,我更倾向把网络、充电这类硬约束交给 JobScheduler,把业务前置条件放在应用内判断。系统约束能进维护窗口统一调度,应用内条件只会让任务躺在队列里。计时类任务优先 setPeriodic,而不是 setMinimumLatency 加无限重排:前者享受电池优化窗口,后者每次重排都会刷新 Job 记录。