在 Android 應用程式中使用 Kotlin 協同程式

在本程式碼研究室中,您將瞭解如何在 Android 應用程式中使用 Kotlin 協同程式,這種管理背景執行緒的新方法可減少回呼需求,進而簡化程式碼。協同程式是 Kotlin 的一項功能,可將資料庫或網路存取等長時間執行的工作,轉換為循序程式碼。

以下程式碼片段可讓您瞭解要執行的操作。

// Async callbacks
networkRequest { result ->
   // Successful network request
   databaseSave(result) { rows ->
     // Result saved
   }
}

以回呼為基礎的程式碼會使用協同程式轉換為循序程式碼。

// The same code with coroutines
val result = networkRequest()
// Successful network request
databaseSave(result)
// Result saved

您將從現有的應用程式著手,該應用程式是使用 Architecture Components 建構而成,並採用回呼樣式執行長時間執行的工作。

完成本程式碼研究室後,您將有足夠的經驗,在應用程式中使用協同程式從網路載入資料,並將協同程式整合至應用程式。您也會熟悉協同程式的最佳做法,以及如何針對使用協同程式的程式碼編寫測試。

必要條件

  • 熟悉架構元件 ViewModelLiveDataRepositoryRoom
  • 熟悉 Kotlin 語法,包括擴充功能函式和 lambda。
  • 瞭解如何在 Android 上使用執行緒,包括主執行緒、背景執行緒和回呼。

執行步驟

  • 呼叫以協同程式編寫的程式碼,並取得結果。
  • 使用暫停函式,讓非同步程式碼依序執行。
  • 使用 launchrunBlocking 控制程式碼的執行方式。
  • 瞭解如何使用 suspendCoroutine 將現有 API 轉換為協同程式。
  • 將協同程式與架構元件搭配使用。
  • 瞭解測試協同程式的最佳做法。

軟硬體需求

  • Android Studio 3.5 (程式碼研究室可能適用於其他版本,但部分項目可能會遺失或外觀不同)。

進行本程式碼研究室時,如果你遇到任何問題 (例如程式碼錯誤、文法錯誤或用詞不明確等),請透過程式碼研究室左下角的「回報錯誤」連結回報問題。

下載程式碼

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

下載 ZIP 檔

... 或使用下列指令,從指令列複製 GitHub 存放區:

$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git

常見 問題

首先,來看看初始範例應用程式的外觀。請按照這些操作說明,在 Android Studio 中開啟範例應用程式。

  1. 如果您下載的是 kotlin-coroutines zip 檔案,請解壓縮該檔案。
  2. 在 Android Studio 中開啟 coroutines-codelab 專案。
  3. 選取 start 應用程式模組。
  4. 按一下「Run」execute.png按鈕,然後選擇模擬器或連線至 Android 裝置 (必須能夠執行 Android Lollipop,支援的最低 SDK 為 21)。畫面上應會顯示 Kotlin 協同程式:

這個入門應用程式會在您按下螢幕後,使用執行緒以短暫延遲遞增計數。裝置也會從網路擷取新標題,並顯示在畫面上。現在試試看,稍待片刻後,您應該會看到計數和訊息有所變化。在本程式碼研究室中,您將轉換這個應用程式,改用協同程式。

這個應用程式會使用架構元件,將 MainActivity 中的 UI 程式碼與 MainViewModel 中的應用程式邏輯分開。請花點時間熟悉專案結構。

  1. MainActivity 會顯示 UI、註冊點擊監聽器,並顯示 Snackbar。它會將事件傳遞至 MainViewModel,並根據 MainViewModel 中的 LiveData 更新畫面。
  2. MainViewModel 會處理 onMainViewClicked 中的事件,並使用 LiveData.MainActivity 通訊
  3. Executors 定義 BACKGROUND,,可在背景執行緒上執行作業。
  4. TitleRepository 會從網路擷取結果,並儲存至資料庫。

在專案中新增協同程式

如要在 Kotlin 中使用協同程式,您必須在專案的 build.gradle (Module: app) 檔案中加入 coroutines-core 程式庫。程式碼研究室專案已為您完成這項操作,因此您不必執行這項操作即可完成程式碼研究室。

Android 上的協同程式可做為核心程式庫和 Android 專用擴充功能:

  • kotlinx-corountines-core :在 Kotlin 中使用協同程式的主要介面
  • kotlinx-coroutines-android :支援協同程式中的 Android 主要執行緒

啟動應用程式時,build.gradle. 中已包含依附元件。建立新的應用程式專案時,您需要開啟 build.gradle (Module: app),並將協同程式依附元件新增至專案。

dependencies {
  ...
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:x.x.x"
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:x.x.x"
}

在 Android 上,如何避免主執行緒遭到封鎖,就顯得相當重要。主執行緒是處理所有 UI 更新的單一執行緒,這個執行緒同時也會呼叫所有點擊處理常式,以及其他 UI 回呼。因此,UI 執行緒必須順暢執行,才能確保提供優質的使用者體驗。

為了避免使用者在應用程式中遇到任何卡頓情形,主執行緒每 16 毫秒或更短的間隔 (大約每秒 60 影格數) 就必須更新畫面一次。許多常見工作耗時超過這個時間,例如剖析大型 JSON 資料集、將資料寫入資料庫,或是從網路擷取資料。因此,如果從主執行緒呼叫這類程式碼,可能會導致應用程式暫停、延遲,乃至凍結。但是,如果封鎖主執行緒的時間過長,應用程式甚至可能會當機,並顯示「應用程式無回應」對話方塊。

請觀看下方影片,瞭解協同程式如何導入主執行緒安全機制,在 Android 上解決這個問題。

回呼模式

在不封鎖主執行緒的情況下,有種執行長時間工作的模式稱做「回呼」。利用回呼,您可以開始在背景執行緒上長時間執行工作。工作完成後,系統會呼叫回呼,然後將結果告知主執行緒。

請參閱回呼模式範例。

// Slow request with callbacks
@UiThread
fun makeNetworkRequest() {
    // The slow network request runs on another thread
    slowFetch { result ->
        // When the result is ready, this callback will get the result
        show(result)
    }
    // makeNetworkRequest() exits after calling slowFetch without waiting for the result
}

由於這段程式碼以 @UiThread 註解,因此必須快速執行,才能在主執行緒上執行。也就是說,它必須非常快速地傳回,以免延遲下一個畫面更新。不過,由於 slowFetch 需要幾秒甚至幾分鐘才能完成,因此主執行緒無法等待結果。show(result) 回呼可讓 slowFetch 在背景執行緒上執行,並在準備就緒時傳回結果。

使用協同程式移除回呼

回呼算是不錯的模式,但有些缺點。大量使用回呼的程式碼會變得不易讀取且難以推論,此外,回呼也不允許使用例外狀況等部分語言功能。

運用 Kotlin 協同程式,您可將以回呼為基礎的程式碼轉換為循序程式碼。依序編寫的程式碼通常較容易閱讀,甚至能使用例外狀況等語言功能。

說到底,協同程式和回呼所做的工作其實並無二致,亦即等到長時間執行的工作產生結果,再繼續執行。不過,在程式碼中,兩者看起來大不相同。

Kotlin 會使用 suspend 關鍵字標記可供協同程式使用的函式或函式類型。協同程式呼叫標示為 suspend 的函式時,不會像一般函式呼叫一樣封鎖,直到該函式傳回為止,而是會「暫停」執行,直到結果準備就緒,然後「繼續」從中斷處執行,並取得結果。在等待結果時,它會解除執行所在執行緒的封鎖 ,讓其他函式或協同程式可以執行。

舉例來說,在下方程式碼中,makeNetworkRequest()slowFetch() 都是 suspend 函式。

// Slow request with coroutines
@UiThread
suspend fun makeNetworkRequest() {
    // slowFetch is another suspend function so instead of 
    // blocking the main thread  makeNetworkRequest will `suspend` until the result is 
    // ready
    val result = slowFetch()
    // continue to execute after the result is ready
    show(result)
}

// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }

與回呼版本一樣,makeNetworkRequest 必須立即從主執行緒傳回,因為已標示為 @UiThread。也就是說,通常無法呼叫 slowFetch 等封鎖方法。這時 suspend 關鍵字就能發揮神奇效果。

相較於以回呼為基礎的程式碼,協同程式碼能以較少的程式碼,達到解除封鎖目前執行緒的相同結果。由於採用循序樣式,因此您可以輕鬆串連多個長時間執行的工作,不必建立多個回呼。舉例來說,從兩個網路端點擷取結果並儲存至資料庫的程式碼,可以寫成協同程式中的函式,且不含回呼。像這樣:

// Request data from network and save it to database with coroutines

// Because of the @WorkerThread, this function cannot be called on the
// main thread without causing an error.
@WorkerThread
suspend fun makeNetworkRequest() {
    // slowFetch and anotherFetch are suspend functions
    val slow = slowFetch()
    val another = anotherFetch()
    // save is a regular function and will block this thread
    database.save(slow, another)
}

// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }
// anotherFetch is main-safe using coroutines
suspend fun anotherFetch(): AnotherResult { ... }

您將在下一節中,將協同程式導入範例應用程式。

在本練習中,您將編寫協同程式,在延遲後顯示訊息。如要開始使用,請確認您已在 Android Studio 中開啟 start 模組。

瞭解 CoroutineScope

在 Kotlin 中,所有協同程式都會在 CoroutineScope 中執行。scope 會透過工作控制協同程式的生命週期。取消範圍的工作時,會同時取消在該範圍中啟動的協同程式。在 Android 上,您可以使用範圍取消所有正在執行的協同程式,例如使用者離開 ActivityFragment 時。範圍也可讓您指定預設的調度器。調度器會控管哪個執行緒執行協同程式。

如果是 UI 啟動的協同程式,通常會在 Dispatchers.Main 上啟動,也就是 Android 的主執行緒。在 Dispatchers.Main 上啟動的協同程式暫停時,不會封鎖主執行緒。由於 ViewModel 協同程式幾乎一律會在主執行緒上更新 UI,因此在主執行緒上啟動協同程式可省下額外的執行緒切換作業。在主執行緒上啟動的協同程式,可以在啟動後隨時切換調度器。舉例來說,它可以利用其他調度器,在主執行緒外剖析大型 JSON 結果。

使用 viewModelScope

AndroidX lifecycle-viewmodel-ktx 程式庫會將 CoroutineScope 新增至 ViewModel,並設定為啟動與 UI 相關的協同程式。如要使用這個程式庫,請務必將其納入專案的 build.gradle (Module: start) 檔案。程式碼研究室專案已完成該步驟。

dependencies {
  ...
  implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:x.x.x"
}

程式庫會將 viewModelScope 新增為 ViewModel 類別的擴充功能函式。這個範圍會繫結至 Dispatchers.Main,並在清除 ViewModel 時自動取消。

從執行緒切換至協同程式

MainViewModel.kt 中找出下一個 TODO,以及這段程式碼:

MainViewModel.kt

/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
   // TODO: Convert updateTaps to use coroutines
   tapCount++
   BACKGROUND.submit {
       Thread.sleep(1_000)
       _taps.postValue("$tapCount taps")
   }
}

這段程式碼會使用 BACKGROUND ExecutorService (在 util/Executor.kt 中定義) 在背景執行緒中執行。由於 sleep 會封鎖目前的執行緒,因此如果在主執行緒上呼叫,就會凍結 UI。使用者點選主要檢視區塊後一秒,系統就會要求顯示訊息列。

您可以從程式碼中移除 BACKGROUND,然後再次執行,即可看到這種情況。載入微調器不會顯示,所有內容會在 1 秒後「跳轉」至最終狀態。

MainViewModel.kt

/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
   // TODO: Convert updateTaps to use coroutines
   tapCount++
   Thread.sleep(1_000)
   _taps.postValue("$tapCount taps")
}

請將 updateTaps 替換為這個以協同程式為基礎的程式碼,兩者功能相同。您必須匯入 launchdelay

MainViewModel.kt

/**
* Wait one second then display a snackbar.
*/
fun updateTaps() {
   // launch a coroutine in viewModelScope
   viewModelScope.launch {
       tapCount++
       // suspend this coroutine for one second
       delay(1_000)
       // resume in the main dispatcher
       // _snackbar.value can be called directly from main thread
       _taps.postValue("$tapCount taps")
   }
}

這段程式碼的作用相同,會等待一秒後再顯示 Snackbar。不過,兩者之間還是有重大差異:

  1. viewModelScope.launch 會在 viewModelScope 中啟動協同程式。也就是說,當傳遞至 viewModelScope 的工作遭到取消時,這項工作/範圍中的所有協同程式都會取消。如果使用者在 delay 傳回前離開 Activity,當 ViewModel 遭到刪除而呼叫 onCleared 時,這個協同程式就會自動取消。
  2. 由於 viewModelScope 的預設調度器為 Dispatchers.Main,因此這個協同程式會在主執行緒中啟動。稍後我們會說明如何使用不同執行緒。
  3. delay 函式是 suspend 函式。Android Studio 會在左側裝訂線中顯示 圖示,即使這個協同程式是在主執行緒上執行,delay 也不會封鎖執行緒一秒鐘。而是會排定協同程式在下一條陳述式中於一秒後繼續執行。

請繼續執行。按一下主要檢視畫面,一秒後應該會看到 Snackbar。

下一節將說明如何測試這項函式。

在本練習中,您將為剛才編寫的程式碼撰寫測試。本練習說明如何使用 kotlinx-coroutines-test 程式庫,測試在 Dispatchers.Main 上執行的協同程式。在本程式碼研究室稍後的內容中,您將實作直接與協同程式互動的測試。

查看現有程式碼

開啟 androidTest 資料夾中的 MainViewModelTest.kt

MainViewModelTest.kt

class MainViewModelTest {
   @get:Rule
   val coroutineScope =  MainCoroutineScopeRule()
   @get:Rule
   val instantTaskExecutorRule = InstantTaskExecutorRule()

   lateinit var subject: MainViewModel

   @Before
   fun setup() {
       subject = MainViewModel(
           TitleRepository(
                   MainNetworkFake("OK"),
                   TitleDaoFake("initial")
           ))
   }
}

規則是在 JUnit 中執行測試前後執行程式碼的方式。我們使用兩項規則,在裝置外測試 MainViewModel:

  1. InstantTaskExecutorRule 是一項 JUnit 規則,可設定 LiveData 同步執行各項工作
  2. MainCoroutineScopeRule 是這個程式碼集中的自訂規則,可設定 Dispatchers.Main 使用 kotlinx-coroutines-test 中的 TestCoroutineDispatcher。這樣一來,測試就能推進測試用的虛擬時鐘,程式碼也能在單元測試中使用 Dispatchers.Main

setup 方法中,系統會使用測試偽造項目建立 MainViewModel 的新例項。這些是範例程式碼中提供的網路和資料庫偽造實作項目,可協助您撰寫測試,而不必使用實際的網路或資料庫。

在這項測試中,您只需要假的 MainViewModel 依附元件。在本程式碼研究室的後續章節,您將更新模擬物件,以支援協同程式。

撰寫可控制協同程式的測試

新增一項測試,確保點選主要檢視畫面後,輕觸次數會在 1 秒後更新:

MainViewModelTest.kt

@Test
fun whenMainClicked_updatesTaps() {
   subject.onMainViewClicked()
   Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("0 taps")
   coroutineScope.advanceTimeBy(1000)
   Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("1 taps")
}

呼叫 onMainViewClicked 後,系統就會啟動我們剛建立的協同程式。這項測試會檢查 onMainViewClicked 呼叫後,輕觸次數文字是否維持「0 次輕觸」,然後在 1 秒後更新為「1 次輕觸」

這項測試使用虛擬時間來控制 onMainViewClicked 啟動的協同程式執行作業。MainCoroutineScopeRule 可讓您暫停、繼續或控制在 Dispatchers.Main 上啟動的協同程式執行作業。這裡我們呼叫 advanceTimeBy(1_000),這會導致主要調度器立即執行排定在 1 秒後繼續執行的協同程式。

這項測試完全具有確定性,也就是說,測試一律會以相同方式執行。此外,由於它可以完全控管在 Dispatchers.Main 上啟動的協同程式執行作業,因此不必等待一秒鐘來設定值。

執行現有測試

  1. 在編輯器中,對類別名稱 MainViewModelTest 按一下滑鼠右鍵,開啟內容選單。
  2. 在內容選單中選擇「Run 'MainViewModelTest'」(執行「MainViewModelTest」)execute.png
  3. 日後執行時,您可以在工具列中 execute.png 按鈕旁的設定中選取這項測試設定。根據預設,設定會稱為 MainViewModelTest

您應該會看到測試通過!執行時間應該遠少於一秒。

在下一個練習中,您將瞭解如何從現有的回呼 API 轉換為使用協同程式。

在這個步驟中,您將開始轉換存放區,改用協同程式。為此,我們將協同程式新增至 ViewModelRepositoryRoomRetrofit

建議您先瞭解架構各部分的職責,再將其切換為使用協同程式。

  1. MainDatabase 會使用 Room 實作資料庫,儲存及載入 Title
  2. MainNetwork 會實作網路 API,用於擷取新標題。並使用 Retrofit 擷取標題。Retrofit 會隨機傳回錯誤或模擬資料,但其他行為與發出實際網路要求時相同。
  3. TitleRepository 會實作單一 API,結合網路和資料庫中的資料,擷取或重新整理標題。
  4. MainViewModel 代表畫面的狀態並處理事件。當使用者輕觸螢幕時,這會告知存放區重新整理標題。

由於網路要求是由 UI 事件驅動,且我們想根據這些事件啟動協同程式,因此使用協同程式的自然位置是 ViewModel

回呼版本

開啟 MainViewModel.kt 即可查看 refreshTitle 的聲明。

MainViewModel.kt

/**
* Update title text via this LiveData
*/
val title = repository.title


// ... other code ...


/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
   // TODO: Convert refreshTitle to use coroutines
   _spinner.value = true
   repository.refreshTitleWithCallbacks(object: TitleRefreshCallback {
       override fun onCompleted() {
           _spinner.postValue(false)
       }

       override fun onError(cause: Throwable) {
           _snackBar.postValue(cause.message)
           _spinner.postValue(false)
       }
   })
}

每當使用者點選畫面時,系統就會呼叫這個函式,並導致存放區重新整理標題,然後將新標題寫入資料庫。

這項實作會使用回呼執行下列操作:

  • 在開始查詢之前,系統會顯示載入旋轉圖示,並附上 _spinner.value = true
  • 取得結果後,系統會使用 _spinner.value = false 清除載入微調器
  • 如果發生錯誤,系統會告知 Snackbar 顯示內容,並清除微調器

請注意,onCompleted 回呼不會傳遞 title。由於我們會將所有標題寫入 Room 資料庫,因此 UI 會觀察 Room 更新的 LiveData,藉此更新為目前的標題。

在協同程式的更新中,我們將維持完全相同的行為。建議使用可觀察的資料來源 (例如 Room 資料庫),自動更新 UI。

協同程式版本

我們來使用協同程式重新編寫 refreshTitle

由於我們馬上就會用到,因此請在存放區 (TitleRespository.kt) 中建立空白的暫停函式。定義使用 suspend 運算子的新函式,告知 Kotlin 該函式會與協同程式搭配運作。

TitleRepository.kt

suspend fun refreshTitle() {
    // TODO: Refresh from network and write to database
    delay(500)
}

完成本程式碼研究室後,您將更新這項作業,使用 Retrofit 和 Room 擷取新標題,並使用協同程式將標題寫入資料庫。目前,這項函式只會花費 500 毫秒模擬執行工作,然後繼續。

MainViewModel 中,將回呼版本的 refreshTitle 替換為啟動新協同程式的版本:

MainViewModel.kt

/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           repository.refreshTitle()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

我們來逐步瞭解這個函式:

viewModelScope.launch {

與更新輕觸次數的協同程式相同,首先在 viewModelScope 中啟動新的協同程式。這會使用 Dispatchers.Main,沒問題。即使 refreshTitle 會發出網路要求和資料庫查詢,仍可使用協同程式公開 主執行緒安全的介面。也就是說,從主執行緒呼叫這個函式是安全的。

由於我們使用 viewModelScope,當使用者離開這個畫面時,這項協同程式啟動的工作會自動取消。也就是說,不會產生額外的網路要求或資料庫查詢。

接下來幾行程式碼會在 repository 中實際呼叫 refreshTitle

try {
    _spinner.value = true
    repository.refreshTitle()
}

這個協同程式會先啟動載入微調器,然後像一般函式一樣呼叫 refreshTitle。不過,由於 refreshTitle 是暫停函式,因此執行方式與一般函式不同。

我們不必傳遞回呼。協同程式會暫停,直到 refreshTitle 恢復執行為止。雖然這看起來就像一般的封鎖函式呼叫,但系統會自動等待網路和資料庫查詢完成,然後再繼續執行,不會封鎖主執行緒。

} catch (error: TitleRefreshError) {
    _snackBar.value = error.message
} finally {
    _spinner.value = false
}

暫停函式中的例外狀況與一般函式中的錯誤相同。如果在暫停函式中擲回錯誤,系統會將錯誤擲回給呼叫端。因此,即使兩者執行方式大相逕庭,您仍可使用一般的 try/catch 區塊來處理。這項功能相當實用,因為您可以依賴內建的語言支援來處理錯誤,不必為每個回呼建構自訂錯誤處理機制。

此外,如果您從協同程式擲回例外狀況,該協同程式預設會取消其父項。因此可以輕鬆地一併取消多個相關工作。

然後,在 finally 區塊中,我們可以確保查詢執行後一律會關閉微調器。

選取「start」設定並按下 execute.png,再次執行應用程式,輕觸任何位置時應該會看到載入微調器。由於我們尚未連結網路或資料庫,因此標題會維持不變。

在下一個練習中,您將更新存放區,實際執行工作。

在本練習中,您將瞭解如何切換協同程式執行的執行緒,以便實作 TitleRepository 的運作版本。

查看 refreshTitle 中的現有回呼程式碼

開啟 TitleRepository.kt,並檢查現有的回呼式實作方式。

TitleRepository.kt

// TitleRepository.kt

fun refreshTitleWithCallbacks(titleRefreshCallback: TitleRefreshCallback) {
   // This request will be run on a background thread by retrofit
   BACKGROUND.submit {
       try {
           // Make network request using a blocking call
           val result = network.fetchNextTitle().execute()
           if (result.isSuccessful) {
               // Save it to database
               titleDao.insertTitle(Title(result.body()!!))
               // Inform the caller the refresh is completed
               titleRefreshCallback.onCompleted()
           } else {
               // If it's not successful, inform the callback of the error
               titleRefreshCallback.onError(
                       TitleRefreshError("Unable to refresh title", null))
           }
       } catch (cause: Throwable) {
           // If anything throws an exception, inform the caller
           titleRefreshCallback.onError(
                   TitleRefreshError("Unable to refresh title", cause))
       }
   }
}

TitleRepository.kt 方法中,refreshTitleWithCallbacks 會實作回呼,將載入和錯誤狀態傳達給呼叫端。

這個函式會執行許多動作,以實作重新整理。

  1. 使用 BACKGROUND ExecutorService 切換至其他討論串
  2. 使用封鎖 execute() 方法執行 fetchNextTitle 網路要求。這會在目前的執行緒中執行網路要求,在本例中,這是 BACKGROUND 中的其中一個執行緒。
  3. 如果結果成功,請使用 insertTitle 將結果儲存至資料庫,然後呼叫 onCompleted() 方法。
  4. 如果結果不成功或發生例外狀況,請呼叫 onError 方法,將重新整理失敗的訊息告知呼叫端。

這個以回呼為基礎的實作方式對主執行緒無威脅,因為不會封鎖主執行緒。不過,這項作業必須使用回呼,在工作完成時通知呼叫端。此外,它也會在切換到的 BACKGROUND 執行緒上呼叫回呼。

從協同程式呼叫封鎖呼叫

我們可以使用協同程式,在不將協同程式導入網路或資料庫的情況下,讓這段程式碼不影響主執行緒。這樣我們就能擺脫回呼,並將結果傳回最初呼叫它的執行緒。

每當您需要在協同程式內執行封鎖或大量使用 CPU 的工作 (例如排序及篩選大型清單,或從磁碟讀取資料) 時,都可以使用這個模式。

如要在任何調度工具之間切換,協同程式會使用 withContext。呼叫 withContext 會切換至其他分派器 (僅適用於 lambda),然後使用該 lambda 的結果,返回呼叫它的分派器。

根據預設,Kotlin 協同程式會提供三個調度器:MainIODefault。調度器 IO 針對 IO 工作 (例如,讀取網路或磁碟) 進行最佳化,而調度器 Default 則針對大量耗用 CPU 的工作進行最佳化。

TitleRepository.kt

suspend fun refreshTitle() {
   // interact with *blocking* network and IO calls from a coroutine
   withContext(Dispatchers.IO) {
       val result = try {
           // Make network request using a blocking call
           network.fetchNextTitle().execute()
       } catch (cause: Throwable) {
           // If the network throws an exception, inform the caller
           throw TitleRefreshError("Unable to refresh title", cause)
       }
      
       if (result.isSuccessful) {
           // Save it to database
           titleDao.insertTitle(Title(result.body()!!))
       } else {
           // If it's not successful, inform the callback of the error
           throw TitleRefreshError("Unable to refresh title", null)
       }
   }
}

這個實作方式會針對網路和資料庫使用封鎖呼叫,但仍比回呼版本簡單一些。

這個程式碼仍會使用封鎖呼叫。呼叫 execute()insertTitle(...) 都會封鎖這個協同程式執行的執行緒。不過,使用 withContext 切換至 Dispatchers.IO 時,我們會封鎖 IO 調度工具中的其中一個執行緒。呼叫此函式的協同程式 (可能在 Dispatchers.Main 上執行) 會暫停,直到 withContext lambda 完成為止。

與回呼版本相比,有兩項重要差異:

  1. withContext 會將結果傳回給呼叫它的 Dispatcher,在本例中為 Dispatchers.Main。回呼版本會在 BACKGROUND 執行器服務的執行緒中呼叫回呼。
  2. 呼叫端不必將回呼傳遞至這個函式。他們可以依賴暫停和繼續來取得結果或錯誤。

再次執行應用程式

如果您再次執行應用程式,會發現以協同程式為基礎的新實作方式正在從網路載入結果!

在下一個步驟中,您將協同程式整合到 Room 和 Retrofit。

為了繼續整合協同程式,我們將使用穩定版 Room 和 Retrofit 中的暫停函式支援,然後使用暫停函式大幅簡化剛才編寫的程式碼。

Room 中的協同程式

首先開啟 MainDatabase.kt,然後將 insertTitle 設為暫停函式:

MainDatabase.kt

// add the suspend modifier to the existing insertTitle

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)

這麼做時,Room 會自動將查詢設為主執行緒安全,並在背景執行緒上執行。但這也表示您只能從協同程式內呼叫這項查詢。

這樣就完成所有步驟,您可以在 Room 中使用協同程式。相當實用。

Retrofit 中的協同程式

接下來,我們來看看如何將協同程式與 Retrofit 整合。開啟 MainNetwork.kt,並將 fetchNextTitle 變更為暫停函式。

MainNetwork.kt

// add suspend modifier to the existing fetchNextTitle
// change return type from Call<String> to String

interface MainNetwork {
   @GET("next_title.json")
   suspend fun fetchNextTitle(): String
}

如要搭配 Retrofit 使用暫停函式,您必須執行兩項操作:

  1. 在函式中新增暫停修飾符
  2. 從回傳型別中移除 Call 包裝函式。這裡我們傳回 String,但您也可以傳回複雜的 JSON 支援型別。如果仍想提供對 Retrofit 完整 Result 的存取權,可以從暫停函式傳回 Result<String>,而非 String

Retrofit 會自動讓暫停函式「不影響主執行緒」,因此您可以直接從 Dispatchers.Main 呼叫這些函式。

使用 Room 和 Retrofit

Room 和 Retrofit 現在支援暫停函式,因此我們可以從存放區使用這些函式。開啟 TitleRepository.kt,看看使用暫停函式如何大幅簡化邏輯,即使與封鎖版本相比也是如此:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = network.fetchNextTitle()
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

哇,這多了。發生什麼事?結果發現,依賴暫停和繼續執行可大幅縮短程式碼。Retrofit 可讓我們在此使用 StringUser 物件等傳回型別,而非 Call。這樣做很安全,因為在暫停函式中,Retrofit 可以在背景執行緒上執行網路要求,並在呼叫完成時繼續執行協同程式。

更棒的是,我們擺脫了 withContext。由於 Room 和 Retrofit 都提供對主執行緒沒有威脅的暫停函式,因此可安全地從 Dispatchers.Main 安排這項非同步工作。

修正編譯器錯誤

改用協同程式時,您必須變更函式的簽章,因為一般函式無法呼叫暫停函式。您在這個步驟中新增 suspend 修飾符時,系統會產生幾個編譯器錯誤,顯示在實際專案中將函式變更為暫停時會發生什麼情況。

瀏覽專案,並將建立的函式改為暫停函式,修正編譯器錯誤。以下是各項問題的快速解決方法:

TestingFakes.kt

更新測試模擬,支援新的暫停修飾符。

TitleDaoFake

  1. 按下 Alt+Enter,將暫停修飾符新增至階層中的所有函式

MainNetworkFake

  1. 按下 Alt+Enter,將暫停修飾符新增至階層中的所有函式
  2. fetchNextTitle 替換成這個函式
override suspend fun fetchNextTitle() = result

MainNetworkCompletableFake

  1. 按下 Alt+Enter,將暫停修飾符新增至階層中的所有函式
  2. fetchNextTitle 替換成這個函式
override suspend fun fetchNextTitle() = completable.await()

TitleRepository.kt

  • 刪除 refreshTitleWithCallbacks 函式,因為不再使用。

執行應用程式

再次執行應用程式,編譯完成後,您會發現應用程式正在使用協同程式載入資料,從 ViewModel 一路到 Room 和 Retrofit 都是如此!

恭喜,您已完全將這個應用程式換成使用協同程式!最後,我們會簡單說明如何測試剛才執行的操作。

在本練習中,您將編寫直接呼叫 suspend 函式的測試。

由於 refreshTitle 是以公開 API 的形式公開,因此會直接進行測試,示範如何從測試中呼叫協同程式函式。

以下是您在上一個練習中實作的 refreshTitle 函式:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = network.fetchNextTitle()
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

編寫會呼叫暫停函式的測試

開啟 test 資料夾中的 TitleRepositoryTest.kt,其中有兩個 TODO。

嘗試從第一個測試 whenRefreshTitleSuccess_insertsRows 呼叫 refreshTitle

@Test
fun whenRefreshTitleSuccess_insertsRows() {
   val subject = TitleRepository(
       MainNetworkFake("OK"),
       TitleDaoFake("title")
   )

   subject.refreshTitle()
}

由於 refreshTitlesuspend 函式,Kotlin 不知道如何呼叫該函式,只能從協同程式或其他暫停函式呼叫,因此您會收到類似「Suspend function refreshTitle should be called only from a coroutine or another suspend function.」的編譯器錯誤。

測試執行器對協同程式一無所知,因此我們無法將這項測試設為暫停函式。我們可以使用 CoroutineScope (如 ViewModel 中所示) 啟動協同程式,但測試必須先執行完畢,才能傳回結果。launch測試函式傳回後,測試就會結束。以 launch 啟動的協同程式是非同步程式碼,可能會在日後完成。因此,如要測試非同步程式碼,您需要某種方式來告知測試,等待協同程式完成。由於 launch 是非封鎖呼叫,因此會立即傳回,並可在函式傳回後繼續執行協同程式,但無法用於測試。例如:

@Test
fun whenRefreshTitleSuccess_insertsRows() {
   val subject = TitleRepository(
       MainNetworkFake("OK"),
       TitleDaoFake("title")
   )

   // launch starts a coroutine then immediately returns
   GlobalScope.launch {
       // since this is asynchronous code, this may be called *after* the test completes
       subject.refreshTitle()
   }
   // test function returns immediately, and
   // doesn't see the results of refreshTitle
}

這項測試有時會失敗。對 launch 的呼叫會立即傳回,並與其餘測試案例同時執行。測試無法判斷 refreshTitle 是否已執行,因此檢查資料庫是否已更新等任何判斷都會不穩定。此外,如果 refreshTitle 擲回例外狀況,測試呼叫堆疊就不會擲回例外狀況。而是會擲回 GlobalScope 的未偵測到例外狀況處理常式。

程式庫 kotlinx-coroutines-test 具有 runBlockingTest 函式,會在呼叫暫停函式時進行封鎖。根據預設,當 runBlockingTest 呼叫暫停函式或 launches 新的協同程式時,會立即執行該項作業。你可以將上述過程看為將暫停函式和協同程式轉換為一般函式呼叫的方法。

此外,runBlockingTest 會為您重新擲回未擷取的例外狀況。這樣就能更輕鬆地測試協同程式擲回例外狀況的情況。

使用一個協同程式導入測試

使用 runBlockingTest 包裝對 refreshTitle 的呼叫,並從 subject.refreshTitle() 移除 GlobalScope.launch 包裝函式。

TitleRepositoryTest.kt

@Test
fun whenRefreshTitleSuccess_insertsRows() = runBlockingTest {
   val titleDao = TitleDaoFake("title")
   val subject = TitleRepository(
           MainNetworkFake("OK"),
           titleDao
   )

   subject.refreshTitle()
   Truth.assertThat(titleDao.nextInsertedOrNull()).isEqualTo("OK")
}

這項測試會使用提供的模擬項目,檢查 refreshTitle 是否將「OK」插入資料庫。

測試呼叫 runBlockingTest 時,系統會暫時封鎖,直到 runBlockingTest 啟動的協同程式完成為止。然後在內部呼叫 refreshTitle 時,它會使用一般的暫停和恢復機制,等待資料庫列新增至我們的模擬資料。

測試協同程式完成後,系統會傳回 runBlockingTest

編寫逾時測試

我們想在網路要求中新增短暫的逾時時間。我們先編寫測試,然後實作逾時。建立新測試:

TitleRepositoryTest.kt

@Test(expected = TitleRefreshError::class)
fun whenRefreshTitleTimeout_throws() = runBlockingTest {
   val network = MainNetworkCompletableFake()
   val subject = TitleRepository(
           network,
           TitleDaoFake("title")
   )

   launch {
       subject.refreshTitle()
   }

   advanceTimeBy(5_000)
}

這項測試會使用提供的假 MainNetworkCompletableFake,也就是網路假資料,目的是暫停呼叫端,直到測試繼續執行為止。當 refreshTitle 嘗試提出網路要求時,會永遠處於閒置狀態,因為我們要測試逾時。

接著,系統會啟動個別的協同程式來呼叫 refreshTitle。這是測試逾時的重要環節,逾時應發生在 runBlockingTest 建立的協同程式以外的協同程式中。這樣一來,我們就能呼叫下一行 advanceTimeBy(5_000),將時間前進 5 秒,導致其他協同程式逾時。

這是完整的逾時測試,實作逾時後就會通過。

現在執行指令碼,看看會發生什麼事:

Caused by: kotlinx.coroutines.test.UncompletedCoroutinesError: Test finished with active jobs: ["...]

runBlockingTest 的其中一項功能是,測試完成後不會讓您洩漏協同程式。如果在測試結束時有任何未完成的協同程式 (例如啟動協同程式),測試就會失敗。

新增逾時

開啟 TitleRepository,並為網路擷取作業新增五秒逾時。如要執行這項操作,請使用 withTimeout 函式:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = withTimeout(5_000) {
           network.fetchNextTitle()
       }
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

執行測試。執行測試時,您應該會看到所有測試都通過!

在下一個練習中,您將瞭解如何使用協同程式編寫高階函式。

在本練習中,您將重構 MainViewModel 中的 refreshTitle,以使用一般資料載入函式。本課程將說明如何建構使用協同程式的高階函式。

目前 refreshTitle 的實作方式可行,但我們可以建立一律顯示微調器的通用資料載入協同程式。如果程式碼集會載入多個事件的回應資料,並想確保載入微調器一律會顯示,這項功能就可能派上用場。

查看目前的實作,除了 repository.refreshTitle() 以外,每一行都是樣板,用於顯示微調器和錯誤。

// MainViewModel.kt

fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           // this is the only part that changes between sources
           repository.refreshTitle() 
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

在高階函式中使用協同程式

將這段程式碼新增至 MainViewModel.kt。

MainViewModel.kt

private fun launchDataLoad(block: suspend () -> Unit): Job {
   return viewModelScope.launch {
       try {
           _spinner.value = true
           block()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

現在請重構 refreshTitle(),使用這個高階函式。

MainViewModel.kt

fun refreshTitle() {
   launchDataLoad {
       repository.refreshTitle()
   }
}

我們將顯示載入微調器和顯示錯誤的相關邏輯抽象化,簡化了載入資料所需的實際程式碼。顯示微調器或錯誤訊息很容易就能套用至任何資料載入作業,但每次都必須指定實際的資料來源和目的地。

如要建構這個抽象化,launchDataLoad 會採用做為暫停 lambda 的引數 block。暫停 lambda 可讓您呼叫暫停函式。這就是 Kotlin 實作協同程式建構函式 launchrunBlocking 的方式,我們在本程式碼研究室中一直使用這些函式。

// suspend lambda

block: suspend () -> Unit

如要建立暫停 lambda,請以 suspend 關鍵字開頭。函式箭頭和傳回類型 Unit 會完成宣告。

您通常不需要宣告自己的暫停 Lambda,但這類 Lambda 有助於建立這類抽象化,封裝重複的邏輯!

在本練習中,您將瞭解如何使用 WorkManager 的協同程式程式碼。

什麼是 WorkManager

Android 提供許多可延後執行的背景工作選項。本練習將說明如何整合 WorkManager 與協同程式。WorkManager 是相容性高、靈活又簡單的程式庫,適用於可延後的背景工作。在 Android 上,建議使用 WorkManager 處理這些用途。

WorkManager 屬於 Android Jetpack 的一部分,也是一種架構元件,用於處理需結合「機會式執行」和「保證執行」的背景工作。機會式執行表示 WorkManager 會盡快執行背景工作。保證執行是指 WorkManager 會確保工作在不同情況下都能執行,即便您離開應用程式也一樣。

因此,WorkManager 非常適合必須完成的工作。

以下列舉一些適合使用 WorkManager 的工作範例:

  • 上傳記錄檔
  • 為圖片套用濾鏡並儲存圖片
  • 定期將本機資料同步至網路

將協同程式與 WorkManager 搭配使用

WorkManager 會針對不同用途,提供基本 ListanableWorker 類別的不同實作方式。

最簡單的 Worker 類別可讓 WorkManager 執行某些同步作業。不過,我們目前已將程式碼集轉換為使用協同程式和暫停函式,因此使用 WorkManager 的最佳方式是透過 CoroutineWorker 類別,將 doWork() 函式定義為暫停函式。

如要開始使用,請開啟 RefreshMainDataWork。它已擴充 CoroutineWorker,您需要實作 doWork

suspend doWork 函式中,從存放區呼叫 refreshTitle(),並傳回適當結果!

完成 TODO 後,程式碼會如下所示:

override suspend fun doWork(): Result {
   val database = getDatabase(applicationContext)
   val repository = TitleRepository(network, database.titleDao)

   return try {
       repository.refreshTitle()
       Result.success()
   } catch (error: TitleRefreshError) {
       Result.failure()
   }
}

請注意,CoroutineWorker.doWork() 是暫停函式。與較簡單的 Worker 類別不同,這個程式碼「不會」在 WorkManager 設定中指定的 Executor 上執行,而是使用 coroutineContext 成員中的調度器 (預設為 Dispatchers.Default)。

測試 CoroutineWorker

任何程式碼集都應包含測試。

WorkManager 提供幾種不同的方式來測試 Worker 類別。如要進一步瞭解原始測試基礎架構,請參閱說明文件

WorkManager 2.1 版推出一組新 API,可支援更簡單的 ListenableWorker 類別測試方式,進而測試 CoroutineWorker。在程式碼中,我們會使用其中一個新 API:TestListenableWorkerBuilder

如要新增測試,請更新 androidTest 資料夾下的 RefreshMainDataWorkTest 檔案。

檔案內容如下:

package com.example.android.kotlincoroutines.main

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import androidx.work.testing.TestListenableWorkerBuilder
import com.example.android.kotlincoroutines.fakes.MainNetworkFake
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4


@RunWith(JUnit4::class)
class RefreshMainDataWorkTest {

@Test
fun testRefreshMainDataWork() {
   val fakeNetwork = MainNetworkFake("OK")

   val context = ApplicationProvider.getApplicationContext<Context>()
   val worker = TestListenableWorkerBuilder<RefreshMainDataWork>(context)
           .setWorkerFactory(RefreshMainDataWork.Factory(fakeNetwork))
           .build()

   // Start the work synchronously
   val result = worker.startWork().get()

   assertThat(result).isEqualTo(Result.success())
}

}

在進行測試之前,我們會告知 WorkManager 關於工廠的資訊,以便注入虛假的網路。

測試本身會使用 TestListenableWorkerBuilder 建立工作站,然後呼叫 startWork() 方法執行工作站。

WorkManager 只是協同程式簡化 API 設計的其中一個例子。

在本程式碼研究室中,我們介紹了在應用程式中開始使用協同程式所需的基本知識!

我們介紹了:

  • 如何將協同程式整合至 Android 應用程式 (包括 UI 和 WorkManager 工作),簡化非同步程式設計,
  • 如何在 ViewModel 內使用協同程式,從網路擷取資料並儲存到資料庫,同時不封鎖主執行緒。
  • 以及如何在 ViewModel 完成時取消所有協同程式。

如要測試以協同程式為基礎的程式碼,我們已透過測試行為和直接從測試呼叫 suspend 函式,涵蓋這兩種做法。

瞭解詳情

如要進一步瞭解 Android 上的進階協同程式用法,請參閱「使用 Kotlin Flow 和 LiveData 的進階協同程式」程式碼研究室。

Kotlin 協同程式有許多功能,本程式碼研究室並未涵蓋。如要進一步瞭解 Kotlin 協同程式,請參閱 JetBrains 發布的協同程式指南。此外,請參閱「使用 Kotlin 協同程式提升應用程式效能」,進一步瞭解 Android 上的協同程式使用模式。