在 Android 應用程式中使用 Kotlin Coroutine

在這個程式碼研究室中,您將瞭解如何在 Android 應用程式中使用 Kotlin Coroutines,這個新的管理執行緒能夠減少對回呼的需求,進而簡化程式碼。協同程式是 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

您將會從使用架構元件建構的現有應用程式開始執行,並使用回呼樣式來處理長時間執行的工作。

在這個程式碼研究室結束時,您將具備足夠的經驗,在應用程式中使用協同程式來載入網路資料,並且可將協同程式整合到應用程式中。您也會熟悉協同程式的最佳做法,以及如何針對使用協同程式的程式碼編寫測試。

事前準備

  • 熟悉架構元件 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. 按一下執行.png執行按鈕,選擇模擬器或連接您的 Android 裝置 (必須能夠執行 Android Lollipop (最低 SDK 支援 21)。Kotlin Coroutines 畫面隨即會出現:

這個啟動應用程式會使用執行緒,在你按下螢幕後,稍微增加一段時間。系統也會從網路擷取新標題並在螢幕上顯示。現在就試試看吧!您稍後應可在短時間內看到計數和訊息變更。在這個程式碼研究室中,您將轉換應用程式以使用協同程式。

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

  1. MainActivity 會顯示使用者介面,記錄點擊接聽器,並顯示 Snackbar。它會將事件傳送至 MainViewModel,基於 MainViewModel 中的 LiveData 更新屏幕。
  2. MainViewModel 會處理 onMainViewClicked 中的事件,並會使用 LiveData.MainActivity 進行通訊
  3. Executors 會定義可在背景執行緒中執行的 BACKGROUND,
  4. TitleRepository 會從網路擷取結果並將結果儲存到資料庫。

在專案中新增協同程式

如要在 Kotlin 中使用協同程式,您必須在專案的 build.gradle (Module: app) 檔案中加入 coroutines-core 程式庫。程式碼研究室專案已經為您代勞,因此您不必完成這個步驟,就能完成程式碼研究室。

Android 上的 Coroutines 有核心程式庫和 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 回呼的執行緒。因此必須能夠順暢執行,確保使用者享有優質的體驗。

如要讓主人能在不看見任何暫停狀態的情況下向使用者顯示應用程式,主要執行緒必須「每 16 毫秒以上」更新一次 (即每秒約 60 個畫格)。許多一般工作需要的時間較長,例如剖析大型 JSON 資料集、將資料寫入資料庫,或從網路擷取資料。因此,在主執行緒中呼叫這類程式碼可能會導致應用程式暫停、停止顯示或畫面凍結。如果封鎖主執行緒的時間過長,應用程式甚至可能會當機,並顯示「應用程式無回應」對話方塊。

請觀看以下影片,瞭解協同程式如何藉由在 Android 上推出主要安全機制,在 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 協同程式,你可將以回呼為基礎的程式碼轉換為循序程式碼。按順序編寫的程式碼通常更容易閱讀,甚至可使用例外功能,例如例外狀況。

最後,這些作業會完全執行下去:等待長時間執行的工作結果,再繼續執行。但程式碼則看起來略有不同。

關鍵字「suspend」是 Kotlin 的方法,可用來標記函式 (或函式類型),用於協同程式。協同程式會呼叫標記為 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 內執行。範圍可控制協同程式的生命週期。當您取消特定範圍的工作時,系統會取消在該範圍內啟動的所有協同程式。在 Android 裝置上,您可以設定範圍,在使用者離開 ActivityFragment 時取消所有執行中的協同程式。範圍也可讓您指定預設調度員。調度員可控制哪些執行緒執行協同程式。

針對 UI 啟動的協同程式,通常在 Dispatchers.Main (Android 上的主執行緒) 中啟用這項功能是正常的。在暫停使用的情況下,Dispatchers.Main 的協同程式不會封鎖主執行緒。由於 ViewModel 協同程式幾乎會更新主執行緒的 UI,因此在主執行緒上啟動協同程式可以節省額外的執行緒切換。在主執行緒上啟動的協同程式可以在啟用調度員後隨時變更調度員。例如,可使用另一個調度員剖析主執行緒之外的大型 JSON 結果。

使用 viewModelScope

AndroidX lifecycle-viewmodel-ktx 程式庫在 CorModels 中加入 CoroutineScope,並設定為可啟動 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。使用者點選主要檢視後一秒,它會要求使用 Snackbar。

只要在程式碼中移除 BACKGROUND,然後再次執行程式碼,就可以看見這樣。載入旋轉圖示代表正在顯示,而所有資訊都會以「跳躍」標示成最終狀態。

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")
   }
}

此程式碼的操作相同,請稍候 1 秒再顯示 Snackbar。然而,兩者間有下列重要差異:

  1. viewModelScope.launch將於 viewModelScope 中建立協同程式。因此,當我們傳送至 viewModelScope 的工作遭到取消時,此工作/範圍中的所有協同程式都會取消。如果使用者在 delay 傳回之前就已經離開活動,則當刪除 ViewModel 時,系統就會呼叫 onCleared,系統將自動取消此協同程式。
  2. 由於「viewModelScope」的預設調度員為 Dispatchers.Main,因此這個協同程式將會在主執行緒中啟動。我們稍後會說明如何使用其他執行緒。
  3. delay 函式是 suspend 函式。在 Android Studio 中,左側版位圖示的 圖示會顯示這個圖示。即使協同程式在主執行緒上執行,delay 仍會封鎖執行緒 1 秒。調度員將在下一次的聲明中安排協同程式在 1 秒內恢復執行。

立即執行吧!按一下主要資料檢視後,您應該會在一秒內看到點心吧。

在下一節中,我們會思考如何測試這項功能。

在本練習中,您會撰寫一些您剛寫過的程式碼的測試。本練習將說明如何使用 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 的相依關係中完成模擬作業。在這個程式碼研究室中,您將會更新假部分以支援協同程式。

撰寫控制協同程式的測試

新增一項測試,確保使用者點選主要檢視畫面後,系統會在一秒內更新資料:

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 次」。

這項測試使用 Virtual-time 來控制 onMainViewClicked 啟動的協同程式執行。MainCoroutineScopeRule 可讓您暫停、恢復或控制 Dispatchers.Main 上啟動的協同程式。我們在此呼叫 advanceTimeBy(1_000),這會讓主要調度員立即執行已排定於 1 秒後恢復的協同程式。

這項測試具有完全的決定性,因此將一律以同樣的方式執行。而且,由於在 Dispatchers.Main 上控制的協同程式執行完全掌控,所以不用花一秒鐘的時間設定值。

執行現有測試

  1. 在編輯器中的課程名稱 MainViewModelTest 上按一下滑鼠右鍵,即可開啟內容選單。
  2. 在內容選單中,選擇 執行.png[Run 'MainViewModelTest']
  3. 針對日後的執行作業,您可以在工具列中的 執行.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 資料庫,因此使用者介面會觀察 RoomLiveData,以更新至目前的標題。

在協同程式更新作業中,我們會保留相同的行為。建議您使用 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 會發出網路要求和資料庫查詢,但仍然可以使用協同程式公開 main-safe 介面。也就是說,在主執行緒中可以安全呼叫該程式碼。

由於我們正在使用viewModelScope,因此當使用者離開這個畫面時,系統會自動取消這個協同作業所啟動的工作。這表示不會發出額外的網路要求或資料庫查詢。

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

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

在這個協同程式執行任何動作前,會啟動載入旋轉圖示,此呼叫會像一般函式一樣呼叫 refreshTitle。不過,由於 refreshTitle 是懸置函式,因此其執行方式與一般函式不同。

我們不需要傳送回呼。這項協同程式會在 refreshTitle恢復前暫停。這看起來就如同「一般」封鎖函式呼叫,但會等到網路和資料庫查詢完成之後,再「繼續」封鎖主執行緒。

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

暫停函式中的例外狀況與一般函式中的錯誤類似。如果您在懸置函式中擲回錯誤,系統會將該錯誤推送到呼叫者。因此即使兩者的執行方式大不相同,您仍可使用一般的嘗試/封鎖區塊來處理這些行為。這項做法很有用,因為您可以利用內建語言支援處理錯誤,而不需要針對每個回呼建立自訂錯誤處理。

此外,如果您排除了協同程式的例外狀況,根據預設,該協同程式也會取消該位的父母。因此,您可以輕鬆取消多項相關工作。

最後,在最後一個區塊中,我們可以確保在執行查詢後,一定會關閉旋轉圖示。

如要再次執行應用程式,請選取 [start] 設定,然後按下 執行.png,系統隨即顯示輕觸圖示時,應該會顯示載入旋轉圖示。不過,由於我們尚未連結我們的網路或資料庫,因此影片標題仍維持不變。

在下一次練習中,您將更新存放區以實際執行。

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

在 updateTitle 中查看現有的回呼代碼

開啟 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. 切換至另一個包含BACKGROUNDExecutorService的對話串
  2. 使用封鎖 execute() 方法執行 fetchNextTitle 網路要求。這會在目前的執行緒中執行網路要求,這裡是指 BACKGROUND 中的其中一個執行緒。
  3. 如果結果成功,請使用 insertTitle 將結果儲存到資料庫中,並呼叫 onCompleted() 方法。
  4. 如果結果未成功,或出現例外狀況,請呼叫 onError 方法,告知呼叫失敗。

這個回呼式實作為 main-safe,因為這個規則不會封鎖主執行緒。但是,工作完成時,必須使用回呼功能通知來電者。也會在 BACKGROUND 執行緒上呼叫回呼,但這個函式也已經切換。

在通話中封鎖來自協同程式的通話

如果不將協同程式加到網路或資料庫,我們可以使用協同程式將程式碼設為主要安全。如此一來,我們就能刪除回呼,讓我們將結果傳回回原本呼叫的執行緒。

您可以隨時利用這個模式,在協同程式內部封鎖或耗用大量 CPU 工作,例如排序和篩選大型清單,或從磁碟讀取。

如要切換任一調度員,協同程式會使用 withContext。呼叫 withContext 會切換到另一個調度員只供 lambda 使用,然後再返回該呼叫,以呼叫該 lambda 的結果。

根據預設,Kotlin 協同程式會提供三個調度員:MainIODefault。IO 調度員經過最佳化處理,能夠提升 IO 作業的效能 (例如從網路或磁碟讀取),而預設調度員則針對 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 會將結果傳回給稱為該條件的調度人員,在本例中為 Dispatchers.Main。回呼版本稱為 BACKGROUND 執行器服務中的執行緒中的回呼。
  2. 呼叫者不需要傳送回呼給此函式。他們可以運用停權和恢復功能,取得結果或錯誤。

再次執行應用程式

當您再次執行應用程式時,就會發現新的協同程式實作是從網路載入結果!

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

為了繼續整合協同程式,我們將使用支援至「Room and Retrofit」(穩定版) 和「Retrofit」(固定版) 功能中的暫停功能,然後運用使用懸置函式,簡化剛才撰寫的程式碼。

聊天室中的協同程式

首先開啟 MainDatabase.kt 並將 insertTitle 設為懸置函式:

MainDatabase.kt

// add the suspend modifier to the existing insertTitle

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

如此一來,Room 會將您的查詢設為「main-safe」,並自動在背景執行緒上執行。但這也意味著您只能在 協同程式內部呼叫此查詢。

這樣就能在聊天室中使用協同程式。超讚。

照明改造酒精

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

主要網路.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-backed 類型。如果您仍想提供對 retrofit 的完整 Result 存取權,可以從暫停函式中傳回 Result<String>,而不是 String

Retrofit 會自動將懸置函式設為 main-safe,方便您直接透過 Dispatchers.Main 呼叫這些函式。

使用會議室和改裝功能

由於「Room」和「Retrofit」支援懸置函式,因此現在可以從我們的存放區使用這些功能。開啟 TitleRepository.kt,看看相較於暫停版本,如何使用暫停函式大幅簡化邏輯:

標題:Repository.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)
   }
}

哇,那是一個一個小圖。What happened? 我們發現,運用停權與恢復功能可以縮短程式碼。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)
   }
}

撰寫會呼叫懸置函式的測試

在包含兩個 TODOS 的 test 資料夾中開啟 TitleRepositoryTest.kt

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

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

   subject.refreshTitle()
}

由於 refreshTitle 是 Kotlin 的 suspend 函式,因此除了 Coroutine 或其他懸置函式以外,系統無法知道如何呼叫這個函式,所以會收到編譯器錯誤,例如:“如果這個函式中不應出現暫停函式。suspend

測試執行器並不知道協同程式的知識,因此我們不能將這項測試設為暫停功能。我們可以透過 CoroutineScope 使用 launch 協同程式,就像在 ViewModel 中一樣,但是測試必須執行協同程式才能傳回。測試函式傳回後,就會結束測試。以 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-testrunBlockingTest 函式會在呼叫懸置函式時封鎖。當 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 是否已將「確定」插入資料庫。

測試呼叫 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,並為網路擷取作業新增 5 秒的逾時時間。您可以使用 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 會使用 block 引數 (屬於懸置的 lambda)。暫停 lambda 可讓您呼叫懸置函式。這就是 Kotlin 實作本程式碼研究室所使用的 launchrunBlocking 協同程式建構工具的方式。

// suspend lambda

block: suspend () -> Unit

若要開始使用 lambda,請從 suspend 關鍵字開始。函式箭頭和傳回類型 Unit 完成宣告。

您通常不需要宣告自己的停權 lambda,但它們有助於建立類似的抽象化,以包覆重複邏輯!

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

什麼是 WorkManager

Android 應用程式提供許多背景式的背景處理功能,本練習說明如何將 WorkManager 與協同程式整合。WorkManager 是相容、靈活且簡單易用的程式庫,可處理背景作業。WorkManager 是上述 Android 用途的建議做法。

WorkManager 是 Android Jetpack 的一部分,而架構元件則用於需要以隨機方式操作且保證執行的作業。執行不當,表示 WorkManager 會盡快為您完成背景工作。保證的執行方式意味著 WorkManager 會妥善處理各種情況,即便您離開了應用程式也一樣。

有鑑於此,WorkManager 是最後完成工作的最佳選擇。

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

  • 上傳紀錄
  • 為圖片套用篩選器並儲存圖片
  • 定期將本機資料同步至網路

將協同程式與 WorkManager 搭配使用

WorkManager 為不同用途提供了其基本 ListanableWorker 類別的實作。

最簡單的工作站類別可讓我們執行 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 設定中指定的執行程式上執行,而是在 coroutineContext 成員中使用調度員 (預設為 Dispatchers.Default)。

測試 CoroutineWorker

不得在未經測試的情況下完成任何程式碼集。

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

WorkManager v2.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 設計的範例之一。

在本程式碼研究室中,我們說明瞭在應用程式中使用協同程式的基本知識!

包括:

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

為了測試協同程式程式碼,我們透過測試行為來完成測試,並直接從測試呼叫 suspend 函式。

瞭解詳情

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

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