高级 WorkManager 和测试

1. 简介

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

您将构建什么

在此 Codelab 中,您将确保工作的唯一性、标记工作、取消工作以及实现工作限制。然后,您将学习如何为 Blur-O-Matic 应用编写自动化 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 OS 如果工作已经存在,会发生什么情况。可能的 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()

此函数返回唯一 WorkRequests 链中所有工作的 LiveData<List<WorkInfo>>

使用 tag 获取工作

getWorkInfosByTagLiveData()

此函数返回标记的 LiveData<List<WorkInfo>>

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

  • 工作是处于 BLOCKED(阻塞)、CANCELLED(已取消)、ENQUEUED(已排队)、FAILED(失败)、RUNNING(运行中)还是 SUCCEEDED(成功)状态。
  • 如果 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,它是状态 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 文件中,您从 ViewModelblurUiState 变量获取 UI 状态并更新 UI。

一个 when 块控制应用的 UI。此 when 块为 BlurUiState 的三种状态各有一个分支。

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

  1. 移除 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

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

ui/BluromaticScreen.kt

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

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

  1. 对于 BlurUiState.Default 状态中的 onClick 参数,传入传递给可组合项的 onStartClick 变量。
  2. 对于 stringResourceId 参数,传入字符串资源 ID R.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 状态。对于此状态,应用显示 取消工作 按钮和圆形进度指示器。

  1. 对于 BlurUiState.Loading 状态中按钮的 onClick 参数,传入传递给可组合项的 onCancelClick 变量。
  2. 对于按钮的 stringResourceId 参数,传入字符串资源 ID R.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 状态,它在图像处理并保存后发生。此时,应用仅显示 开始 按钮。

  1. 对于其 onClick 参数在 BlurUiState.Complete 状态中,传入 onStartClick 变量。
  2. 对于其 stringResourceId 参数,传入字符串资源 ID R.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)) }
            }
        }
    }
...

运行您的应用

  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 的 content 参数添加一个 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。辅助函数创建一个 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 重置回其初始状态并仅显示 开始 按钮。

后台任务检查器 显示状态为 Cancelled,这是预期的结果。

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. 在模拟器上,将 Extended Controls 窗口中的 Charge level(充电电量)更改为 15% 或更低,以模拟低电量场景,将 Charger connection(充电器连接)设置为 AC charger(交流充电器),并将 Battery status(电池状态)设置为 Not charging(未充电)。

9b0084cb6e1a8672.png

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

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

7518cf0353d04f12.png

  1. 确认它没有运行后,慢慢增加电池电量。

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

ab189db49e7b8997.png

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 文件同步。

创建测试类

  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 context 变量。
  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 的业务逻辑。对于 CoroutineWorker,例如 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 添加到 WorkManager 中作为 BlurWorker 之后的 continuation。因此,它具有相同的输入数据。它从输入数据中获取 URI,创建位图,然后将该位图作为文件写入磁盘。如果操作成功,结果输出是一个图像 URL。SaveImageToFileWorker 的测试与 BlurWorker 测试非常相似,唯一的区别是输出数据。

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

  1. 构建 worker,传递输入数据。
  2. 创建一个 runBlocking 块。
  3. 在 worker 上调用 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

检查 Worker

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

在本节中,您将学习 后台任务检查器 提供的一些功能,用于检查 Blur-O-Matic 中的 worker。

  1. 在设备或模拟器上启动 Blur-O-Matic 应用。
  2. 导航到 View > Tool Windows > App Inspection

798f10dfd8d74bb1.png

  1. 选择 Background Task Inspector 标签页。

d601998f3754e793.png

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

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

6428a2ab43fc42d1.png

  1. 点击 Workers 下拉菜单。当前没有 worker 正在运行,这是有道理的,因为尚未尝试模糊图像。

cf8c466b3fd7fed1.png

  1. 在应用中,选择 More blurred 并点击 开始。您会立即在 Workers 下拉菜单中看到一些内容。

您现在在 Workers 下拉菜单中看到类似以下内容。

569a8e0c1c6993ce.png

Worker 表显示了 Worker 的名称、服务(本例中为 SystemJobService)、每个的状态以及时间戳。在上一张截图示例中,请注意 BlurWorkerCleanupWorker 已成功完成其工作。

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

  1. 选择一个已排队的 worker,然后点击工具栏中的 Cancel Selected Worker 7108c2a82f64b348.png

检查任务详细信息

  1. 点击 Workers 表中的一个 worker。97eac5ad23c41127.png

这样做会弹出 Task Details 窗口。

9d4e17f7d4afa6bd.png

  1. 查看 Task Details 中显示的信息。59fa1bf4ad8f4d8d.png

详细信息显示以下类别

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

图视图

回想一下,Blur-O-Matic 中的 worker 是链式的。后台任务检查器提供了一种图视图,可以直观地表示 worker 的依赖关系。

后台任务检查器 窗口的角落,有两个按钮可以切换 — Show Graph View(显示图视图)和 Show List View(显示列表视图)。

4cd96a8b2773f466.png

  1. 点击 Show Graph View 6f871bb00ad8b11a.png(显示图视图)。

ece206da18cfd1c9.png

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

  1. 点击 Show List View 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. 恭喜

恭喜!您学习了 WorkManager 的其他功能,为 Blur-O-Matic worker 编写了自动化测试,并使用 后台任务检查器 来检查它们。在本 Codelab 中,您学习了

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