高度な WorkManager とテスト

1. はじめに

WorkManager によるバックグラウンド処理の Codelab では、WorkManager を使用して(メインスレッドではなく)バックグラウンドで処理を実行する方法を学習しました。この Codelab では、WorkManager が備える処理チェーンを一意にする機能、処理にタグを付ける機能、処理をキャンセルする機能、処理に制約を付ける機能について引き続き学習します。この Codelab では、ワーカーが正常に機能し、期待される結果が返されることを検証する自動テストの作成方法を学習します。また、Android Studio に用意されている Background Task Inspector を使用して、キューに登録されたワーカーを検査する方法についても説明します。

作成するアプリの概要

この Codelab では、処理チェーンを一意にし、処理にタグを付け、処理をキャンセルし、処理の制約を実装します。その後、WorkManager によるバックグラウンド処理の Codelab で作成した 3 つのワーカーの機能を検証する、Blur-O-Matic アプリ用の自動 UI テストの作成方法を学習します。

  • BlurWorker
  • CleanupWorker
  • SaveImageToFileWorker

学習内容

  • 処理チェーンを一意にする
  • 処理のキャンセル方法
  • 処理の制約の定義方法
  • Worker の機能を検証する自動テストの作成方法
  • Background Task Inspector を使用して、キューに登録されているワーカーを検査する基本的な方法

必要なもの

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. 処理チェーンを一意にする

ワーカー チェーンの作成方法を確認したので、今度は WorkManager のもう一つの強力な機能である一意処理シーケンスに取り組みましょう。

実行する処理チェーンを一度に 1 つにしたい場合があります。たとえば、ローカルデータとサーバーとの同期を行う処理チェーンであれば、最初のデータ同期が完了してから 2 つ目を開始するのが望ましいでしょう。そのためには、beginWith() の代わりに beginUniqueWork() を使用し、一意の String の名前を付けます。この入力により、処理リクエストのチェーン全体に名前が付き、まとめて参照やクエリができるようになります。

さらに、ExistingWorkPolicy オブジェクトを渡す必要もあります。このオブジェクトは、処理がすでに存在する場合に何が起こるかを Android OS に示すものです。指定可能な ExistingWorkPolicy の値は、REPLACE、KEEP、APPEND、APPEND_OR_REPLACE です。

このアプリでは REPLACE を使用します。現在の画像のぼかし処理が終了する前に別の画像にぼかしを入れることが選択された場合、現在の画像の処理を停止し、新しい画像のぼかし処理を開始するためです。

また、処理リクエストがすでにキューに追加されているときに [Start] がクリックされた場合、以前の処理リクエストを新しいリクエストに置き換える必要があります。前のリクエストの処理を継続しても、新しいリクエストに置き換えられるため、意味がありません。

data/WorkManagerBluromaticRepository.kt ファイルの applyBlur() メソッド内で、次のように作業します。

  1. beginWith() 関数の呼び出しを削除し、beginUniqueWork() 関数の呼び出しを追加します。
  2. beginUniqueWork() 関数の最初のパラメータとして、定数 IMAGE_MANIPULATION_WORK_NAME を渡します。
  3. 2 つ目のパラメータである existingWorkPolicy パラメータとして、ExistingWorkPolicy.REPLACE を渡します。
  4. 3 つ目のパラメータとして、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 の変更方法が決まります。

次の表に、処理情報を取得するために使用できる 3 種類のメソッドを示します。

型

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() メソッドの使用です。このメソッドは 3 つすべての WorkRequest(CleanupWorker、BlurWorker、SaveImageToFileWorker)に関する情報を返します。この方法の欠点は、必要な 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 を取得する

BlurUiState に基づいて UI に表示するコンポーザブルを決定する際に、SaveImageToFileWorker 処理リクエストの WorkInfo 情報を使用するというロジックを使用します。

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. このメソッドを Flow に変換するために、.asFlow() 関数の呼び出しを連結します。LiveData の代わりに Kotlin Flow を使用できるように、このメソッドを変換します。

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() 変換関数が値の存在を保証し、null 値許容型である必要がなくなったので、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. blurUiState 変数にリポジトリから出力された outputWorkInfo Flow を設定します。

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() 関数の呼び出しには、以下の 3 つの引数が必要です。

  1. 最初のパラメータ viewModelScope は、ViewModel に紐付けられているコルーチン スコープです。
  2. 2 つ目のパラメータは SharingStarted.WhileSubscribed(5_000) です。このパラメータは、共有の開始と停止のタイミングを制御します。
  3. 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 ファイルで、ViewModel の blurUiState 変数から UI 状態を取得し、UI を更新します。

when ブロックでアプリの UI を制御します。この when ブロックには、3 つの 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 -> {}
        }
    }
...

デフォルトの状態では、アプリに [Start] ボタンが表示されます。

  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 状態です。この状態では、[Cancel Work] ボタンと円形の進行状況インジケーターが表示されます。

  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 状態です。これは、画像にぼかしを入れて保存すると発生します。この時点では、[Start] ボタンのみが表示されます。

  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. アプリを実行し、[Start] をクリックします。
  2. [Background Task Inspector] ウィンドウを参照して、各状態と表示される UI との対応を確認します。

SystemJobService は、Worker の実行を管理するコンポーネントです。

ワーカーの実行中は、UI に [Cancel Work] ボタンと円形の進行状況インジケーターが表示されます。

3395cc370b580b32.pngc5622f923670cf67.png

ワーカーが終了すると UI が更新され、[Start] ボタンが表示されるはずです。

97252f864ea042aa.png81ba9962a8649e70.png

5. 最終出力を表示する

このセクションでは、ぼかしを入れた画像を表示する準備ができたら [See File] ボタンを表示するようアプリを設定します。

[See File] ボタンを作成する

[See File] ボタンを BlurUiState が Complete の場合にのみ表示します。

  1. ui/BluromaticScreen.kt ファイルを開き、BlurActions コンポーザブルに移動します。
  2. [Start] ボタンと [See File] ボタンの間にスペースを追加するために、BlurUiState.Complete ブロック内で Spacer コンポーザブルを追加します。
  3. 新しい FilledTonalButton コンポーザブルを追加します。
  4. onClick パラメータとして、onSeeFileClick(blurUiState.outputUri) を渡します。
  5. Button のコンテンツ パラメータとして Text コンポーザブルを追加します。
  6. Text の text パラメータとして、文字列リソース 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 で設定され、WorkRequest の状態と、場合によっては 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 -> {
// ...

[See File] のクリック イベント コードを作成する

[See File] ボタンがクリックされると、その onClick ハンドラが割り当てられた関数を呼び出します。この関数は、BlurActions() コンポーザブルの呼び出しで引数として渡します。

この関数の目的は、その URI から取得した保存済み画像を表示することです。showBlurredImage() ヘルパー関数を呼び出し、URI を渡します。ヘルパー関数はインテントを作成し、それを使用して新しいアクティビティを開始して保存済み画像を表示します。

  1. ui/BluromaticScreen.kt ファイルを開きます。
  2. BluromaticScreenContent() 関数のコンポーズ可能な関数 BlurActions() の呼び出しで、currentUri というパラメータだけを受け取る onSeeFileClick パラメータのラムダ関数を作成します。この方法で保存済み画像の URI が保存されます。

ui/BluromaticScreen.kt

// ...
BlurActions(
    blurUiState = blurUiState,
    onStartClick = { applyBlur(selectedValue) },
    onSeeFileClick = { currentUri ->
    },
    onCancelClick = { cancelWork() },
    modifier = Modifier.fillMaxWidth()
)
// ...
  1. ラムダ関数の本体で、showBlurredImage() ヘルパー関数を呼び出します。
  2. 最初のパラメータとして、context 変数を渡します。
  3. 2 つ目のパラメータとして 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.png926e532cc24a0d4f.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

処理をキャンセルすると、WorkInfo.State が CANCELLED になるため、[Start] ボタンのみが表示されます。この変更により、blurUiState 変数は BlurUiState.Default に設定され、UI が初期状態にリセットされて [Start] ボタンのみが表示されます。

[Background Task Inspector] には、想定取りに [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 で行われる処理は UI に直接アクセスできないため、厳密なビジネス ロジックになります。通常は、ローカル単体テストでビジネス ロジックをテストします。しかし、WorkManager によるバックグラウンド処理の Codelab では、WorkManger の実行に Android の Context が必要でした。Context は、デフォルトではローカル単体テストで使用できません。そのため、テストする直接の UI 要素がない場合でも、UI テストで Worker テストをテストする必要があります。

依存関係を設定する

プロジェクトに 3 つの Gradle 依存関係を追加する必要があります。最初の 2 つは、UI テストのための JUnit と espresso を有効にします。3 つ目の依存関係は、処理テスト 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. UI テスト用のディレクトリを [app] > [src] に作成します。a7768e9b6ea994d3.png

20cc54de1756c884.png

  1. androidTest/java ディレクトリに WorkerInstrumentationTest という名前の新しい Kotlin クラスを作成します。

CleanupWorker テストを作成する

手順に沿ってテストを作成し、CleanupWorker の実装を検証します。この検証を、手順に沿ってご自身で実装してみてください。解答はこの手順の最後に用意してあります。

  1. WorkerInstrumentationTest.kt で、Context のインスタンスを保持する lateinit 変数を作成します。
  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 のテストには、さまざまな処理ビルダーが必要です。WorkManager API には、次の 2 種類のビルダーが用意されています。

どちらのビルダーでも、ワーカーのビジネス ロジックをテストできます。CleanupWorker、BlurWorker、SaveImageToFileWorker などの CoroutineWorkers は、複雑なコルーチンのスレッドを扱うため、テストには TestListenableWorkerBuilder を使用します。

  1. 各 CoroutineWorker は、コルーチンの使用により非同期に実行されます。ワーカーを並行に実行するために、runBlocking を使用します。初めは空のラムダ本体を指定し、ワーカーをキューに入れる代わりに、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 のラムダ本体で、ステップ 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-Value ペアとして、次のサンプルコードを使用します。
KEY_IMAGE_URI to "android.resource://com.example.bluromatic/drawable/android_cupcake"
  1. blurWorker_doWork_resultSuccessReturnsUri() 関数内で BlurWorker を作成し、作成したモック URI 入力を setInputData() メソッドで処理データとして渡します。

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-Value ペアが返されます。出力の内容が正しいことを確認するには、出力データにキー 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. Background Task Inspector を使用して WorkManager をデバッグする

ワーカーを検査する

自動テストは、Worker の機能を検証するのに最適な方法です。しかし、Worker をデバッグしようとしているときには、それほど役に立ちません。幸いなことに、Android Studio には、Worker をリアルタイムで可視化、モニタリング、デバッグできるツールがあります。Background Task Inspector は、API レベル 26 以降を搭載したエミュレータとデバイスで動作します。

このセクションでは、Blur-O-Matic のワーカーの検査に使用する Background Task Inspector の機能をいくつか説明します。

  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] プルダウン メニューをクリックします。この時点で、実行されているワーカーはありません。これは、画像にぼかしを入れるリクエストがなかったためです。

cf8c466b3fd7fed1.png

  1. アプリで [More blurred] を選択し、[Start] をクリックします。すぐに [Workers] プルダウンになんらかの内容が表示されます。

[Workers] プルダウンには次のように表示されます。

569a8e0c1c6993ce.png

Worker の表には、Worker の名前、Service(この場合は SystemJobService)、それぞれのステータス、タイムスタンプが表示されます。前のステップのスクリーンショットで、BlurWorker と CleanupWorker が正常にそれぞれの処理を完了させていることをご覧ください。

インスペクタを使って処理をキャンセルすることもできます。

  1. キューに入れられているワーカーを選択し、ツールバーの [Cancel Selected Worker ] 7108c2a82f64b348.png をクリックします。

タスクの詳細情報を検査する

  1. [Workers] の表でワーカーをクリックします。97eac5ad23c41127.png

この操作により、[Task Details] ウィンドウが表示されます。

9d4e17f7d4afa6bd.png

  1. [Task Details] に表示される情報を確認します。59fa1bf4ad8f4d8d.png

詳細情報には次のカテゴリが表示されます。

  • Description: このセクションには、ワーカークラス名と完全修飾パッケージが一覧表示され、このワーカーに割り当てられたタグと UUID も示されます。
  • Execution: このセクションには、ワーカーの制約(存在する場合)、実行頻度、その状態、このワーカーが作成されてキューに入れられたクラスが表示されます。BlurWorker には、電池残量が少ないときには実行できないという制約があることを思い出してください。制約のある Worker を検査すると、このセクションに表示されます。
  • WorkContinuation: このセクションには、このワーカーが存在する処理チェーン内の場所が表示されます。処理チェーン内の別のワーカーの詳細を確認するには、UUID をクリックします。
  • Results: このセクションには、選択したワーカーの開始時間、再試行回数、出力データが表示されます。

グラフ表示

Blur-O-Matic のワーカーは連結されていることを思い出してください。Background Task Inspector には、ワーカーの依存関係を視覚的に表すグラフビューが用意されています。

[Background Task Inspector] ウィンドウの隅に、[Show Graph View] と [Show List View] との間での切り替えを行う 2 つのボタンがあります。

4cd96a8b2773f466.png

  1. [Show Graph View] 6f871bb00ad8b11a.png をクリックします。

ece206da18cfd1c9.png

グラフビューに、Blur-O-Matic アプリに実装されている Worker の依存関係が正確に表示されます。

  1. [Show List View] 669084937ea340f5.png をクリックして、グラフビューを終了します。

その他の機能

Blur-O-Matic アプリは、バックグラウンド タスクを完了するために Worker のみを実装しています。他のタイプのバックグラウンド処理を検査する際に使用できるツールについては、Background Task Inspector のドキュメントをご覧ください。

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 ワーカーの自動テストを作成し、Background Task Inspector を使用してそれらを調査しました。この Codelab では、以下のことを学びました。

  • 一意の WorkRequest チェーンの命名
  • WorkRequest へのタグ付け
  • WorkInfo に基づく UI の更新
  • WorkRequest のキャンセル
  • WorkRequest への制約の追加
  • WorkManager テスト API
  • ワーカーの実装をテストする方法
  • CoroutineWorker のテスト方法
  • ワーカーを手動で検査して機能を検証する方法