1. 简介
在使用 WorkManager 执行后台工作 Codelab 中,您学习了如何使用 WorkManager 在后台(非主线程)执行工作。在此 Codelab 中,您将继续学习 WorkManager 的功能,以确保工作的唯一性、标记工作、取消工作和工作限制。本 Codelab 的最后,您将学习如何编写自动化测试来验证您的 worker 是否正常运行并返回预期结果。您还将学习如何使用 Android Studio 提供的 后台任务检查器 来检查排队的 worker。
您将构建什么
在此 Codelab 中,您将确保工作的唯一性、标记工作、取消工作以及实现工作限制。然后,您将学习如何为 Blur-O-Matic 应用编写自动化 UI 测试,以验证在使用 WorkManager 执行后台工作 Codelab 中创建的三个 worker 的功能
BlurWorker
CleanupWorker
SaveImageToFileWorker
您将学到什么
您需要什么
- 最新稳定版 Android Studio
- 完成使用 WorkManager 执行后台工作 Codelab
- Android 设备或模拟器
2. 设置
下载代码
点击以下链接下载本 Codelab 的所有代码
或者,如果您愿意,可以从 GitHub 克隆代码
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-workmanager.git $ cd basic-android-kotlin-compose-training-workmanager $ git checkout intermediate
在 Android Studio 中打开项目。
3. 确保工作唯一性
既然您已经了解如何链式调用 worker,是时候学习 WorkManager 的另一个强大功能了:唯一工作序列。
有时,您只希望一个工作链同时运行。例如,您可能有一个将本地数据与服务器同步的工作链。您可能希望在开始新的同步之前完成第一个数据同步。为此,您可以使用 beginUniqueWork()
而非 beginWith()
,并提供一个唯一的 String
名称。此输入为**整个**工作请求链命名,以便您可以一起引用和查询它们。
您还需要传入一个 ExistingWorkPolicy
对象。此对象会告知 Android OS 如果工作已经存在,会发生什么情况。可能的 ExistingWorkPolicy
值包括 REPLACE
、KEEP
、APPEND
或 APPEND_OR_REPLACE
。
在此应用中,您希望使用 REPLACE
,因为如果用户在当前图像处理完成之前决定处理另一张图像,您希望停止当前的图像处理并开始处理新的图像。
您还需要确保,如果用户在工作请求已排队时点击 开始,则应用会用新的请求替换先前的请求。继续处理先前的请求没有意义,因为应用无论如何都会用新的请求替换它。
在 data/WorkManagerBluromaticRepository.kt
文件中,在 applyBlur()
方法内部,完成以下步骤
- 删除对
beginWith()
函数的调用,并添加对beginUniqueWork()
函数的调用。 - 对于
beginUniqueWork()
函数的第一个参数,传入常量IMAGE_MANIPULATION_WORK_NAME
。 - 对于第二个参数,即
existingWorkPolicy
参数,传入ExistingWorkPolicy.REPLACE
。 - 对于第三个参数,为
CleanupWorker
创建一个新的OneTimeWorkRequest
。
data/WorkManagerBluromaticRepository.kt
import androidx.work.ExistingWorkPolicy
import com.example.bluromatic.IMAGE_MANIPULATION_WORK_NAME
...
// REPLACE THIS CODE:
// var continuation = workManager.beginWith(OneTimeWorkRequest.from(CleanupWorker::class.java))
// WITH
var continuation = workManager
.beginUniqueWork(
IMAGE_MANIPULATION_WORK_NAME,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequest.from(CleanupWorker::class.java)
)
...
Blur-O-Matic 现在一次只处理一张图像。
4. 根据工作状态标记和更新 UI
您要进行的下一个更改是当工作执行时应用显示的内容。返回的有关排队工作的信息决定了 UI 需要如何更改。
下表显示了您可以调用的获取工作信息的三种不同方法
类型 | WorkManager 方法 | 说明 |
使用 id 获取工作 | 此函数通过 ID 返回特定 WorkRequest 的单个 LiveData<WorkInfo>。 | |
使用 唯一链名称 获取工作 | 此函数返回唯一 WorkRequests 链中所有工作的 LiveData<List<WorkInfo>>。 | |
使用 tag 获取工作 | 此函数返回标记的 LiveData<List<WorkInfo>>。 |
一个 WorkInfo
对象包含有关 WorkRequest
当前状态的详细信息,包括
- 工作是处于
BLOCKED
(阻塞)、CANCELLED
(已取消)、ENQUEUED
(已排队)、FAILED
(失败)、RUNNING
(运行中)还是SUCCEEDED
(成功)状态。 - 如果
WorkRequest
已完成,以及工作中的任何输出数据。
这些方法返回 LiveData。LiveData 是一种感知生命周期的可观察数据持有者。我们通过调用 .asFlow()
将其转换为 WorkInfo
对象的 Flow。
由于您关注最终图像保存的时间,您需要为 SaveImageToFileWorker
WorkRequest 添加一个标签,以便通过 getWorkInfosByTagLiveData()
方法获取其 WorkInfo。
另一种选择是使用 getWorkInfosForUniqueWorkLiveData()
方法,它返回所有三个 WorkRequest(CleanupWorker
、BlurWorker
和 SaveImageToFileWorker
)的信息。此方法的缺点是您需要额外的代码来专门查找必要的 SaveImageToFileWorker
信息。
标记工作请求
工作标记是在 data/WorkManagerBluromaticRepository.kt
文件中,在 applyBlur()
函数内部完成的。
- 创建
SaveImageToFileWorker
工作请求时,通过调用addTag()
方法并传入String
常量TAG_OUTPUT
来标记工作。
data/WorkManagerBluromaticRepository.kt
import com.example.bluromatic.TAG_OUTPUT
...
val save = OneTimeWorkRequestBuilder<SaveImageToFileWorker>()
.addTag(TAG_OUTPUT) // <- Add this
.build()
您使用标签而非 WorkManager ID 来标记您的工作,因为如果用户模糊多张图像,所有保存图像的 WorkRequest
都具有相同的标签,但 ID 不同。
获取 WorkInfo
您在逻辑中使用 SaveImageToFileWorker
工作请求的 WorkInfo
信息,根据 BlurUiState
决定在 UI 中显示哪些可组合项。
ViewModel 从仓库的 outputWorkInfo
变量中消费此信息。
现在您已经标记了 SaveImageToFileWorker
工作请求,可以完成以下步骤来检索其信息
- 在
data/WorkManagerBluromaticRepository.kt
文件中,调用workManager.getWorkInfosByTagLiveData()
方法来填充outputWorkInfo
变量。 - 对于方法的参数,传入
TAG_OUTPUT
常量。
data/WorkManagerBluromaticRepository.kt
...
override val outputWorkInfo: Flow<WorkInfo?> =
workManager.getWorkInfosByTagLiveData(TAG_OUTPUT)
...
调用 getWorkInfosByTagLiveData()
方法返回 LiveData。LiveData 是一种感知生命周期的可观察数据持有者。.asFlow()
函数将其转换为 Flow。
- 链式调用
.asFlow()
函数,将方法转换为 Flow。您转换方法是为了让应用能够使用 Kotlin Flow 而非 LiveData。
data/WorkManagerBluromaticRepository.kt
import androidx.lifecycle.asFlow
...
override val outputWorkInfo: Flow<WorkInfo?> =
workManager.getWorkInfosByTagLiveData(TAG_OUTPUT).asFlow()
...
- 链式调用
.mapNotNull()
转换函数,以确保 Flow 包含值。 - 对于转换规则,如果元素不为空,则选择集合中的第一个项目。否则,返回 null 值。如果它们是 null 值,转换函数将将其移除。
data/WorkManagerBluromaticRepository.kt
import kotlinx.coroutines.flow.mapNotNull
...
override val outputWorkInfo: Flow<WorkInfo?> =
workManager.getWorkInfosByTagLiveData(TAG_OUTPUT).asFlow().mapNotNull {
if (it.isNotEmpty()) it.first() else null
}
...
- 由于
.mapNotNull()
转换函数保证存在一个值,您可以安全地移除 Flow 类型中的?
,因为它不再需要是可空类型。
data/WorkManagerBluromaticRepository.kt
...
override val outputWorkInfo: Flow<WorkInfo> =
...
- 您还需要移除
BluromaticRepository
接口中的?
。
data/BluromaticRepository.kt
...
interface BluromaticRepository {
// val outputWorkInfo: Flow<WorkInfo?>
val outputWorkInfo: Flow<WorkInfo>
...
WorkInfo
信息以 Flow
的形式从仓库发出。然后 ViewModel
消费它。
更新 BlurUiState
ViewModel
使用仓库从 outputWorkInfo
Flow 发出的 WorkInfo
来设置 blurUiState
变量的值。
UI 代码使用 blurUiState
变量的值来确定显示哪些可组合项。
完成以下步骤以执行 blurUiState
更新
- 使用仓库中的
outputWorkInfo
Flow 填充blurUiState
变量。
ui/BlurViewModel.kt
// ...
// REMOVE
// val blurUiState: StateFlow<BlurUiState> = MutableStateFlow(BlurUiState.Default)
// ADD
val blurUiState: StateFlow<BlurUiState> = bluromaticRepository.outputWorkInfo
// ...
- 然后,您需要根据工作的状态,将 Flow 中的值映射到
BlurUiState
状态。
当工作完成时,将 blurUiState
变量设置为 BlurUiState.Complete(outputUri = "")
。
当工作被取消时,将 blurUiState
变量设置为 BlurUiState.Default
。
否则,将 blurUiState
变量设置为 BlurUiState.Loading
。
ui/BlurViewModel.kt
import androidx.work.WorkInfo
import kotlinx.coroutines.flow.map
// ...
val blurUiState: StateFlow<BlurUiState> = bluromaticRepository.outputWorkInfo
.map { info ->
when {
info.state.isFinished -> {
BlurUiState.Complete(outputUri = "")
}
info.state == WorkInfo.State.CANCELLED -> {
BlurUiState.Default
}
else -> BlurUiState.Loading
}
}
// ...
- 因为您对 StateFlow 感兴趣,所以通过链式调用
.stateIn()
函数来转换 Flow。
调用 .stateIn()
函数需要三个参数
- 对于第一个参数,传入
viewModelScope
,它是绑定到 ViewModel 的协程作用域。 - 对于第二个参数,传入
SharingStarted.WhileSubscribed(5_000)
。此参数控制共享何时开始和停止。 - 对于第三个参数,传入
BlurUiState.Default
,它是状态 Flow 的初始值。
ui/BlurViewModel.kt
import kotlinx.coroutines.flow.stateIn
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
// ...
val blurUiState: StateFlow<BlurUiState> = bluromaticRepository.outputWorkInfo
.map { info ->
when {
info.state.isFinished -> {
BlurUiState.Complete(outputUri = "")
}
info.state == WorkInfo.State.CANCELLED -> {
BlurUiState.Default
}
else -> BlurUiState.Loading
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = BlurUiState.Default
)
// ...
ViewModel
通过 blurUiState
变量将 UI 状态信息暴露为 StateFlow
。通过调用 stateIn()
函数,Flow 从冷的 Flow
转换为热的 StateFlow
。
更新 UI
在 ui/BluromaticScreen.kt
文件中,您从 ViewModel
的 blurUiState
变量获取 UI 状态并更新 UI。
一个 when
块控制应用的 UI。此 when
块为 BlurUiState
的三种状态各有一个分支。
UI 在 BlurActions
可组合项内部的 Row
可组合项中更新。完成以下步骤
- 移除
Row
Composable 内的Button(onStartClick)
代码,并将其替换为一个以blurUiState
作为其参数的when
块。
ui/BluromaticScreen.kt
...
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center
) {
// REMOVE
// Button(
// onClick = onStartClick,
// modifier = Modifier.fillMaxWidth()
// ) {
// Text(stringResource(R.string.start))
// }
// ADD
when (blurUiState) {
}
}
...
应用打开时,处于其默认状态。此状态在代码中表示为 BlurUiState.Default
。
- 在
when
块内部,为此状态创建一个分支,如下面的代码示例所示
ui/BluromaticScreen.kt
...
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center
) {
when (blurUiState) {
is BlurUiState.Default -> {}
}
}
...
对于默认状态,应用显示 开始 按钮。
- 对于
BlurUiState.Default
状态中的onClick
参数,传入传递给可组合项的onStartClick
变量。 - 对于
stringResourceId
参数,传入字符串资源 IDR.string.start
。
ui/BluromaticScreen.kt
...
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center
) {
when (blurUiState) {
is BlurUiState.Default -> {
Button(
onClick = onStartClick,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.start))
}
}
}
...
当应用正在积极处理图像时,即 BlurUiState.Loading
状态。对于此状态,应用显示 取消工作 按钮和圆形进度指示器。
- 对于
BlurUiState.Loading
状态中按钮的onClick
参数,传入传递给可组合项的onCancelClick
变量。 - 对于按钮的
stringResourceId
参数,传入字符串资源 IDR.string.cancel_work
。
ui/BluromaticScreen.kt
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
...
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center
) {
when (blurUiState) {
is BlurUiState.Default -> {
Button(onStartClick) { Text(stringResource(R.string.start)) }
}
is BlurUiState.Loading -> {
FilledTonalButton(onCancelClick) { Text(stringResource(R.string.cancel_work)) }
CircularProgressIndicator(modifier = Modifier.padding(dimensionResource(R.dimen.padding_small)))
}
}
}
...
要配置的最后一个状态是 BlurUiState.Complete
状态,它在图像处理并保存后发生。此时,应用仅显示 开始 按钮。
- 对于其
onClick
参数在BlurUiState.Complete
状态中,传入onStartClick
变量。 - 对于其
stringResourceId
参数,传入字符串资源 IDR.string.start
。
ui/BluromaticScreen.kt
...
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center
) {
when (blurUiState) {
is BlurUiState.Default -> {
Button(onStartClick) { Text(stringResource(R.string.start)) }
}
is BlurUiState.Loading -> {
FilledTonalButton(onCancelClick) { Text(stringResource(R.string.cancel_work)) }
CircularProgressIndicator(modifier = Modifier.padding(dimensionResource(R.dimen.padding_small)))
}
is BlurUiState.Complete -> {
Button(onStartClick) { Text(stringResource(R.string.start)) }
}
}
}
...
运行您的应用
- 运行您的应用并点击 开始。
- 参考 后台任务检查器 窗口,查看各种状态与显示的 UI 如何对应。
的 SystemJobService
是负责管理 Worker 执行的组件。
Worker 运行时,UI 会显示 取消工作 按钮和圆形进度指示器。
Worker 完成后,UI 会按预期更新为显示 开始 按钮。
5. 显示最终输出
在本节中,您将配置应用,使其在有可显示的模糊图像时显示一个标有 查看文件 的按钮。
创建 查看文件 按钮
仅当 BlurUiState
为 Complete
时,查看文件 按钮才会显示。
- 打开
ui/BluromaticScreen.kt
文件并导航到BlurActions
可组合项。 - 要在 开始 按钮和 查看文件 按钮之间添加空间,请在
BlurUiState.Complete
块中添加一个Spacer
可组合项。 - 添加一个新的
FilledTonalButton
可组合项。 - 对于
onClick
参数,传入onSeeFileClick(blurUiState.outputUri)
。 - 为
Button
的 content 参数添加一个Text
可组合项。 - 对于
Text
的text
参数,使用字符串资源 IDR.string.see_file
。
ui/BluromaticScreen.kt
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
// ...
is BlurUiState.Complete -> {
Button(onStartClick) { Text(stringResource(R.string.start)) }
// Add a spacer and the new button with a "See File" label
Spacer(modifier = Modifier.width(dimensionResource(R.dimen.padding_small)))
FilledTonalButton({ onSeeFileClick(blurUiState.outputUri) })
{ Text(stringResource(R.string.see_file)) }
}
// ...
更新 blurUiState
的 BlurUiState
状态在 ViewModel 中设置,并取决于工作请求的状态以及可能的 bluromaticRepository.outputWorkInfo
变量。
- 在
ui/BlurViewModel.kt
文件中,在map()
转换内部,创建一个新变量outputImageUri
。 - 从
outputData
数据对象中填充此新变量的已保存图像 URI。
您可以使用 KEY_IMAGE_URI
键检索此字符串。
ui/BlurViewModel.kt
import com.example.bluromatic.KEY_IMAGE_URI
// ...
.map { info ->
val outputImageUri = info.outputData.getString(KEY_IMAGE_URI)
when {
// ...
- 如果 worker 完成并且变量已填充,则表明存在可显示的模糊图像。
您可以通过调用 outputImageUri.isNullOrEmpty()
来检查此变量是否已填充。
- 更新
isFinished
分支,使其也检查变量是否已填充,然后将outputImageUri
变量传入BlurUiState.Complete
数据对象。
ui/BlurViewModel.kt
// ...
.map { info ->
val outputImageUri = info.outputData.getString(KEY_IMAGE_URI)
when {
info.state.isFinished && !outputImageUri.isNullOrEmpty() -> {
BlurUiState.Complete(outputUri = outputImageUri)
}
info.state == WorkInfo.State.CANCELLED -> {
// ...
创建查看文件点击事件代码
当用户点击 查看文件 按钮时,其 onClick
处理程序会调用其指定的函数。此函数作为参数传递给 BlurActions()
可组合项的调用。
此函数旨在显示其 URI 中的已保存图像。它调用 showBlurredImage()
辅助函数并传入 URI。辅助函数创建一个 intent 并使用它来启动新 Activity 以显示已保存的图像。
- 打开
ui/BluromaticScreen.kt
文件。 - 在
BluromaticScreenContent()
函数中,在调用BlurActions()
可组合函数时,开始为onSeeFileClick
参数创建一个 lambda 函数,该函数接受一个名为currentUri
的参数。此方法用于存储已保存图像的 URI。
ui/BluromaticScreen.kt
// ...
BlurActions(
blurUiState = blurUiState,
onStartClick = { applyBlur(selectedValue) },
onSeeFileClick = { currentUri ->
},
onCancelClick = { cancelWork() },
modifier = Modifier.fillMaxWidth()
)
// ...
- 在 lambda 函数的主体内部,调用
showBlurredImage()
辅助函数。 - 对于第一个参数,传入
context
变量。 - 对于第二个参数,传入
currentUri
变量。
ui/BluromaticScreen.kt
// ...
BlurActions(
blurUiState = blurUiState,
onStartClick = { applyBlur(selectedValue) },
// New lambda code runs when See File button is clicked
onSeeFileClick = { currentUri ->
showBlurredImage(context, currentUri)
},
onCancelClick = { cancelWork() },
modifier = Modifier.fillMaxWidth()
)
// ...
运行您的应用
运行您的应用。您现在看到新的、可点击的 查看文件 按钮,它会将您带到已保存的文件
6. 取消工作
之前您添加了 取消工作 按钮,现在您可以添加代码使其执行操作。使用 WorkManager,您可以通过 ID、标签和**唯一链**名称取消工作。
在这种情况下,您希望使用其唯一链名称取消工作,因为您希望取消链中的所有工作,而不仅仅是特定步骤。
按名称取消工作
- 打开
data/WorkManagerBluromaticRepository.kt
文件。 - 在
cancelWork()
函数中,调用workManager.cancelUniqueWork()
函数。 - 传入唯一链名称
IMAGE_MANIPULATION_WORK_NAME
,以便调用仅取消具有该名称的已计划工作。
data/WorkManagerBluromaticRepository.kt
override fun cancelWork() {
workManager.cancelUniqueWork(IMAGE_MANIPULATION_WORK_NAME)
}
遵循关注点分离的设计原则,可组合函数不得直接与仓库交互。可组合函数与 ViewModel 交互,而 ViewModel 与仓库交互。
这种方法是一个值得遵循的良好设计原则,因为对仓库的更改不需要您更改可组合函数,因为它们不直接交互。
- 打开
ui/BlurViewModel.kt
文件。 - 创建一个名为
cancelWork()
的新函数来取消工作。 - 在函数内部,在
bluromaticRepository
对象上,调用cancelWork()
方法。
ui/BlurViewModel.kt
/**
* Call method from repository to cancel any ongoing WorkRequest
* */
fun cancelWork() {
bluromaticRepository.cancelWork()
}
设置取消工作点击事件
- 打开
ui/BluromaticScreen.kt
文件。 - 导航到
BluromaticScreen()
可组合函数。
ui/BluromaticScreen.kt
fun BluromaticScreen(blurViewModel: BlurViewModel = viewModel(factory = BlurViewModel.Factory)) {
val uiState by blurViewModel.blurUiState.collectAsStateWithLifecycle()
val layoutDirection = LocalLayoutDirection.current
Surface(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(
start = WindowInsets.safeDrawing
.asPaddingValues()
.calculateStartPadding(layoutDirection),
end = WindowInsets.safeDrawing
.asPaddingValues()
.calculateEndPadding(layoutDirection)
)
) {
BluromaticScreenContent(
blurUiState = uiState,
blurAmountOptions = blurViewModel.blurAmount,
applyBlur = blurViewModel::applyBlur,
cancelWork = {},
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(dimensionResource(R.dimen.padding_medium))
)
}
}
在调用 BluromaticScreenContent
可组合项时,您希望在用户点击按钮时运行 ViewModel 的 cancelWork()
方法。
- 将
cancelWork
参数赋值为blurViewModel::cancelWork
。
ui/BluromaticScreen.kt
// ...
BluromaticScreenContent(
blurUiState = uiState,
blurAmountOptions = blurViewModel.blurAmount,
applyBlur = blurViewModel::applyBlur,
cancelWork = blurViewModel::cancelWork,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(dimensionResource(R.dimen.padding_medium))
)
// ...
运行您的应用并取消工作
运行您的应用。它编译正常。开始模糊图片,然后点击 取消工作。整个链都被取消了!
取消工作后,由于 WorkInfo.State
为 CANCELLED
,因此仅显示 开始 按钮。此更改导致 blurUiState
变量设置为 BlurUiState.Default
,从而将 UI 重置回其初始状态并仅显示 开始 按钮。
后台任务检查器 显示状态为 Cancelled,这是预期的结果。
7. 工作限制
最后但同样重要的一点是,WorkManager
支持 Constraints
。约束是 WorkRequest 运行之前必须满足的要求。
一些示例约束是 requiresDeviceIdle()
和 requiresStorageNotLow()
。
- 对于
requiresDeviceIdle()
约束,如果传递的值为true
,则仅当设备处于空闲状态时才运行工作。 - 对于
requiresStorageNotLow()
约束,如果传递的值为true
,则仅当存储空间不低时才运行工作。
对于 Blur-O-Matic,您添加了一个约束,即在运行 blurWorker
工作请求之前,设备的电池电量不能低。此约束意味着您的工作请求会被延迟,并且仅当设备的电池电量不低时才会运行。
创建电池电量不低的约束
在 data/WorkManagerBluromaticRepository.kt
文件中,完成以下步骤
- 导航到
applyBlur()
方法。 - 在声明
continuation
变量的代码之后,创建一个名为constraints
的新变量,该变量用于保存正在创建的约束的Constraints
对象。 - 通过调用
Constraints.Builder()
函数创建一个 Constraints 对象的构建器,并将其赋值给新变量。
data/WorkManagerBluromaticRepository.kt
import androidx.work.Constraints
// ...
override fun applyBlur(blurLevel: Int) {
// ...
val constraints = Constraints.Builder()
// ...
- 链式调用
setRequiresBatteryNotLow()
方法并向其传递值true
,以便WorkRequest
仅在设备的电池电量不低时运行。
data/WorkManagerBluromaticRepository.kt
// ...
override fun applyBlur(blurLevel: Int) {
// ...
val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
// ...
- 通过链式调用
.build()
方法来构建对象。
data/WorkManagerBluromaticRepository.kt
// ...
override fun applyBlur(blurLevel: Int) {
// ...
val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
.build()
// ...
- 要将约束对象添加到
blurBuilder
工作请求,请链式调用.setConstraints()
方法并传入约束对象。
data/WorkManagerBluromaticRepository.kt
// ...
blurBuilder.setInputData(createInputDataForWorkRequest(blurLevel, imageUri))
blurBuilder.setConstraints(constraints) // Add this code
//...
使用模拟器测试
- 在模拟器上,将 Extended Controls 窗口中的 Charge level(充电电量)更改为 15% 或更低,以模拟低电量场景,将 Charger connection(充电器连接)设置为 AC charger(交流充电器),并将 Battery status(电池状态)设置为 Not charging(未充电)。
- 运行应用并点击 开始 以开始模糊图像。
模拟器的电池电量设置为低,因此 WorkManager
由于约束不会运行 blurWorker
工作请求。它已排队,但会延迟执行,直到满足约束条件。您可以在 后台任务检查器 标签页中看到此延迟。
- 确认它没有运行后,慢慢增加电池电量。
电池电量达到大约 25% 后,约束条件得到满足,延迟的工作开始运行。此结果会显示在 后台任务检查器 标签页中。
8. 编写 Worker 实现的测试
如何测试 WorkManager
为 Worker 编写测试以及使用 WorkManager API 进行测试可能不太直观。Worker 中完成的工作无法直接访问 UI——它严格来说是业务逻辑。通常,您使用本地单元测试测试业务逻辑。但是,您可能还记得在使用 WorkManager 进行后台工作 Codelab 中提到,WorkManager 的运行需要 Android Context。默认情况下,Context 在本地单元测试中不可用。因此,即使没有直接的 UI 元素可供测试,您也必须使用 UI 测试来测试 Worker。
设置依赖项
您需要向项目添加三个 Gradle 依赖项。前两个用于启用 JUnit 和 espresso 进行 UI 测试。第三个依赖项提供工作测试 API。
app/build.gradle.kts
dependencies {
// Espresso
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
// Junit
androidTestImplementation("androidx.test.ext:junit:1.1.5")
// Work testing
androidTestImplementation("androidx.work:work-testing:2.8.1")
}
您需要在应用中使用 work-runtime-ktx
的最新稳定版本。如果更改了版本,请务必点击 Sync Now 以将项目与更新后的 Gradle 文件同步。
创建测试类
- 在 app > src 目录中为您的 UI 测试创建一个目录。
- 在
androidTest/java
目录中创建一个名为WorkerInstrumentationTest
的新 Kotlin 类。
编写 CleanupWorker
测试
按照步骤编写一个测试来验证 CleanupWorker
的实现。尝试根据说明自己实现此验证。解决方案在步骤的末尾提供。
- 在
WorkerInstrumentationTest.kt
中,创建一个lateinit
变量来保存Context
的实例。 - 创建一个使用
@Before
注解的setUp()
方法。 - 在
setUp()
方法中,使用来自ApplicationProvider
的应用程序上下文初始化lateinit
context 变量。 - 创建一个名为
cleanupWorker_doWork_resultSuccess()
的测试函数。 - 在
cleanupWorker_doWork_resultSuccess()
测试中,创建一个CleanupWorker
的实例。
WorkerInstrumentationTest.kt
class WorkerInstrumentationTest {
private lateinit var context: Context
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
}
@Test
fun cleanupWorker_doWork_resultSuccess() {
}
}
在编写 Blur-O-Matic 应用时,您使用 OneTimeWorkRequestBuilder
创建 worker。测试 Worker 需要不同的工作构建器。WorkManager API 提供两个不同的构建器
这两个构建器都允许您测试 worker 的业务逻辑。对于 CoroutineWorker
,例如 CleanupWorker
、BlurWorker
和 SaveImageToFileWorker
,使用 TestListenableWorkerBuilder
进行测试,因为它处理协程的线程复杂性。
- 鉴于使用了协程,
CoroutineWorker
是异步运行的。要并行执行 worker,请使用runBlocking
。最初给它一个空的 lambda 主体,但您使用runBlocking
来指示 worker 直接执行doWork()
而不是将 worker 排队。
WorkerInstrumentationTest.kt
class WorkerInstrumentationTest {
private lateinit var context: Context
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
}
@Test
fun cleanupWorker_doWork_resultSuccess() {
val worker = TestListenableWorkerBuilder<CleanupWorker>(context).build()
runBlocking {
}
}
}
- 在
runBlocking
的 lambda 主体中,对您在步骤 5 中创建的CleanupWorker
实例调用doWork()
,并将其保存为一个值。
您可能还记得 CleanupWorker
会删除 Blur-O-Matic 应用文件结构中保存的所有 PNG 文件。此过程涉及文件输入/输出,这意味着尝试删除文件时可能会抛出异常。因此,删除文件的尝试被包装在一个 try
块中。
CleanupWorker.kt
...
return@withContext try {
val outputDirectory = File(applicationContext.filesDir, OUTPUT_PATH)
if (outputDirectory.exists()) {
val entries = outputDirectory.listFiles()
if (entries != null) {
for (entry in entries) {
val name = entry.name
if (name.isNotEmpty() && name.endsWith(".png")) {
val deleted = entry.delete()
Log.i(TAG, "Deleted $name - $deleted")
}
}
}
}
Result.success()
} catch (exception: Exception) {
Log.e(
TAG,
applicationContext.resources.getString(R.string.error_cleaning_file),
exception
)
Result.failure()
}
请注意,在 try
块的末尾,返回 Result.success()
。如果代码成功执行到 Result.success()
,则表示访问文件目录没有错误。
现在是进行断言以表明 worker 成功的时候了。
- 断言 worker 的结果是
ListenableWorker.Result.success()
。
请看以下解决方案代码
WorkerInstrumentationTest.kt
class WorkerInstrumentationTest {
private lateinit var context: Context
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
}
@Test
fun cleanupWorker_doWork_resultSuccess() {
val worker = TestListenableWorkerBuilder<CleanupWorker>(context).build()
runBlocking {
val result = worker.doWork()
assertTrue(result is ListenableWorker.Result.Success)
}
}
}
编写 BlurWorker
测试
按照以下步骤编写一个测试来验证 BlurWorker
的实现。尝试根据说明自己实现此验证。解决方案在步骤的末尾提供。
- 在
WorkerInstrumentationTest.kt
中,创建一个名为blurWorker_doWork_resultSuccessReturnsUri()
的新测试函数。
BlurWorker
需要处理图像。因此,构建 BlurWorker
的实例需要一些包含此类图像的输入数据。
- 在测试函数外部,创建一个模拟 URI 输入。模拟 URI 是一个包含键和 URI 值对。使用以下示例代码作为键值对
KEY_IMAGE_URI to "android.resource://com.example.bluromatic/drawable/android_cupcake"
- 在
blurWorker_doWork_resultSuccessReturnsUri()
函数内部构建一个BlurWorker
,并确保通过setInputData()
方法将您创建的模拟 URI 输入作为工作数据传入。
与 CleanupWorker
测试类似,您必须在 runBlocking
内部调用 worker 的实现。
- 创建一个
runBlocking
块。 - 在
runBlocking
块内部调用doWork()
。
与 CleanupWorker
不同,BlurWorker
有一些可供测试的输出数据!
- 要访问输出数据,请从
doWork()
的结果中提取 URI。
WorkerInstrumentationTest.kt
@Test
fun blurWorker_doWork_resultSuccessReturnsUri() {
val worker = TestListenableWorkerBuilder<BlurWorker>(context)
.setInputData(workDataOf(mockUriInput))
.build()
runBlocking {
val result = worker.doWork()
val resultUri = result.outputData.getString(KEY_IMAGE_URI)
}
}
- 进行断言以证明 worker 成功。例如,请查看以下来自
BlurWorker
的代码
BlurWorker.kt
val resourceUri = inputData.getString(KEY_IMAGE_URI)
val blurLevel = inputData.getInt(BLUR_LEVEL, 1)
...
val picture = BitmapFactory.decodeStream(
resolver.openInputStream(Uri.parse(resourceUri))
)
val output = blurBitmap(picture, blurLevel)
// Write bitmap to a temp file
val outputUri = writeBitmapToFile(applicationContext, output)
val outputData = workDataOf(KEY_IMAGE_URI to outputUri.toString())
Result.success(outputData)
...
BlurWorker
从输入数据中获取 URI 和模糊级别,并创建一个临时文件。如果操作成功,它将返回一个包含 URI 的键值对。要检查输出内容是否正确,请断言输出数据包含键 KEY_IMAGE_URI
。
- 断言输出数据包含一个以字符串
"file:///data/user/0/com.example.bluromatic/files/blur_filter_outputs/blur-filter-output-"
开头的 URI
- 对照以下解决方案代码检查您的测试
WorkerInstrumentationTest.kt
@Test
fun blurWorker_doWork_resultSuccessReturnsUri() {
val worker = TestListenableWorkerBuilder<BlurWorker>(context)
.setInputData(workDataOf(mockUriInput))
.build()
runBlocking {
val result = worker.doWork()
val resultUri = result.outputData.getString(KEY_IMAGE_URI)
assertTrue(result is ListenableWorker.Result.Success)
assertTrue(result.outputData.keyValueMap.containsKey(KEY_IMAGE_URI))
assertTrue(
resultUri?.startsWith("file:///data/user/0/com.example.bluromatic/files/blur_filter_outputs/blur-filter-output-")
?: false
)
}
}
编写 SaveImageToFileWorker
测试
顾名思义,SaveImageToFileWorker
会将文件写入磁盘。回想一下,在 WorkManagerBluromaticRepository
中,您将 SaveImageToFileWorker
添加到 WorkManager 中作为 BlurWorker
之后的 continuation。因此,它具有相同的输入数据。它从输入数据中获取 URI,创建位图,然后将该位图作为文件写入磁盘。如果操作成功,结果输出是一个图像 URL。SaveImageToFileWorker
的测试与 BlurWorker
测试非常相似,唯一的区别是输出数据。
看看您是否可以自己为 SaveImageToFileWorker
编写测试!完成后,您可以查看下面的解决方案。回想一下您为 BlurWorker
测试所采取的方法
- 构建 worker,传递输入数据。
- 创建一个
runBlocking
块。 - 在 worker 上调用
doWork()
。 - 检查结果是否成功。
- 检查输出是否包含正确的键和值。
这是解决方案
@Test
fun saveImageToFileWorker_doWork_resultSuccessReturnsUrl() {
val worker = TestListenableWorkerBuilder<SaveImageToFileWorker>(context)
.setInputData(workDataOf(mockUriInput))
.build()
runBlocking {
val result = worker.doWork()
val resultUri = result.outputData.getString(KEY_IMAGE_URI)
assertTrue(result is ListenableWorker.Result.Success)
assertTrue(result.outputData.keyValueMap.containsKey(KEY_IMAGE_URI))
assertTrue(
resultUri?.startsWith("content://media/external/images/media/")
?: false
)
}
}
9. 使用后台任务检查器调试 WorkManager
检查 Worker
自动化测试是验证 Worker 功能的绝佳方法。但是,当您尝试调试 Worker 时,它们的实用性并不高。幸运的是,Android Studio 提供了一个工具,可让您实时可视化、监控和调试 Worker。后台任务检查器适用于运行 API 级别 26 或更高版本的模拟器和设备。
在本节中,您将学习 后台任务检查器 提供的一些功能,用于检查 Blur-O-Matic 中的 worker。
- 在设备或模拟器上启动 Blur-O-Matic 应用。
- 导航到 View > Tool Windows > App Inspection。
- 选择 Background Task Inspector 标签页。
- 如果需要,从下拉菜单中选择设备和正在运行的进程。
在示例图像中,进程是 com.example.bluromatic
。它可能会自动为您选择进程。如果选择了错误的进程,您可以更改它。
- 点击 Workers 下拉菜单。当前没有 worker 正在运行,这是有道理的,因为尚未尝试模糊图像。
- 在应用中,选择 More blurred 并点击 开始。您会立即在 Workers 下拉菜单中看到一些内容。
您现在在 Workers 下拉菜单中看到类似以下内容。
Worker 表显示了 Worker 的名称、服务(本例中为 SystemJobService
)、每个的状态以及时间戳。在上一张截图示例中,请注意 BlurWorker
和 CleanupWorker
已成功完成其工作。
您还可以使用检查器取消工作。
- 选择一个已排队的 worker,然后点击工具栏中的 Cancel Selected Worker
。
检查任务详细信息
- 点击 Workers 表中的一个 worker。
这样做会弹出 Task Details 窗口。
- 查看 Task Details 中显示的信息。
详细信息显示以下类别
- 描述:此部分列出了 Worker 类名以及完全限定包名、分配的标签和此 worker 的 UUID。
- 执行:此部分显示 worker 的约束(如果有)、运行频率、状态以及创建和排队此 worker 的类。回想一下,BlurWorker 有一个约束,阻止它在电池电量低时执行。当您检查具有约束的 Worker 时,它们会出现在此部分。
- 工作延续:此部分显示此 worker 在工作链中的位置。要检查工作链中另一个 worker 的详细信息,请点击其 UUID。
- 结果:此部分显示选定 worker 的开始时间、重试次数和输出数据。
图视图
回想一下,Blur-O-Matic 中的 worker 是链式的。后台任务检查器提供了一种图视图,可以直观地表示 worker 的依赖关系。
在 后台任务检查器 窗口的角落,有两个按钮可以切换 — Show Graph View(显示图视图)和 Show List View(显示列表视图)。
- 点击 Show Graph View
(显示图视图)。
图视图准确地显示了 Blur-O-Matic 应用中实现的 Worker 依赖关系。
- 点击 Show List View
(显示列表视图)退出图视图。
附加功能
Blur-O-Matic 应用仅实现 Worker 来完成后台任务。但是,您可以在 后台任务检查器 的文档中阅读有关检查其他类型后台工作的可用工具的更多信息。
10. 获取解决方案代码
要下载完成的 Codelab 的代码,您可以使用以下命令
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-workmanager.git $ cd basic-android-kotlin-compose-training-workmanager $ git checkout main
或者,您可以将仓库下载为 zip 文件,解压并在 Android Studio 中打开。
如果您想查看本 Codelab 的解决方案代码,请在 GitHub 上查看。
11. 恭喜
恭喜!您学习了 WorkManager 的其他功能,为 Blur-O-Matic worker 编写了自动化测试,并使用 后台任务检查器 来检查它们。在本 Codelab 中,您学习了
- 命名唯一的
WorkRequest
链。 - 标记
WorkRequest
。 - 根据
WorkInfo
更新 UI。 - 取消
WorkRequest
。 - 向
WorkRequest
添加约束。 - WorkManager 测试 API。
- 如何进行 worker 实现的测试。
- 如何测试
CoroutineWorker
。 - 如何手动检查 worker 并验证其功能。