高级 WorkManager 和测试

1. 简介

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

您将构建什么

在本 codelab 中,您将确保唯一工作、标记工作、取消工作以及实现工作约束。然后,您将学习如何为 **模糊器** 应用编写自动化 UI 测试,以验证在 使用 WorkManager 进行后台工作 codelab 中创建的三个 worker 的功能

  • BlurWorker
  • CleanupWorker
  • SaveImageToFileWorker

您将学到什么

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

您需要什么

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 操作系统如果工作已存在会发生什么情况。可能的 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()

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

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

getWorkInfosByTagLiveData()

此函数返回标签的 **LiveData<List<WorkInfo>>**。

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

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

因为您对最终图像保存的时间感兴趣,所以您向 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. 对于变换规则,如果元素不为空,则选择集合中的第一个项目。否则,返回 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
        }
...
  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 转换为热 StateFlow

更新 UI

ui/BluromaticScreen.kt 文件中,您可以从 ViewModelblurUiState 变量获取 UI 状态并更新 UI。

一个 when 块控制应用程序的 UI。此 when 块为三个 BlurUiState 状态中的每一个都有一个分支。

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

  1. 删除 Row 可组合项内的 Button(onStartClick) 代码,并将其替换为一个 when 块,并以 blurUiState 作为其参数。

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. Button 的内容参数添加一个 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. 如果工作完成并且该变量已填充,则表示存在要显示的模糊图像。

您可以通过调用 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。帮助程序函数创建 Intent 并使用它启动一个新的 Activity 以显示保存的图像。

  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 的后台工作 codelab 中,WorkManger 需要 Android Context 才能运行。默认情况下,本地单元测试中不可用 Context。因此,您必须使用 UI 测试来测试 Worker 测试,即使没有直接的 UI 元素需要测试。

设置依赖项

您需要向项目添加三个 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 创建 Worker。测试 Worker 需要不同的工作构建器。WorkManager API 提供了两个不同的构建器

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

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

现在是时候进行断言以指示 Worker 成功了。

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

  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 内部调用 Worker 的实现。

  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. 断言 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

  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作为BlurWorker之后的延续添加到WorkManager中。因此,它具有相同输入数据。它从输入数据中获取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并点击取消选定的worker7108c2a82f64b348.png 工具栏中的。

检查任务详细信息

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

这样做会调出任务详细信息窗口。

9d4e17f7d4afa6bd.png

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

详细信息显示以下类别

  • 描述:此部分列出了工作器类名(包括完整限定包名)以及分配的标签和此工作器的UUID。
  • 执行:此部分显示工作器的约束条件(如果有)、运行频率、状态以及哪个类创建并排队了此工作器。回想一下,BlurWorker有一个约束条件,可以防止它在电池电量低时执行。当您检查具有约束条件的工作器时,它们会显示在此部分中。
  • WorkContinuation:此部分显示此工作器在工作链中的位置。要检查工作链中另一个工作器的详细信息,请单击其UUID。
  • 结果:此部分显示选定工作器的开始时间、重试次数和输出数据。

图形视图

回想一下,Blur-O-Matic中的工作器是链接的。后台任务检查器提供了一个图形视图,可以直观地表示工作器依赖项。

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

4cd96a8b2773f466.png

  1. 点击显示图形视图6f871bb00ad8b11a.png

ece206da18cfd1c9.png

图形视图准确地指示了在Blur-O-Matic应用中实现的工作器依赖关系。

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

其他功能

Blur-O-Matic应用仅实现工作器来完成后台任务。但是,您可以在后台任务检查器的文档中阅读有关检查其他类型后台工作的可用工具的更多信息。

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工作器编写了自动化测试,并使用了后台任务检查器来检查它们。在本codelab中,您学习了

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