随着设备性能提升和软件生态发展,越来越多的 Android 应用需要执行相对更复杂的网络、异步和离线等任务。例如用户想要离线观看某个视频,又不想一直停留在应用界面等待下载完成,那么就需要以一定的方式让这些离线的过程在后台运行。再比如您想将一段精彩的 Vlog 分享到社交媒体,肯定也会希望视频上传时不会影响到自己继续使用设备。这就涉及到了我们今天分享的主题: 使用 WorkManager 管理后台和前台工作。
如果您更喜欢通过视频了解此内容,请 点击此处 查看。
本文将着重探讨 WorkManager 的 API 以及用法,帮助您深入了解它的运行机制,以及在实际开发中的使用方式。近期也将会有另一篇关于在 Android Studio 中如何更好地使用 WorkManager 的文章,敬请关注。
WorkManager 基础 API
从首个稳定版本发布以来,WorkManager 提供了一些基础 API,帮助您定义工作、放入队列、依次执行,且在工作完成时通知您的应用。以功能划分分类,这些基础 API 包括:
延迟执行
最初的版本中,这些工作只能被定义为延迟执行,也就是它们会在定义之后延期再开始执行。通过这种延期执行策略,一些不紧急或优先级不高的任务将会推后执行。
WorkManager 的延期执行会充分考虑设备的低电耗状态,以及应用的待机存储分区,因此您不必考虑工作需要在哪个具体时间被执行,这些都交给 WorkManager 考虑即可。
工作约束
WorkManager 支持对给定工作运行设定约束条件,约束 可确保将工作延迟到满足最佳条件时运行。例如,仅在设备采用不按流量计费的网络连接时、当设备处于空闲状态或者有足够的电量时运行。您可以专心开发应用的其他功能,将对工作条件的检查交给 WorkManager。
工作间的依赖关系
我们知道,工作之间是可能存在依赖关系的。比如您正在开发一个视频编辑应用,当剪辑完成后用户可能需要分享到社交媒体,于是您的应用需要依次渲染若干个视频片段,然后将它们一起上传到视频服务。这个过程是具有先后次序的,也就是上传工作依赖渲染工作的完成。
再举另外一个例子,当您的应用完成与后端同步数据后,也许您希望同步过程中产生的本地日志文件被及时清理,或者是将来自后端的新数据填充到本地数据库中。于是您可以请求 WorkManager 按照顺序或者并行执行这些工作,从而实现各个工作之间无缝衔接。而 WorkManager 会在确保所有给定条件都满足后再运行后续的 Worker。
多次执行的工作
很多具备与服务器同步功能的应用都具有这样的特点: 应用与后端服务器的同步往往不是一次性的,它可能是需要多次执行的。比如当您的应用提供在线编辑服务时,一定需要频繁将本地的编辑数据同步到云端,这就产生了定期执行的工作。
工作状态
由于您可以随时检查某个工作的状态,因此对于定期执行的工作而言,整个生命周期是透明的。您可以知道一个工作是处于队列等待、运行中、阻塞还是已完成状态。
WorkManager 现代 API
上述的基础 API 早在我们发布 WorkManager 的第一个稳定版时就已经提供了。首次在 Android 开发者峰会中谈到 WorkManager 时,我们把它看作是管理可延期后台工作的一个库。如今从底层的角度来看,这种观点仍然是成立的。但后来我们又添加了更多新功能,并让 API 更符合现代规范。
立即执行
现在,当您的应用处于前台时,您可以请求立即执行某项工作。随后即便应用被置于后台,这项工作也不会被中断,而是继续进行。所以,即使用户切换到别的应用去使用,您的应用仍然可以继续实现为照片添加滤镜、保存到本地、上传等一系列工作。
对于大型应用的开发商来说,他们需要在优化资源使用方面投入更多的资源和精力。但 WorkManager 可以凭借优秀的资源分配策略大大减轻他们的负担。
多进程 API
由于使用了新的多进程库处理工作,WorkManager 引入了新的 API,并进行了底层优化来帮助大型应用更有效地安排和执行工作。这得益于新的 WorkManager 可以在一个独立的进程中更高效地进行调度和处理。
强化的工作测试 API
应用发布到商店或是分发给用户之前,测试是非常重要的一个环节。因此我们增加了 API 来帮助您测试单独的 Worker 或是一组具备依赖关系的 Worker。
工具改进
在发布库的同时,我们还改进了众多开发者工具。作为开发者,您可以直接使用 Android Studio 来访问详尽的调试日志和检查信息。
开始使用 WorkManager
这些新引入的 API 和改进的工具在为开发者提供更大便利的同时,也促使我们重新思考使用 WorkManager 的最佳时机。虽然从技术角度,我们设计 WorkManager 的核心思想仍然是正确的,但对于日益复杂的开发生态而言,WorkManager 的能力已经大大超过我们的设计预期。
工作的 "持久化" 特性
WorkManager 可以处理您指派给它的任何类型的工作,因此它已经进化成了一个专门处理任务且值得信赖的好工具。WorkManager 在全局作用域中执行您定义的 Worker,这意味着只要您的应用还在运行,不论是设备方向的变化,还是 Activity 被回收等,您的工作会被一直留存。不过单凭这一点,还不能称之拥有 "持久化" 特性,因此 WorkManager 在底层还使用了 Room 数据库来保证当进程被结束或设备重启后,您的工作仍然可以执行,并有可能从中断位置继续执行。
执行需要长时间运行的工作
WorkManager 2.3 版本引入了对长时间运行的工作的支持。当我们谈到长时间运行的工作时,指的是运行时间超过 10 分钟执行窗口期的工作。通常情况下,一个 Worker 的执行窗口期被限定为 10 分钟。为了能实现长时间运行的工作,WorkManager 将 Worker 的生命周期与前台服务的生命周期捆绑在一起。JobScheduler 和进程内调度程序 (In-Process Scheduler) 仍然能感知到这种工作的存在。
由于前台服务掌握着工作执行的生命周期,而前台服务又需要向用户展示通知信息,所以我们向 WorkManager 添加了相关的 API。用户的注意力持续时间是有限的,所以 WorkManager 提供了 API 让用户能方便地通过通知停止长时间运行的工作。我们来分析一个长时间运行工作示例,代码如下:
代码语言:javascript复制class DownloadWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
fun notification(progress: String): Notification = TODO()
// notification 方法根据进度信息生成一条 Android 通知消息。
suspend fun download(inputUrl: String,
outputFile: String,
callback: suspend (progress: String) -> Unit) = TODO()
// 定义一个用于分块下载的方法
fun createForegroundInfo(progress: String): ForegroundInfo {
return ForegroundInfo(id, notification(progress))
}
override suspend fun doWork(): Result {
download(inputUrl, outputFile) { progress ->
val progress = "Progress $progress %"
setForeground(createForegroundInfo(progress))
} // 提供了一个 suspend 标记的 doWork 方法,其中调用下载方法,并显示最新进度信息。
return Result.success()
} //下载完成后,Worker 只需要返回成功即可
}
△ DownloadWorker 类
这里有一个 DownloadWorker 类,它扩展自 CoroutineWorker 类。我们会在这个类当中定义一些辅助方法来简化我们的工作。首先是一个 notification 方法,它可以根据所给定的进度信息生成一条 Android 通知消息。接下来我们要定义一个用于分块下载的方法,这个方法接受三个参数: 下载文件的 URL、文件保存的本地位置、suspend 回调函数。每当某个分块下载状态变化时,此回调就会被执行一次。于是,回调中携带的信息就可以被用来生成一条通知。
有了这些辅助方法,我们就可以将 WorkManager 执行长时间运行工作所需要的 ForegroundInfo 实例保存起来。ForegroundInfo 是由通知 ID 和通知实例组合构造而成的,请继续参照上述 CoroutineWorker 类的代码示例。
在这段代码里,我们提供了一个 suspend 标记的 doWork 方法,其中调用了刚才提到的分块下载辅助方法。由于每次回调发生时都会提供一些最新的进度信息,所以我们可以利用这些信息来构建通知,并调用 setForeground 方法来向用户显示这些通知。这里调用 setForeground 的操作正是导致 Worker 长时间运行的原因。下载完成后,Worker 只需要返回成功即可,随后 WorkManager 会将 Worker 的执行与前台服务解耦分离、清理通知消息,并在必要时结束相关的服务。因此我们的 Worker 本身并不需要执行服务管理工作。
终止已提交执行的工作
用户可能会突然改变主意,比如想要取消某个工作。某个前台运行服务的通知是无法简单滑动取消的,此前的做法是为这条通知消息添加一个动作,当用户点击时会向 WorkManager 发送一个信号,从而按照用户的意图终止某项工作。您也可以通过执行加急工作来终止,详见后文。
代码语言:javascript复制fun notification(progress: String): Notification {
val intent = WorkManager.getInstance(context)
.createCancelPendingIntent(getId())
return NotificationCompat.Builder(applicationContext, id)
.setContentTitle(title)
.setContentText(progress)
// 其他一些操作
.addAction(android.R.drawable.ic_delete, cancel, intent)
.build()
}
△ 派生自 CoroutineWorker 类的 DownloadWorker 类
首先需要创建一个待处理的 Intent,它可以很方便地取消某项工作。我们需要调用 getId 方法来获取这个工作创建时的工作请求 ID,然后调用 createCancelPendingIntent API 创建这个 Intent 实例。当此 Intent 被触发时,它会向 WorkManager 发送取消工作的信号,从而实现取消工作的目的。
接下来就要生成带有自定义动作的通知消息了。我们使用 NotificationCompat.Builder 设置通知的标题,然后添加一些文字说明。随后调用 addAction 方法将通知中的 "取消" 按钮与上一步创建的 Intent 关联起来。于是,当用户点击 "取消" 按钮时,这个 Intent 就会被发送到当前正在执行这个 Worker 的前台服务,从而将其终止。
执行加急工作
Android 12 中引入了新的前台服务限制,当应用在后台时是无法启动前台服务的。因此从 Android 12 开始,调用 setForegroundAsync 方法会抛出 Foreground Service Start Not Allowed Exception (不允许启动前台服务) 异常。这种情况下,WorkManager 就派上用场了。WorkManager 2.7 版本中增加了对加急工作 (expedited work) 的支持,所以接下来将会向您介绍如何使用 WorkManager 实现终止已提交执行的工作。
从用户的角度来说,加急工作是由用户发起的,因此对用户而言非常重要。甚至应用不在前台时,这些工作也需要被启动执行。比如聊天应用需要下载一条消息中的附件,或者应用需要处理付款订阅的流程。在早于 Android 12 的 API 版本中,加急工作都是由前台服务执行的,而从 Android 12 开始,它们将由加急作业 (expedited job) 实现。
系统以配额的形式限制了加急工作的数量。当应用处于前台时,加急工作不存在任何配额限制,但是当应用转到后台运行时,就必须遵从这些限制。配额的大小取决于应用的待机存储分区和进程重要性 (如优先级)。从字面意思来看,加急工作就是需要尽快启动执行的工作,这意味着此类工作对于延迟相当敏感,所以也就不支持设定初始延迟或是定期执行的设置。由于受到配额限制,加急工作也不可以取代长时间运行的工作。当您的用户想要发送一条重要信息时,WorkManager 会尽可能保证这条消息尽快发送。
代码语言:javascript复制class SendMessageWorker(context: Context, parameters: WorkerParameters):
CoroutineWorker(context, parameters) {
override suspend fun getForegroundInfo(): ForegroundInfo {
TODO()
}
override suspend fun doWork(): Result {
TODO()
}
}
△ 加急工作示例代码
例如,一个同步聊天应用消息的案例使用了加急工作 API。SendMessageWorker 类扩展自 CoroutineWorker,而它的作用是负责从后台为聊天应用同步消息。加急工作需要在某个前台服务的上下文中运行,这很类似于 Android 12 之前版本中的长时间运行的工作。因此我们的 Worker 类还需要实现 getForegroundInfo 接口,方便生成和显示通知消息。但是在 Android 12 上 WorkManager 不会显示其他的通知,这是因为我们定义的 Worker 背后是由加急作业实现的。您需要像平常那样实现一个 suspend 标记的 doWork 方法。需要注意的是,当您的应用占用了全部的配额后,加急作业可能会被中断。因此我们的 Worker 最好能跟踪某些状态,以便在重新安排执行时间后能够恢复运行。
代码语言:javascript复制val request = OneTimeWorkRequestBuilder<ForegroundWorker>()
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
.build()
WorkManager.getInstance(context)
.enqueue(request)
△ setExpedited API 示例代码
您可以使用 setExpedited API 来安排加急工作,这个 API 会告诉 WorkManager,用户认为给定的工作请求非常重要。由于所能安排的工作存在配额限制,所以您需要表明当应用的配额用尽时该怎么处理,有两种备选方案: 其一是将加急请求变成常规工作请求,其二是在配额耗尽时放弃新的工作请求。
WorkManager 多进程 API
从 2.5 版本开始,WorkManager 对支持多进程的应用进行了若干项改进。如果您需要使用多进程 API,就需要定义 work-multiprocess 工件的依赖项,多进程 API 的目标是在辅助进程中对 WorkManager 的冗余部分或高开销部分进行大范围初始化操作。比如有多个进程在同时获取统一底层 SQLite 数据库的事务锁,这时就会发生 SQLite 争用;而这种争用正是我们想要通过多进程 API 减少的。另一方面,我们还想确保进程内调度程序在正确的进程中运行。
为了解 WorkManager 初始化时哪些部分是冗余的,我们需要清楚它会在后台执行哪些操作。
单进程的初始化
△ 单进程的初始化过程
首先观察一下单进程初始化过程。应用启动后,第一件事是有平台调用 Application.onCreate 方法。随后在进程生命周期的某个时间点,WorkManager.getInstance 会被调用以启动 WorkManager 的初始化。当 WorkManager 初始化完毕后,我们运行 ForceStopRunnable。这个过程很重要,因为此时 WorkManager 会检查应用之前是否被强制停止过,它会比较 WorkManager 存储的信息与 JobScheduler 或 AlarmManager 中的信息,确保作业都被准确编入执行计划中。同时,我们也可以重新安排此前中断的某些工作,比如进程崩溃后进行的一些恢复工作。大家都知道,这样做的开销非常高,我们需要在多个子系统中比较和协调状态,但是理想状态下,这种操作只应该被执行一次。另外需要注意,进程内调度程序只在默认进程中运行。
多进程的初始化
△ 多进程的初始化过程
接着我们再看看如果应用有第二个进程会发生什么。假如应用有第二个进程,基本上它会重复在第一个进程中完成的各项操作。首先第一个进程如上文那样初始化,并且由于这是主进程 (primary process),所以进程内调度程序 (In-Process Scheduler) 也会在其中运行。对于第二个进程,我们会重复刚才的过程,再次调用 Application.onCreate,然后重新初始化 WorkManager。这意味着,我们将重复在第一个进程中所做的所有工作。
根据前面的分析,您也许会感到疑惑,为什么还需要再次执行 ForceStopRunable 呢?这是由于 WorkManager 并不知道这些进程中哪一个优先级较高。如果应用是屏幕键盘或者微件 (Widget),那么主进程可能并不等同于默认进程。另外,辅助进程 (secondary processes) 中也没有运行进程内调度程序 (因为它不是默认进程)。其实进程内调度程序所在的进程选择非常重要,由于它不受其他持久性调度器的限制影响,所以调整其所在的进程可以显著提升数据吞吐量。例如,JobScheduler 的作业上限是 100 个,而进程内调度程序则没有这个限制。
代码语言:javascript复制val config = Configuration.Builder()
.setDefaultProcessName("com.example.app")
.build()
△ 指定应用的默认进程示例代码
通过 WorkManager 定义主进程
我们来看看如何定义指定的默认进程。首先根据自己的意愿设置默认进程的名称,这通常是应用的软件包名称,一旦定义了应用的默认进程,那么进程内调度程序就会在其中运行。但是辅助进程怎么办?有没有办法能够防止在其中再次初始化 WorkManager?事实证明这是可以办到的。其实我们真正需要的是完全不必初始化 WorkManager。
为了实现这个目标,我们引入了 RemoteWorkManager。这个类需要绑定到指定进程 (主进程),并使用绑定服务将次要进程的所有工作请求转发到这个指定的主进程。这样一来,您就可以完全避免所有刚才提到的跨进程 SQLite 争用,因为从开始到结束只有唯一一个进程在向底层 SQLite 数据库写入数据。您可以跟往常一样在辅助进程中创建工作请求,但是此处应该使用 RemoteWorkManager 而不是 WorkManager。使用 RemoteWorkManager 后,会通过绑定服务绑定到主进程中,并将所有工作请求进行转发,然后存储到特定队列等待执行。您可以通过将 RemoteWorkManager 服务合并到应用的 Android Manifest RXML 中实现这个绑定。
代码语言:javascript复制val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.build()
RemoteWorkManager.getInstance(context)
.enqueue(request)
△ 使用 RemoteWorkManager 示例代码
代码语言:javascript复制<!-- AndroidManifest.xml -->
<service
android:name="androidx.work.multiprocess.RemoteWorkManagerService"
android:exported="false" />
△ Manifest 注册服务示例代码
不同进程中运行 Worker
我们已经了解如何通过 WorkManager 定义主进程来避免争用,但有时候,您也希望能够在不同的进程中运行 Worker。举个例子,如果您在某应用的辅助进程中运行机器学习工作流 (ML Pipeline),而且该应用还有专门的界面进程,那么您可能需要在不同的进程中运行不同的 Worker。比如在辅助进程中隔离执行某个工作,这样一来即使这个进程内出现错误而崩溃也不会导致应用的其他部分瘫痪而整体退出,尤其是要保障界面进程正常工作。要实现在不同进程中执行 Worker,您需要扩展 RemoteCoroutineWorker 类。这个类与 CoroutineWorker 类似,扩展之后您需要自己实现 doRemoteWork 接口。
代码语言:javascript复制public class IndexingWorker(
context: Context,
parameters: WorkerParameters
): RemoteCoroutineWorker(context, parameters) {
override suspend fun doRemoteWork(): Result {
doSomething()
return Result.success()
}
}
△ IndexingWorker 类示例代码
由于这个方法是在辅助进程中执行的,我们仍然要定义 Worker 需要与哪个进程绑定。为此,我们还需要在 Android Manifest RXML 中添加一个条目。一个应用可以定义多项 RemoteWorker 服务,每项服务都在独立的进程中运行。
代码语言:javascript复制<!-- AndroidManifest.xml -->
<service
android:name="androidx.work.multiprocess.RemoteWorkerService"
android:exported="false"
android:process=":background" />
△ Manifest 注册服务示例代码
这里您可以看到,我们为名为 background 的辅助进程添加了新服务。现在,您已经在 RXML 中定义好了服务,还需要进一步在工作请求中指明要绑定的组件名称。
代码语言:javascript复制val inputData = workDataOf(
ARGUMENT_PACKAGE_NAME to context.packageName,
ARGUMENT_CLASS_NAME to RemoteWorkerService::class.java.name
)
val request = OneTimeWorkRequestBuilder<RemoteDownloadWorker>()
.setInputData(inputData)
.build()
WorkManager.getInstance(context).enqueue(request)
△ 将 RemoteWork 对象放入队列示例代码
组件名称是软件包名和类名的组合,您需要将其添加到工作请求的输入数据中,然后用这个输入数据创建工作请求,这样一来 WorkManager 就知道要绑定哪项服务了。我们照常将工作放入队列中,当 WorkManager 准备执行这项工作时,它首先根据输入数据中定义的内容找到绑定的服务,并执行 doRemoteWork 方法。这样一来,所有复杂繁琐的跨进程通信的任务都交给 WorkManager 来处理了。
总结
WorkManager 是应对长执行时间工作的推荐方案,推荐您使用 WorkManager 实现请求和取消长时间运行的工作任务。通过本文了解到如何以及何时使用加急工作 API,如何编写可靠的高性能多进程应用。希望这篇文章对您有所帮助,下一篇文章将对新的后台任务检查器做出简单介绍,敬请关注!
如需更多资源,请参阅:
- Codelab: 使用 WorkManager 处理后台任务
- Codelab: WorkManager 进阶知识
- WorkManager 示例代码