17、协程与WorkManager:WorkManager的协程支持、CoroutineWorker的使用、长时运行任务的协程管理、WorkManager与协程的取消策略

说实话,WorkManager 刚出来那会儿,我其实有点抵触。总觉得 Google 又在造轮子。直到我在一个项目里要处理「上传日志 + 同步配置 + 清理缓存」这种后台任务链,才发现自己以前用 Service + 定时器那套方案有多脆弱——手机一杀进程,全白干。

后来 WorkManager 加入了协程支持,嗯,这才对味了。它把生命周期感知、任务持久化、电量优化这些脏活累活都包了,我们只需要写业务逻辑。今天我们就来聊聊,协程和 WorkManager 怎么配合,才能写出既优雅又健壮的后台任务。

WorkManager 的协程支持:从 ListenableWorker 到 CoroutineWorker

WorkManager 本身是基于 ListenableWorker 的,它用 ListenableFuture 做异步回调。说实话,这玩意儿写起来挺别扭的——你要么用 Guava 的 Futures,要么自己转成回调。我早期项目里就吃过这个亏,回调嵌套多了,代码跟意大利面似的。

后来 Google 推出了 CoroutineWorker,本质上是把 ListenableWorker 的异步模型用协程包装了一层。你想想看,我们只需要继承 CoroutineWorker,重写 suspend doWork() 方法,就能直接用协程了。多舒服。

核心区别:

  • ListenableWorker:返回 ListenableFuture<Result>,需要手动管理线程
  • CoroutineWorker:返回 suspend Result,自动运行在 Dispatchers.Default

CoroutineWorker 的使用:一个完整的例子

直接上代码吧。假设我们要做一个「夜间数据同步」的任务——把本地缓存的用户操作记录上传到服务器。

class SyncWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        // 注意:这里默认运行在 Dispatchers.Default
        return try {
            val records = fetchLocalRecords()
            uploadToServer(records)
            clearLocalRecords()
            Result.success()
        } catch (e: IOException) {
            // 网络错误,可以重试
            if (runAttemptCount < 3) {
                Result.retry()
            } else {
                Result.failure()
            }
        } catch (e: Exception) {
            // 其他错误,直接失败
            Result.failure()
        }
    }

    private suspend fun fetchLocalRecords(): List<Record> {
        // 用 Room 查询,Room 本身支持协程
        return withContext(Dispatchers.IO) {
            database.recordDao().getUnsyncedRecords()
        }
    }

    private suspend fun uploadToServer(records: List<Record>) {
        // 网络请求,用 Retrofit 的协程支持
        apiService.uploadRecords(records)
    }
}

这里有个细节我特别想提一下:doWork() 是 suspend 函数,但它的调度器是 Dispatchers.Default。如果你在里面做数据库操作或网络请求,最好用 withContext(Dispatchers.IO) 切一下。我曾经见过有人直接在 doWork() 里用 GlobalScope.launch 开新协程——千万别这么干,WorkManager 不会等你那个协程结束的。

长时运行任务的协程管理

有些任务不是几秒钟能搞定的。比如下载一个大文件、处理一批图片。这时候用普通的 CoroutineWorker 可能会被系统杀掉——因为 WorkManager 默认认为你的任务应该在 10 分钟内完成。

对于长时任务,WorkManager 提供了 ForegroundInfo 机制。说白了,就是让你的任务变成一个前台服务,显示一个通知。这样系统就不会轻易杀你了。

class DownloadWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        // 设置前台通知
        val notification = createNotification("正在下载...")
        setForeground(ForegroundInfo(1, notification))

        return try {
            downloadLargeFile()
            Result.success()
        } catch (e: Exception) {
            Result.failure()
        }
    }

    override suspend fun getForegroundInfo(): ForegroundInfo {
        return ForegroundInfo(1, createNotification("下载中"))
    }

    private fun createNotification(content: String): Notification {
        // 构建通知,省略具体代码
    }
}

我个人习惯在 doWork() 一开始就调用 setForeground(),而不是等到真正开始下载才设置。为什么?因为从任务被调度到真正执行,中间可能有几秒延迟。如果用户在这几秒内把应用划掉,你的任务可能还没拿到前台权限就被杀了。嗯,这个坑我踩过。

小技巧:如果你需要定期更新通知进度,可以在协程里用 withContext(NonCancellable) 来更新通知。因为 setForeground() 本身不是挂起函数,但更新通知可能需要切到主线程。

WorkManager 与协程的取消策略

这是最容易出问题的地方。WorkManager 有自己的取消机制——当你调用 WorkManager.cancelWorkById() 时,它会尝试中断你的 Worker。对于 CoroutineWorker,这个中断是通过协程的取消协作机制实现的。

说白了,WorkManager 会取消 doWork() 所在的协程。如果你的代码没有正确处理取消信号,任务可能不会真正停止。

class CancellableWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            // 模拟一个可取消的循环
            for (i in 0 until 100) {
                // 检查是否被取消
                if (isStopped) {
                    return Result.failure()
                }
                // 或者用 ensureActive()
                ensureActive()

                processItem(i)
            }
            Result.success()
        } catch (e: CancellationException) {
            // 协程被取消时,这里会被触发
            // 注意:不要吞掉 CancellationException
            throw e
        }
    }

    private suspend fun processItem(index: Int) {
        // 模拟耗时操作
        delay(100)
    }
}

这里有个关键点:isStoppedCoroutineWorker 提供的属性,它会在 WorkManager 要求停止时变为 true。而 ensureActive() 是协程的取消检查函数。我个人建议两者都用——isStopped 用于快速返回,ensureActive() 用于抛出 CancellationException 让协程框架处理。

我曾经踩过的坑:doWork() 里用了 try-catch 捕获了 CancellationException 但没有重新抛出。结果任务被取消后,WorkManager 以为它正常完成了,返回了 Result.success()。这会导致下次调度时,系统认为这个任务已经成功执行了,不会再重试。正确的做法是:要么不捕获 CancellationException,要么捕获后重新抛出。

知识体系总览

下面这张图梳理了协程与 WorkManager 的核心关系。你可以看到,从任务定义到执行调度,再到取消处理,整个链路都是围绕协程的挂起和取消机制展开的。

协程与 WorkManager 核心架构 CoroutineWorker suspend fun doWork() ForegroundInfo 长时任务前台通知 调度器 Dispatchers.Default 执行流程 检查取消状态 执行任务逻辑 处理异常 返回 Result 取消策略 isStopped 检查 快速返回 ensureActive() 抛出 CancellationException withContext(NonCancellable) 不可取消区域 重抛异常 不要吞掉

一些实战建议

最后,分享几个我在实际项目中积累的经验:

  • 不要用 GlobalScope:在 CoroutineWorker 里开 GlobalScope.launch 会导致协程生命周期与 Worker 脱节。任务取消后,那个协程还在跑,浪费资源。
  • 合理设置重试策略Result.retry() 配合 BackoffPolicy 使用。我一般设置指数退避,初始延迟 10 秒,最多重试 3 次。网络波动时很管用。
  • 测试取消逻辑:在单元测试里模拟 WorkManager.cancelWorkById(),验证你的 Worker 是否能正确响应取消。我见过太多人只测正常流程,结果线上取消时出问题。
  • 注意内存泄漏:如果 doWork() 里持有 Activity 或 Fragment 的引用,即使任务被取消,也可能导致内存泄漏。用 applicationContext 或者只传必要的数据。

好了,关于协程与 WorkManager 的配合,核心就是这些。说白了,就是把 WorkManager 的生命周期管理能力和协程的异步能力结合起来。你只要记住:用 CoroutineWorker 替代 ListenableWorker,用 isStoppedensureActive() 处理取消,用 ForegroundInfo 处理长时任务。剩下的,交给协程的挂起和恢复机制就好。

一句话总结:WorkManager 负责「什么时候做、做多久、要不要重做」,协程负责「怎么做、怎么停、怎么不卡主线程」。两者各司其职,配合起来非常舒服。


公众号:蓝海资料掘金营,微信deep3321