進階 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 值為 REPLACE、KEEP、APPEND、或 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 (CleanupWorker、BlurWorker 和 SaveImageToFileWorker) 的資訊。這個方法的缺點是,您需要額外的程式碼,特別用於找出所需的 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 檔案中,您可以從 ViewModel 的 blurUiState 變數取得 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.pngc5622f923670cf67.png

worker 完成作業後,UI 就會更新,並如預期顯示「Start」按鈕。

97252f864ea042aa.png81ba9962a8649e70.png

5. 顯示最終輸出結果

在本章節中,您將對應用程式進行設定,在經過模糊處理的圖片可供查看時,顯示「See File」按鈕。

建立「See File」按鈕

只有在 BlurUiState 為 Complete 時,才會顯示「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. 針對 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 中設定,取決於工作要求的狀態或 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.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()
}

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

  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.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 處理背景工作」程式碼研究室曾提到,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 的商業邏輯。請使用 TestListenableWorkerBuilder 對 CoroutineWorkers (例如 CleanupWorker、BlurWorker 和 SaveImageToFileWorker) 進行測試,因為它會處理協同程式的執行緒複雜度。

  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 的狀態,以及時間戳記。請注意,在前一個步驟的螢幕截圖中,BlurWorker 和 CleanupWorker 已順利完成工作。

您也可以使用檢查器取消工作。

  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 並驗證其功能。