如何在 WorkManager 中实现任务依赖(Chain)和取消特定任务?

解读

国内面试场景下,这道题常被用来区分“用过”与“真正落地”过 WorkManager 的候选人。
面试官想听的不只是 API 名字,而是:

  1. 任务链在业务中的典型形态:串行、并行、树状、交叉依赖;
  2. 取消粒度:按 id、按 tag、按链、按唯一链(UniqueWork);
  3. 取消后如何防止脏数据、如何感知状态、如何与前台 UI 联动;
  4. 对国产 ROM 后台限制的兜底策略(JobScheduler 被拦截、电量优化、AlarmManager fallback);
  5. 线上崩溃与 ANR 的排查经验(WorkManager 版本差异、Room 数据库锁、Worker 构造函数无参检查)。

回答时务必结合真实业务:例如“图片上传链”先压缩→加密→上传→上报,用户点击“取消上传”按钮后,整条链要干净地停掉并删除临时文件。

知识点

  1. WorkRequest 的三种类型:OneTimeWorkRequest、PeriodicWorkRequest、CoroutineWorker;
  2. 链式 API:beginWith() / then() / combine() / WorkContinuation;并行子链用 List<OneTimeWorkRequest> 一次性注入;
  3. 唯一链:enqueueUniqueWork(String uniqueName, ExistingWorkPolicy, WorkContinuation) 可防止重复入队;
  4. 取消 API:WorkManager.cancelWorkById(UUID)、cancelAllWorkByTag(String)、cancelUniqueWork(String)、cancelAllWork();
  5. 取消信号:Worker 内监听 isStopped() 或 Kotlin 的 coroutineContext.isActive,及时释放 native 资源、删除临时文件;
  6. 状态回调:WorkInfo.State.CANCELLED 会立即回调到 LiveData/Flow,UI 层可弹 Toast 并重置按钮;
  7. 数据库一致性:链中某个节点被取消,后续节点默认标记为 CANCELLED,不会执行;若需要“无论成败都清理”,可在 finally 块注册 cancel 监听器;
  8. 国产 ROM 适配:华为/小米/OPPO 对 JobScheduler 的 9 分钟调度限制,需在后台保活白名单引导页提示用户,或降级使用 setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST);
  9. 版本坑:2.7.0 之前 enqueueUniqueWork 的 ExistingWorkPolicy.REPLACE 不会自动取消旧链,导致重复上传;升级后需显式调用 cancelUniqueWork 再入队;
  10. 调试工具:adb shell dumpsys jobscheduler | grep your.package 查看 JobId;App 内集成 WorkManagerInspector 可实时查看链状态。

答案

以“图片上传”业务为例,演示“压缩→加密→上传→上报”链及用户随时取消。

  1. 定义 Worker
class CompressWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val uri = inputData.getString("img_uri") ?: return Result.failure()
        while (isActive) {              // 监听取消信号
            // 模拟压缩
            delay(500)
            break
        }
        if (!isActive) {                // 被用户取消
            File(uri).delete()          // 清理临时文件
            return Result.failure()
        }
        val outUri = compress(uri)
        return Result.success(workDataOf("compress_uri" to outUri))
    }
}

class EncryptWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val uri = inputData.getString("compress_uri") ?: return Result.failure()
        if (!isActive) return Result.failure()
        val outUri = encrypt(uri)
        return Result.success(workDataOf("encrypt_uri" to outUri))
    }
}

class UploadWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val uri = inputData.getString("encrypt_uri") ?: return Result.failure()
        if (!isActive) return Result.failure()
        val url = upload(uri)
        return Result.success(workDataOf("remote_url" to url))
    }
}

class ReportWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val url = inputData.getString("remote_url") ?: return Result.failure()
        if (!isActive) return Result.failure()
        report(url)
        return Result.success()
    }
}
  1. 组装链并入队
val compress = OneTimeWorkRequestBuilder<CompressWorker>()
    .addTag("upload_chain")
    .setInputData(workDataOf("img_uri" to localUri))
    .build()

val encrypt = OneTimeWorkRequestBuilder<EncryptWorker>()
    .addTag("upload_chain")
    .build()

val upload = OneTimeWorkRequestBuilder<UploadWorker>()
    .addTag("upload_chain")
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .build()

val report = OneTimeWorkRequestBuilder<ReportWorker>()
    .addTag("upload_chain")
    .build()

val chain = WorkManager.getInstance(context)
    .beginUniqueWork(
        "unique_upload_${localUri.hashCode()}",
        ExistingWorkPolicy.REPLACE,   // 防止重复点击生成多条链
        compress
    )
    .then(encrypt)
    .then(upload)
    .then(report)

chain.enqueue()
  1. 取消指定链
fun cancelUpload(localUri: String) {
    val uniqueName = "unique_upload_${localUri.hashCode()}"
    WorkManager.getInstance(context).cancelUniqueWork(uniqueName)
}
  1. UI 监听
WorkManager.getInstance(context)
    .getWorkInfosForUniqueWorkLiveData("unique_upload_${localUri.hashCode()}")
    .observe(owner) { list ->
        val finished = list?.any {
            it.state == WorkInfo.State.CANCELLED ||
            it.state == WorkInfo.State.FAILED ||
            it.state == WorkInfo.State.SUCCEEDED
        } == true
        if (finished) {
            // 刷新按钮状态
        }
    }
  1. 国产 ROM 兜底 在 Application 初始化时:
val config = Configuration.Builder()
    .setJobSchedulerJobIdRange(1000, 2000)
    .setMinJobSchedulerId(1000)
    .build()
WorkManager.initialize(this, config)

并在设置页引导用户关闭“省电优化”,确保链节点能在 9 分钟内被调度。

拓展思考

  1. 如果链中某个节点失败,如何只重试该节点而不重跑整条链?
    答:给单个 WorkRequest 设置 setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS),并在 doWork() 返回 Result.retry();其余节点无需改动,WorkManager 会自动重试失败节点,成功后继续下游。

  2. 如何支持“断点续传”式上传?
    答:在 UploadWorker 的 inputData 里带上已上传字节数,上传前查询服务器已接收长度,使用 HttpURLConnection.setRequestProperty("Range", "bytes=$alreadyUploaded-") 继续传;若 Worker 被系统杀死,下次重试时仍能从断点继续。

  3. 多端登录场景下,如何防止另一台设备重复上传同一张图片?
    答:利用唯一链名称里加入用户 ID + 图片 MD5,服务器上传接口先校验 MD5 是否已存在,若存在直接返回 URL,客户端 ReportWorker 依旧执行,保证两端数据一致。

  4. 如果业务要求“加密与上传并行”以缩短耗时,如何改造?
    答:将加密和上传设为并行节点,使用 WorkContinuation.combine() 合并两条子链,最后统一接入 ReportWorker;注意加密输出 URI 要放入 Data,上传节点读取即可。

  5. 线上出现“链状态卡在 ENQUEUED 但一直不执行”如何排查?
    答:先通过 adb shell dumpsys jobscheduler | grep your.package 看 JobId 是否被系统取消;再检查是否被省电策略拦截;最后确认 WorkManager 版本是否低于 2.7.0,低版本在 Android 12 上缺少 expedited 权限导致无法调度,升级后加 setExpedited() 即可。