高级 WorkManager 和测试

1. 简介

使用 WorkManager 处理后台工作 Codelab 中,您学习了如何使用 WorkManager 在后台(而不是在主线程上)执行工作。在此 Codelab 中,您将继续了解用于确保工作具有唯一性、标记工作、取消工作和设置工作约束条件的 WorkManager 功能。在此 Codelab 结束时,您将学习如何编写自动化测试来验证 worker 是否正常运行并返回预期结果。您还将学习如何使用 Android Studio 提供的后台任务检查器检查已加入队列的 worker。

构建内容

在此 Codelab 中,您将确保工作具有唯一性、标记工作、取消工作和实现工作约束条件。然后,您将学习如何为 Blur-O-Matic 应用编写自动化界面测试,以验证在使用 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,因为如果用户决定在当前图片完成之前对其他图片进行模糊处理,您需要停止当前图片并开始对新图片进行模糊处理。

您还希望确保,在工作请求已加入队列后,如果用户点击 Start,应用便会将之前的工作请求替换为新请求。继续处理上一个请求没有意义,因为应用仍然会用新请求替换它。

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. 根据工作状态标记和更新界面

您所做的下一个更改是应用在执行工作时显示的内容。返回的有关已加入队列工作的信息决定了界面需要如何更改。

下表显示了三种不同的方法,您可以通过调用这些方法来获取工作信息:

类型

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 决定要在界面中显示哪些可组合项。

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 类型中移除 ?,因为它不再需要为可为 null 类型。

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 变量的值。

界面代码使用 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 变量将界面状态信息以 StateFlow 的形式公开。该 Flow 通过调用 stateIn() 函数,从冷 Flow 转换为热 StateFlow

更新界面

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

when 代码块用于控制应用的界面。此 when 代码块针对三种 BlurUiState 状态分别有一个分支。

界面会在其 Row 可组合项内的 BlurActions 可组合项中更新。请完成以下步骤:

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

对于默认状态,应用会显示 Start 按钮。

  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 状态。对于此状态,应用会显示 Cancel Work 按钮和圆形进度指示器。

  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 状态,该状态会在图片进行模糊处理并保存之后发生。目前,应用仅显示 Start 按钮。

  1. 对于其 BlurUiState.Complete 状态中的 onClick 参数,请传递 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. 运行应用,然后点击 Start
  2. 查看后台任务检查器窗口,了解各种状态如何与显示的界面相对应。

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

在 worker 运行时,界面会显示 Cancel Work 按钮和圆形进度指示器。

3395cc370b580b32.png

c5622f923670cf67.png

worker 完成操作后,界面就会更新,按预期显示 Start 按钮。

97252f864ea042aa.png

81ba9962a8649e70.png

5. 显示最终输出

在本部分中,您将配置应用,使其在准备好显示经过模糊处理的图片后显示 See File 按钮。

创建 See File 按钮

仅当 BlurUiStateComplete 时,See File 按钮才会显示。

  1. 打开 ui/BluromaticScreen.kt 文件并前往 BlurActions 可组合项。
  2. 如需在 Start 按钮和 See File 按钮之间留有空间,请在 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. 如果 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 -> {
// ...

创建“See File”点击事件代码

当用户点击 See File 按钮时,其 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()
)
// ...

运行您的应用

运行应用。您现在会看到新的可点击 See File 按钮,点击该按钮就能打开保存的文件:

9d76d5d7f231c6b6.png

926e532cc24a0d4f.png

6. 取消工作

5cec830cc8ef647e.png

之前,您已添加 Cancel Work 按钮,现在您可以添加代码,让其执行某项操作。借助 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()
}

设置“Cancel Work”点击事件

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

运行应用并取消工作

运行应用。它编译得很好。开始对照片进行模糊处理,然后点击 Cancel Work。整个链都会被取消!

81ba9962a8649e70.png

取消工作后,系统只会显示 Start 按钮,因为 WorkInfo.StateCANCELLED。此更改会导致 blurUiState 变量设置为 BlurUiState.Default,这会将界面重置为初始状态,并且仅显示 Start 按钮。

后台任务检查器会按预期显示 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. 运行应用,然后点击 Start 以开始对图片进行模糊处理。

由于模拟器的电池电量设得较低,因此根据约束条件,WorkManager 不会运行 blurWorker 工作请求。它已加入队列,但被延迟,直到满足约束条件。您可以在 Background Task Inspector 标签页中查看此延迟情况。

7518cf0353d04f12.png

  1. 确认工作请求没有运行后,缓慢提高电池电量。

在电池电量达到约 25% 后,约束条件得到满足,推迟的工作开始运行。此结果会显示在 Background Task Inspector 标签页中。

ab189db49e7b8997.png

8. 为 worker 实现编写测试

如何测试 WorkManager

为 worker 编写测试并使用 WorkManager API 进行测试可能违背常理。在 worker 中完成的工作无法直接访问界面,这严格属于业务逻辑。通常,您需要使用本地单元测试来测试业务逻辑。不过,在“使用 WorkManager 处理后台工作”Codelab 中,您可能还记得 WorkManger 需要 Android Context 才能运行。默认情况下,本地单元测试没有 Context。因此,即使没有要测试的直接界面元素,也必须使用界面测试来测试 worker 测试。

设置依赖项

您需要向项目中添加三个 Gradle 依赖项。前两个用于为界面测试启用 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 的最新稳定版本。如果您更改了版本,请务必点击 Sync Now,将您的项目与更新后的 Gradle 文件同步。

创建测试类

  1. app > src 目录中为您的界面测试创建一个目录。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 添加到 WorkManager,作为 BlurWorker 的延续。因此,它具有相同的输入数据。它从输入数据中获取 URI,创建位图,然后将该位图作为文件写入磁盘。如果操作成功,输出结果将是一个图片网址。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 或更高的模拟器和设备。

在本部分中,您将了解 Background Task Inspector 为检查 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,然后点击 Start。您可以立即在 Workers 下拉菜单中看到部分内容。

现在,您可以在 Workers 下拉列表中看到如下内容。

569a8e0c1c6993ce.png

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

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

  1. 选择已加入队列的 worker,然后点击工具栏中的 Cancel Selected Worker 7108c2a82f64b348.png

检查任务详细信息

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

这样会打开 Task Details 窗口。

9d4e17f7d4afa6bd.png

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

详细信息包括以下类别:

  • Description:此部分列出了包含完全限定软件包的 worker 类名,以及此 worker 的已分配标记和 UUID。
  • Execution:此部分显示了 worker 的约束条件(若有)、运行频率、状态,以及哪个类创建了此 worker 并将其加入队列。回想一下,BlurWorker 有一个约束条件,可以在电池电量不足时阻止它执行。当您检查具有约束条件的 worker 时,它们会显示在此部分。
  • WorkContinuation:此部分显示了此 worker 在工作链中所处的位置。如需查看工作链中另一个 worker 的详情,请点击其 UUID。
  • Results:此部分显示了所选 worker 的开始时间、重试次数和输出数据。

图表视图

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

Background Task Inspector 窗口的一角有两个用于在 Show Graph ViewShow 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. 恭喜

恭喜!您了解了其他 WorkManger 功能,为 Blur-O-Matic worker 编写了自动化测试,并使用后台任务检查器对它们进行检查。在本 Codelab 中,您学习了以下内容:

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