進階 WorkManager 和測試

1. 簡介

使用 WorkManager 處理背景工作程式碼研究室中,您已瞭解如何使用 WorkManager 在背景 (而非主執行緒) 執行工作。在本程式碼研究室中,您將繼續學習 WorkManager 功能,瞭解如何確保不重複工作、標記工作、取消工作,以及工作限制條件。本程式碼研究室會說明如何編寫自動化測試驗證 worker 運作狀況,並傳回預期結果。此外,您也會瞭解如何使用 Android Studio 提供的背景工作檢查器,檢查佇列中的 worker。

建構項目

在這個程式碼研究室中,您將確保不重複工作、標記工作、取消工作,以及實作工作限制條件。您也會瞭解如何為 Blur-O-Matic 應用程式編寫自動化 UI 測試,驗證在使用 WorkManager 處理背景工作程式碼研究室中建立的三個 worker 功能:

  • BlurWorker
  • CleanupWorker
  • SaveImageToFileWorker

課程內容

  • 確保不重複工作
  • 如何取消工作。
  • 如何定義工作限制條件
  • 如何編寫自動化測試以驗證 worker 功能。
  • 使用背景工作檢查器檢查已排入佇列的 worker。

軟硬體需求

2. 開始設定

下載程式碼

點選下方連結即可下載這個程式碼研究室的所有程式碼:

您也可以視需要從 GitHub 複製程式碼:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-workmanager.git
$ cd basic-android-kotlin-compose-training-workmanager
$ git checkout intermediate

在 Android Studio 中開啟專案。

3. 確保不重複工作

現在您已瞭解如何鏈結 worker,可以開始使用 WorkManager 的另一個強大功能:不重複工作序列

有時候,您會希望一次只執行一個工作鏈結。舉例來說,假如您有一個用來將本機資料與伺服器保持同步的工作鏈結,您可能會想先完成第一次的資料同步作業,再開始新的同步程序。如要這麼做,請使用 beginUniqueWork() 而非 beginWith(),並提供不重複的 String 名稱。這個輸入內容會命名整個工作要求鏈結,方便您一併參照及查詢這些要求。

您還需要傳入 ExistingWorkPolicy 物件。這個物件會通知 Android 作業系統如果工作已存在,會發生什麼情況。可能的 ExistingWorkPolicy 值為 REPLACEKEEPAPPEND、或 APPEND_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. 根據工作狀態標記及更新 UI

您接下來所做的變更,是應用程式在工作執行時顯示的內容。系統傳回的已佇列工作相關資訊,會決定 UI 需如何變更。

下表顯示了三種方法,您可以呼叫方法來取得工作資訊:

類型

WorkManager 方法

說明

使用 ID 取得工作

getWorkInfoByIdLiveData()

此函式會根據特定 WorkRequest 的 ID,傳回單一 LiveData<WorkInfo>

使用專屬鏈結名稱取得工作

getWorkInfosForUniqueWorkLiveData()

這個函式會為 WorkRequests 專屬鏈結中的所有工作傳回 LiveData<List<WorkInfo>>

使用標記取得工作

getWorkInfosByTagLiveData()

這個函式會為標記傳回 LiveData<List<WorkInfo>>

WorkInfo 物件包含 WorkRequest 目前狀態的詳細資料,包括:

這些方法會傳回 LiveData。LiveData 是生命週期感知型可觀測的資料容器,我們會透過呼叫 .asFlow() 將其轉換為 WorkInfo 物件的資料流。

如果您想瞭解圖片成品的儲存時間,可以在 SaveImageToFileWorker WorkRequest 中新增標記,即可透過 getWorkInfosByTagLiveData() 方法取得 WorkInfo。

另一種方式是使用 getWorkInfosForUniqueWorkLiveData() 方法,此方法會傳回全部三個 WorkRequest (CleanupWorkerBlurWorkerSaveImageToFileWorker) 的資訊。這個方法的缺點是,您需要額外的程式碼,特別用於找出所需的 SaveImageToFileWorker 資訊。

標記工作要求

標記工作是在 applyBlur() 函式內的 data/WorkManagerBluromaticRepository.kt 檔案中完成。

  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() 函式會將其轉換為資料流。

  1. 鏈結對 .asFlow() 函式的呼叫,以便將該方法轉換為資料流。轉換方法可讓應用程式能夠使用 Kotlin 資料流,而非 LiveData。

data/WorkManagerBluromaticRepository.kt

import androidx.lifecycle.asFlow
...
override val outputWorkInfo: Flow<WorkInfo?> =
    workManager.getWorkInfosByTagLiveData(TAG_OUTPUT).asFlow()
...
  1. 鏈結對 .mapNotNull() 轉換函式的呼叫,以確保資料流含有值。
  2. 如果是轉換規則,若元素並非空白,請選取集合中的第一個項目,否則系統會傳回空值。若傳回空值,轉換函式會將其移除。

data/WorkManagerBluromaticRepository.kt

import kotlinx.coroutines.flow.mapNotNull
...
    override val outputWorkInfo: Flow<WorkInfo?> =
        workManager.getWorkInfosByTagLiveData(TAG_OUTPUT).asFlow().mapNotNull {
            if (it.isNotEmpty()) it.first() else null
        }
...
  1. 由於 .mapNotNull() 轉換函式可保證有值存在,因此您可以放心從資料流類型中移除 ?,因為其不再需要設為可為空值的類型。

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 資料流存放區發送的 WorkInfo 來設定 blurUiState 變數的值。

UI 程式碼會使用 blurUiState 變數值來判斷要顯示的可組合項。

如要更新 blurUiState,請完成下列步驟:

  1. 透過 outputWorkInfo 資料流從存放區填入 blurUiState 變數。

ui/BlurViewModel.kt

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

// ADD
val blurUiState: StateFlow<BlurUiState> = bluromaticRepository.outputWorkInfo
// ...
  1. 接下來,您需要根據工作狀態,將資料流中的值對應至 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() 函式來轉換資料流。

呼叫 .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 會在 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 參數,請傳遞 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 執行作業的元件。

worker 執行期間,UI 會顯示「Cancel Work」按鈕和圓形進度指標。

3395cc370b580b32.png

c5622f923670cf67.png

worker 完成作業後,UI 就會更新,並如預期顯示「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」按鈕時,它的 onClick 處理常式會呼叫其指派的函式。此函式會在對 BlurActions() 可組合項的呼叫中做為引數傳遞。

此函式的目的是顯示其 URI 中儲存的圖片。它會呼叫 showBlurredImage() 輔助函式並傳入 URI。輔助函式會建立意圖,並使用該意圖啟動新活動,用來顯示已儲存的圖片。

  1. 開啟 ui/BluromaticScreen.kt 檔案。
  2. BluromaticScreenContent() 函式中,呼叫 BlurActions() 可組合函式時,開始為 onSeeFileClick 參數建立一個 lambda 函式,該函式會使用名為 currentUri 的單一參數。這個方法會保存已儲存圖片的 URI。

ui/BluromaticScreen.kt

// ...
BlurActions(
    blurUiState = blurUiState,
    onStartClick = { applyBlur(selectedValue) },
    onSeeFileClick = { currentUri ->
    },
    onCancelClick = { cancelWork() },
    modifier = Modifier.fillMaxWidth()
)
// ...
  1. 在 lambda 函式的主體中,呼叫 showBlurredImage() 輔助函式。
  2. 針對第一個參數,傳遞 context 變數。
  3. 針對第二個參數,傳遞 currentUri 變數。

ui/BluromaticScreen.kt

// ...
BlurActions(
    blurUiState = blurUiState,
    onStartClick = { applyBlur(selectedValue) },
    // New lambda code runs when See File button is clicked
    onSeeFileClick = { currentUri ->
        showBlurredImage(context, currentUri)
    },
    onCancelClick = { cancelWork() },
    modifier = Modifier.fillMaxWidth()
)
// ...

執行應用程式

請執行應用程式。現在您會看到可點選的新「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()
}

設定「取消工作」點擊事件

  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. blurViewModel::cancelWork 值指派給 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.StateCANCELLED,所以畫面上只會顯示「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 處理背景工作」程式碼研究室曾提到,WorkManger 必須要有 Android Context 才能執行。根據預設,本機單元測試沒有 Context。因此,即使沒有可測試的直接 UI 元素,您仍須使用 UI 測試來進行 worker 測試。

設定依附元件

您必須在專案中新增三個 Gradle 依附元件。前兩個依附元件用來為 UI 測試啟用 JUnit 和 espresso,第三個依附元件則提供工作測試 API。

app/build.gradle.kts

dependencies {
    // Espresso
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    // Junit
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    // Work testing
    androidTestImplementation("androidx.work:work-testing:2.8.1")
}

您需要在應用程式中使用 work-runtime-ktx 的最新穩定版。如果您變更了版本,請務必按一下「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 結構定義變數。
  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 的商業邏輯。請使用 TestListenableWorkerBuilderCoroutineWorkers (例如 CleanupWorkerBlurWorkerSaveImageToFileWorker) 進行測試,因為它會處理協同程式的執行緒複雜度。

  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. 在 lambda 主體的 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(),表示存取檔案目錄並未發生錯誤。

現在要做出斷言,指出 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 以上的裝置。

本節將說明背景工作檢查器的部分功能,可用來檢查 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

「Worker」資料表會顯示 worker 的名稱、服務 (在此示例中為 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 在電量不足時執行。當您檢查有限制條件的 worker 時,便會在這個部分顯示。
  • WorkContinuation:這個部分會顯示此 worker 在工作鏈結中的位置。如要查看工作鏈結中其他 worker 的詳細資料,請按一下其 UUID。
  • Results:這個部分會顯示所選 worker 的開始時間、重試次數和輸出資料。

圖表檢視

提醒您,Blur-O-Matic 中的 worker 是鏈結狀態。背景工作檢查器提供圖表檢視模式,能以視覺化方式呈現 worker 的依附元件。

「Background Task Inspector」視窗角落的兩個按鈕可用來切換以下檢視模式:「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. 取得解決方案程式碼

完成程式碼研究室後,如要下載當中用到的程式碼,您可以使用這些指令:

$ 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 中開啟。

如要查看本程式碼研究室的解決方案程式碼,請前往 GitHub

11. 恭喜

恭喜!您已經瞭解 WorkManger 的其他功能、為 Blur-O-Matic 的 worker 編寫自動化測試,並且使用背景工作檢查器檢查 worker。在本程式碼研究室中,您已瞭解以下內容:

  • 為不重複的 WorkRequest 鏈結命名。
  • 標記 WorkRequest
  • 根據 WorkInfo 更新 UI。
  • 取消 WorkRequest
  • WorkRequest 中新增限制條件。
  • WorkManager 測試 API。
  • 如何測試 worker 實作結果。
  • 如何測試 CoroutineWorker
  • 如何手動檢查 worker 並驗證其功能。