高级 WorkManager 和测试

1. 简介

使用 WorkManager 进行后台工作 代码实验室中,您学习了如何使用 WorkManager 在后台(非主线程)执行工作。在本代码实验室中,您将继续学习 WorkManager 功能,以确保唯一的工作、标记工作、取消工作和工作约束。代码实验室将以学习如何编写自动化测试来验证您的工作程序是否正常运行并返回预期结果结束。您还将学习如何使用 Android Studio 提供的 **后台任务检查器** 检查排队的 Worker。

您将构建什么

在本代码实验室中,您将确保唯一的工作、标记工作、取消工作和实现工作约束。然后,您将学习如何为 **Blur-O-Matic** 应用编写自动 UI 测试,以验证在使用 WorkManager 进行后台工作 代码实验室中创建的三个 Worker 的功能

  • BlurWorker
  • CleanupWorker
  • SaveImageToFileWorker

您将学习什么

  • 确保唯一的工作
  • 如何取消工作。
  • 如何定义工作约束
  • 如何编写自动化测试以验证 Worker 功能。
  • 使用 **后台任务检查器** 检查排队 Worker 的基础知识。

您需要什么

2. 设置

下载代码

点击以下链接下载此代码实验室的所有代码

或者,如果您愿意,您可以从 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 操作系统如果工作已存在会发生什么。可能的ExistingWorkPolicy 值是REPLACEKEEPAPPENDAPPEND_OR_REPLACE

在此应用中,您要使用 REPLACE,因为如果用户在当前模糊处理完成之前决定模糊另一张图像,则您希望停止当前模糊处理并开始模糊新图像。

您还希望确保如果用户在工作请求已排队时点击 **开始**,则应用会将之前的请求替换为新请求。继续处理之前的请求没有意义,因为应用无论如何都会将其替换为新请求。

data/WorkManagerBluromaticRepository.kt 文件中,在 applyBlur() 方法内部,完成以下步骤

  1. 删除对 beginWith() 函数的调用,并添加对beginUniqueWork() 函数的调用。
  2. 对于 beginUniqueWork() 函数的第一个参数,传入常量 IMAGE_MANIPULATION_WORK_NAME
  3. 对于第二个参数 existingWorkPolicy 参数,传入 ExistingWorkPolicy.REPLACE
  4. 对于第三个参数,为 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** 获取工作

getWorkInfoByIdLiveData()

此函数通过其 ID 为特定 WorkRequest 返回单个 **LiveData<WorkInfo>**。

使用 **唯一链名称** 获取工作

getWorkInfosForUniqueWorkLiveData()

此函数返回 **LiveData<List<WorkInfo>>**,用于 WorkRequest 的唯一链中的所有工作。

使用 **标记** 获取工作

getWorkInfosByTagLiveData()

此函数返回 **LiveData<List<WorkInfo>>**,用于标记。

WorkInfo 对象包含有关 WorkRequest 当前状态的详细信息,包括

这些方法返回LiveData。LiveData 是一个生命周期感知的可观察数据持有者。我们通过调用.asFlow() 将其转换为 WorkInfo 对象的流。

因为您对最终图像保存的时间感兴趣,所以您向 SaveImageToFileWorker WorkRequest 添加一个标记,以便您可以从 getWorkInfosByTagLiveData() 方法获取其 WorkInfo。

另一种选择是使用 getWorkInfosForUniqueWorkLiveData() 方法,该方法返回有关所有三个 WorkRequest(CleanupWorkerBlurWorkerSaveImageToFileWorker)的信息。此方法的缺点是您需要其他代码来专门查找必要的 SaveImageToFileWorker 信息。

标记工作请求

标记工作是在 data/WorkManagerBluromaticRepository.kt 文件中的 applyBlur() 函数中完成的。

  1. 创建 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 工作请求,您可以完成以下步骤来检索其信息

  1. data/WorkManagerBluromaticRepository.kt 文件中,调用 workManager.getWorkInfosByTagLiveData() 方法来填充 outputWorkInfo 变量。
  2. 为该方法的参数传入 TAG_OUTPUT 常量。

data/WorkManagerBluromaticRepository.kt

...
override val outputWorkInfo: Flow<WorkInfo?> =
    workManager.getWorkInfosByTagLiveData(TAG_OUTPUT)
...

getWorkInfosByTagLiveData() 方法的调用返回 LiveData。LiveData 是一个生命周期感知的可观察数据持有者。.asFlow() 函数将其转换为 Flow。

  1. 链接对 .asFlow() 函数的调用,以将方法转换为 Flow。进行转换是为了使应用能够使用 Kotlin Flow 而不是 LiveData。

data/WorkManagerBluromaticRepository.kt

import androidx.lifecycle.asFlow
...
override val outputWorkInfo: Flow<WorkInfo?> =
    workManager.getWorkInfosByTagLiveData(TAG_OUTPUT).asFlow()
...
  1. 链接对 .mapNotNull() 变换函数的调用,以确保 Flow 包含值。
  2. 对于变换规则,如果元素不为空,则选择集合中的第一个项目。否则,返回空值。然后,变换函数将删除空值。

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
        }
...
  1. 由于 .mapNotNull() 变换函数保证存在值,因此可以安全地从 Flow 类型中删除 ?,因为它不再需要是可空类型。

data/WorkManagerBluromaticRepository.kt

...
    override val outputWorkInfo: Flow<WorkInfo> =
...
  1. 还需要从 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 更新

  1. 使用存储库中的 outputWorkInfo Flow 填充 blurUiState 变量。

ui/BlurViewModel.kt

// ...
// REMOVE
// val blurUiState: StateFlow<BlurUiState> = MutableStateFlow(BlurUiState.Default)

// ADD
val blurUiState: StateFlow<BlurUiState> = bluromaticRepository.outputWorkInfo
// ...
  1. 然后,需要将 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
            }
        }

// ...
  1. 由于感兴趣的是 StateFlow,因此通过链接对 .stateIn() 函数的调用来转换 Flow。

.stateIn() 函数的调用需要三个参数

  1. 对于第一个参数,传递 viewModelScope,它是与 ViewModel 绑定的协程作用域。
  2. 对于第二个参数,传递 SharingStarted.WhileSubscribed(5_000)。此参数控制共享的启动和停止时间。
  3. 对于第三个参数,传递 BlurUiState.Default,这是状态流的初始值。

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 文件中,从 ViewModelblurUiState 变量获取 UI 状态并更新 UI。

一个 when 块控制应用的 UI。此 when 块包含三个 BlurUiState 状态的每个分支。

UI 在其 Row 可组合项内的 BlurActions 可组合项中更新。完成以下步骤

  1. 删除 Row 可组合项内的 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

  1. when 块内,为该状态创建一个分支,如下面的代码示例所示

ui/BluromaticScreen.kt

...
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.Center
    ) {
        when (blurUiState) {
            is BlurUiState.Default -> {}
        }
    }
...

对于默认状态,应用显示“开始”按钮。

  1. 对于 BlurUiState.Default 状态中的 onClick 参数,传递正在传递给可组合项的 onStartClick 变量。
  2. 对于 stringResourceId 参数,传递 R.string.start 的字符串资源 ID。

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 状态。对于此状态,应用显示“取消工作”按钮和一个圆形进度指示器。

  1. 对于 BlurUiState.Loading 状态中按钮的 onClick 参数,传递正在传递给可组合项的 onCancelClick 变量。
  2. 对于按钮的 stringResourceId 参数,传递 R.string.cancel_work 的字符串资源 ID。

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 状态,该状态发生在图像模糊并保存之后。此时,应用仅显示“开始”按钮。

  1. 对于其 BlurUiState.Complete 状态中的 onClick 参数,传递 onStartClick 变量。
  2. 对于其 stringResourceId 参数,传递 R.string.start 的字符串资源 ID。

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)) }
            }
        }
    }
...

运行你的应用

  1. 运行你的应用并点击“开始”。
  2. 参考“后台任务检查器”窗口,查看各种状态如何与正在显示的 UI 相对应。

SystemJobService 是负责管理 Worker 执行的组件。

在 worker 运行期间,UI 显示“取消工作”按钮和一个圆形进度指示器。

3395cc370b580b32.png

c5622f923670cf67.png

worker 完成后,UI 会按预期更新为显示“开始”按钮。

97252f864ea042aa.png

81ba9962a8649e70.png

5. 显示最终输出

在本节中,将配置应用以显示一个标记为“查看文件”的按钮,只要有模糊图像准备好显示。

创建“查看文件”按钮

“查看文件”按钮仅在 BlurUiStateComplete 时显示。

  1. 打开 ui/BluromaticScreen.kt 文件并导航到 BlurActions 可组合项。
  2. 要在“开始”按钮和“查看文件”按钮之间添加空格,请在 BlurUiState.Complete 块中添加 Spacer 可组合项。
  3. 添加一个新的 FilledTonalButton 可组合项。
  4. 对于 onClick 参数,传递 onSeeFileClick(blurUiState.outputUri)
  5. 为按钮的内容参数添加一个 Text 可组合项。
  6. 对于 Texttext 参数,使用字符串资源 ID R.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 变量。

  1. ui/BlurViewModel.kt 文件中,在 map() 变换内,创建一个新的变量 outputImageUri
  2. 使用 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 {
// ...
  1. 如果 worker 完成且变量已填充,则表明存在要显示的模糊图像。

可以通过调用 outputImageUri.isNullOrEmpty() 来检查此变量是否已填充。

  1. 更新 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。辅助函数创建一个意图并使用它启动一个新的活动来显示保存的图像。

  1. 打开 ui/BluromaticScreen.kt 文件。
  2. BluromaticScreenContent() 函数中,在调用 BlurActions() 组合函数时,开始为 onSeeFileClick 参数创建一个 lambda 函数,该函数接受一个名为 currentUri 的单个参数。这种方法存储已保存图像的 URI。

ui/BluromaticScreen.kt

// ...
BlurActions(
    blurUiState = blurUiState,
    onStartClick = { applyBlur(selectedValue) },
    onSeeFileClick = { currentUri ->
    },
    onCancelClick = { cancelWork() },
    modifier = Modifier.fillMaxWidth()
)
// ...
  1. 在 lambda 函数的主体内部,调用 showBlurredImage() 辅助函数。
  2. 对于第一个参数,传递 context 变量。
  3. 对于第二个参数,传递 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()
)
// ...

运行你的应用

运行您的应用程序。您现在可以看到新的、可点击的 **查看文件** 按钮,该按钮将引导您到已保存的文件。

9d76d5d7f231c6b6.png

926e532cc24a0d4f.png

6. 取消工作

5cec830cc8ef647e.png

之前,您添加了 **取消工作** 按钮,因此现在您可以添加代码使其执行某些操作。使用 WorkManager,您可以使用 ID、标签和 **唯一链** 名称来取消工作。

在本例中,您希望使用其唯一链名称取消工作,因为您想要取消链中的所有工作,而不仅仅是特定步骤。

按名称取消工作

  1. 打开 data/WorkManagerBluromaticRepository.kt 文件。
  2. cancelWork() 函数中,调用 workManager.cancelUniqueWork() 函数。
  3. 传入唯一链名称 IMAGE_MANIPULATION_WORK_NAME,以便该调用仅取消具有该名称的已计划工作。

data/WorkManagerBluromaticRepository.kt

override fun cancelWork() {
    workManager.cancelUniqueWork(IMAGE_MANIPULATION_WORK_NAME)
}

遵循 关注点分离 的设计原则,组合函数不得直接与存储库交互。组合函数与 ViewModel 交互,ViewModel 与存储库交互。

这种方法是一个很好的设计原则,因为对存储库的更改不需要您更改组合函数,因为它们不直接交互。

  1. 打开 ui/BlurViewModel.kt 文件。
  2. 创建一个名为 cancelWork() 的新函数来取消工作。
  3. 在函数内部,在 bluromaticRepository 对象上,调用 cancelWork() 方法。

ui/BlurViewModel.kt

/**
 * Call method from repository to cancel any ongoing WorkRequest
 * */
fun cancelWork() {
    bluromaticRepository.cancelWork()
}

设置取消工作点击事件

  1. 打开 ui/BluromaticScreen.kt 文件。
  2. 导航到 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() 方法。

  1. 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))
        )
// ...

运行您的应用程序并取消工作

运行您的应用程序。它编译得很好。开始模糊图片,然后点击 **取消工作**。整个链都被取消了!

81ba9962a8649e70.png

取消工作后,只有 **开始** 按钮显示,因为 WorkInfo.StateCANCELLED。此更改导致 blurUiState 变量设置为 BlurUiState.Default,这将 UI 重置回其初始状态,只显示 **开始** 按钮。

**后台任务检查器** 显示 **已取消** 状态,这是预期的。

7656dd320866172e.png

7. 工作约束

最后但并非最不重要的一点是,WorkManager 支持 Constraints。约束是您在 WorkRequest 运行之前必须满足的要求。

一些示例约束是 requiresDeviceIdle()requiresStorageNotLow()

  • 对于 requiresDeviceIdle() 约束,如果它传递的值为 true,则只有在设备空闲时工作才会运行。
  • 对于 requiresStorageNotLow() 约束,如果它传递的值为 true,则只有在存储空间不足时工作才会运行。

对于 **Blur-O-Matic**,您添加了在运行 blurWorker 工作请求之前设备电池电量必须不低的约束。此约束意味着您的工作请求被延迟,并且只有在设备电池电量不低时才会运行。

创建电池电量不低的约束

data/WorkManagerBluromaticRepository.kt 文件中,完成以下步骤

  1. 导航到 applyBlur() 方法。
  2. 在声明 continuation 变量的代码之后,创建一个名为 constraints 的新变量,该变量保存正在创建的约束的 Constraints 对象。
  3. 通过调用 Constraints.Builder() 函数创建 Constraints 对象的构建器,并将其赋值给新变量。

data/WorkManagerBluromaticRepository.kt

import androidx.work.Constraints

// ...
    override fun applyBlur(blurLevel: Int) {
        // ...

        val constraints = Constraints.Builder()
// ...
  1. setRequiresBatteryNotLow() 方法链接到调用中,并向其传递值为 true,以便 WorkRequest 仅在设备电池电量不低时运行。

data/WorkManagerBluromaticRepository.kt

// ...
    override fun applyBlur(blurLevel: Int) {
        // ...

        val constraints = Constraints.Builder()
            .setRequiresBatteryNotLow(true)
// ...
  1. 通过链接对 .build() 方法的调用来构建对象。

data/WorkManagerBluromaticRepository.kt

// ...
    override fun applyBlur(blurLevel: Int) {
        // ...

        val constraints = Constraints.Builder()
            .setRequiresBatteryNotLow(true)
            .build()
// ...
  1. 要将约束对象添加到 blurBuilder 工作请求,请链接对 .setConstraints() 方法的调用,并传入约束对象。

data/WorkManagerBluromaticRepository.kt

// ...
blurBuilder.setInputData(createInputDataForWorkRequest(blurLevel, imageUri))

blurBuilder.setConstraints(constraints) // Add this code
//...

使用模拟器测试

  1. 在模拟器上,将 **扩展控件** 窗口中的 **充电级别** 更改为 15% 或更低以模拟低电量场景,**充电器连接** 更改为 **交流充电器**,**电池状态** 更改为 **未充电**。

9b0084cb6e1a8672.png

  1. 运行应用程序并点击 **开始** 以开始模糊图像。

模拟器的电池电量设置为低电量,因此由于约束条件,WorkManager 不运行 blurWorker 工作请求。它已排队,但会延迟到满足约束条件为止。您可以在 **后台任务检查器** 选项卡中看到此延迟。

7518cf0353d04f12.png

  1. 确认未运行后,缓慢提高电池电量。

电池电量达到约 25% 后,约束条件得到满足,延迟的工作开始运行。此结果显示在 **后台任务检查器** 选项卡中。

ab189db49e7b8997.png

8. 为 Worker 实现编写测试

如何测试 WorkManager

为 Worker 编写测试和使用 WorkManager API 进行测试可能违反直觉。在 Worker 中完成的工作无法直接访问 UI,它严格来说是业务逻辑。通常,您可以使用本地单元测试来测试业务逻辑。但是,您可能还记得来自 WorkManager 代码实验室的后台工作,WorkManger 需要 Android Context 才能运行。默认情况下,本地单元测试中不提供 Context。因此,即使没有直接的 UI 元素需要测试,您也必须使用 UI 测试来测试 Worker 测试。

设置依赖项

您需要向项目添加三个 Gradle 依赖项。前两个为 UI 测试启用 JUnit 和 Espresso。第三个依赖项提供工作测试 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。如果更改版本,请确保点击 **立即同步** 以使您的项目与更新的 gradle 文件同步。

创建测试类

  1. 在 **app > src** 目录中为 UI 测试创建一个目录。a7768e9b6ea994d3.png

20cc54de1756c884.png

  1. androidTest/java 目录中创建一个名为 WorkerInstrumentationTest 的新 Kotlin 类。

编写 CleanupWorker 测试

按照步骤编写测试以验证 CleanupWorker 的实现。尝试根据说明自行实现此验证。解决方案在步骤末尾提供。

  1. WorkerInstrumentationTest.kt 中,创建一个 lateinit 变量以保存 Context 的实例。
  2. 创建一个用 @Before 注解的 setUp() 方法。
  3. setUp() 方法中,使用来自 ApplicationProvider 的应用程序上下文初始化 lateinit 上下文变量。
  4. 创建一个名为 cleanupWorker_doWork_resultSuccess() 的测试函数。
  5. 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创建工作器。测试工作器需要不同的工作构建器。WorkManager API 提供了两个不同的构建器

这两个构建器都允许您测试工作器的业务逻辑。对于CoroutineWorkers(例如CleanupWorkerBlurWorkerSaveImageToFileWorker),使用TestListenableWorkerBuilder进行测试,因为它可以处理协程的线程复杂性。

  1. CoroutineWorker由于使用了协程,因此异步运行。要并行执行工作器,请使用runBlocking。提供一个空 lambda 函数体以启动,但是您可以使用runBlocking直接指示工作器doWork(),而不是将工作器排队。

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 {
       }
   }
}
  1. 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(),则表示访问文件目录没有错误。

现在是时候进行断言以指示工作器已成功。

  1. 断言工作器的结果为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的实现。请尝试根据说明自行实现此验证。解决方案在步骤末尾提供。

  1. WorkerInstrumentationTest.kt中,创建一个名为blurWorker_doWork_resultSuccessReturnsUri()的新测试函数。

BlurWorker需要一个要处理的图像。因此,构建BlurWorker的实例需要一些包含此类图像的输入数据。

  1. 在测试函数之外,创建一个模拟 URI 输入。模拟 URI 是一对包含键和 URI 值的键值对。使用以下示例代码作为键值对
KEY_IMAGE_URI to "android.resource://com.example.bluromatic/drawable/android_cupcake"
  1. blurWorker_doWork_resultSuccessReturnsUri()函数内构建一个BlurWorker,并确保通过setInputData()方法将您创建的模拟 URI 输入作为工作数据传递。

CleanupWorker测试类似,您必须在runBlocking内调用工作器的实现。

  1. 创建一个runBlocking块。
  2. runBlocking块内调用doWork()

CleanupWorker不同,BlurWorker有一些输出数据适合测试!

  1. 要访问输出数据,请从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)
    }
}
  1. 断言工作器已成功。例如,查看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

  1. 断言输出数据包含一个以字符串"file:///data/user/0/com.example.bluromatic/files/blur_filter_outputs/blur-filter-output-"开头的 URI。
  1. 将您的测试与以下解决方案代码进行比较

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之后的延续。因此,它具有相同的数据输入。它从输入数据中获取 URI,创建位图,然后将该位图作为文件写入磁盘。如果操作成功,则结果输出为图像 URL。SaveImageToFileWorker的测试与BlurWorker测试非常相似,唯一的区别在于输出数据。

看看您是否可以自己编写SaveImageToFileWorker的测试!完成后,您可以查看下面的解决方案。回想一下您对BlurWorker测试所采用的方法

  1. 构建工作器,传递输入数据。
  2. 创建一个runBlocking块。
  3. 在工作器上调用doWork()
  4. 检查结果是否成功。
  5. 检查输出是否包含正确的键和值。

这是解决方案

@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

检查工作器

自动化测试是验证工作器功能的好方法。但是,当您尝试调试工作器时,它们提供的实用程序并不多。幸运的是,Android Studio 有一个工具允许您实时可视化、监控和调试工作器。后台任务检查器适用于运行 API 级别 26 或更高版本的模拟器和设备。

在本节中,您将了解**后台任务检查器**提供的一些功能,以检查**Blur-O-Matic**中的工作器。

  1. 在设备或模拟器上启动**Blur-O-Matic**应用。
  2. 导航到**查看 > 工具窗口 > 应用检查**。

798f10dfd8d74bb1.png

  1. 选择**后台任务检查器**选项卡。

d601998f3754e793.png

  1. 如有必要,从下拉菜单中选择设备和正在运行的进程。

在示例图像中,进程为com.example.bluromatic。它可能会自动为您选择进程。如果选择了错误的进程,您可以更改它。

6428a2ab43fc42d1.png

  1. 单击**工作器**下拉菜单。目前没有运行的工作器,这是有道理的,因为还没有尝试模糊图像。

cf8c466b3fd7fed1.png

  1. 在应用中,选择**更模糊**并单击**启动**。您会立即在**工作器**下拉菜单中看到一些内容。

您现在在**工作器**下拉菜单中看到类似以下内容。

569a8e0c1c6993ce.png

工作器表显示工作器的名称、服务(在本例中为SystemJobService)、每个工作器的状态和时间戳。在上一步骤的屏幕截图中,请注意BlurWorkerCleanupWorker已成功完成其工作。

您也可以使用检查器取消工作。

  1. 选择一个已排队的 Worker,然后单击工具栏中的**取消选定工作器**7108c2a82f64b348.png

检查任务详细信息

  1. 单击**工作器**表中的工作器。97eac5ad23c41127.png

这样做会打开**任务详细信息**窗口。

9d4e17f7d4afa6bd.png

  1. 查看**任务详细信息**中显示的信息。59fa1bf4ad8f4d8d.png

详细信息显示以下类别

  • 说明:此部分列出了 Worker 类名称(包含完整限定包名)、分配的标签以及此 worker 的 UUID。
  • 执行:此部分显示 worker 的约束条件(如有)、运行频率、状态以及创建和排队此 worker 的类。请记住,BlurWorker 具有一个约束条件,可防止其在电池电量低时执行。当您检查具有约束条件的 Worker 时,这些约束条件会显示在此部分。
  • WorkContinuation:此部分显示此 worker 在工作链中的位置。要检查工作链中其他 worker 的详细信息,请点击其 UUID。
  • 结果:此部分显示所选 worker 的开始时间、重试次数和输出数据。

图表视图

请记住,**Blur-O-Matic** 中的 worker 是链式连接的。后台任务检查器提供了一个图表视图,可以直观地表示 worker 之间的依赖关系。

在**后台任务检查器**窗口的角落,有两个按钮可在两者之间切换——**显示图表视图**和**显示列表视图**。

4cd96a8b2773f466.png

  1. 点击**显示图表视图**6f871bb00ad8b11a.png

ece206da18cfd1c9.png

图表视图准确地指示了在**Blur-O-Matic** 应用中实现的 Worker 依赖关系。

  1. 点击**显示列表视图**669084937ea340f5.png以退出图表视图。

其他功能

**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. 恭喜

恭喜!您学习了有关 WorkManger 其他功能的知识,为**Blur-O-Matic** worker 编写了自动化测试,并使用了**后台任务检查器**来检查它们。在此 codelab 中,您学习了:

  • 命名唯一的WorkRequest链。
  • 标记WorkRequest
  • 根据WorkInfo更新 UI。
  • 取消WorkRequest
  • WorkRequest添加约束条件。
  • WorkManager 测试 API。
  • 如何进行 worker 实现测试。
  • 如何测试CoroutineWorker
  • 如何手动检查 worker 并验证其功能。