使用 Kotlin 協同程式提升應用程式效能

Kotlin 協同程式可讓您編寫簡潔和簡化的非同步程式碼,讓應用程式即使正在管理長時間執行的工作 (例如網路呼叫或磁碟作業),仍能回應其他操作。

本主題會詳細介紹 Android 上的協同程式。如果您還不熟悉協同程式,在閱讀本主題前,請務必先參考「Android 上的 Kotlin 協同程式」。

管理長時間執行的工作

協同程式會在常見函式中增添兩項作業,以處理長時間執行的工作。亦即,除了 invoke (或 call) 和 return 外,協同程式還新增了 suspend 和 resume:

  • suspend 會暫停執行當前的協同程式,並儲存所有本機變數。
  • resume 會在先前暫停處繼續執行已暫停的協同程式。

您只能透過其他 suspend 函式呼叫 suspend 函式,或使用協同程式建構工具 (例如 launch) 來啟動新的協同程式。

以下範例顯示,在某個長時間執行的工作中,實作簡易的協同程式:

suspend fun fetchDocs() { // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result) // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) {
    /* ... */
}

在這個範例中,get() 仍在主執行緒上執行,但在執行網路要求前,先讓協同程式暫停了。當網路要求完成時,get 繼續執行已暫停的協同程式,而不是使用回呼來通知主執行緒。

Kotlin 使用「堆疊框架」來管理要搭配本機變數執行的函式。暫停協同程式時,系統會複製和儲存目前的堆疊框架,供日後使用。繼續執行協同程式時,系統會從儲存的位置複製堆疊框架,並再次執行函式。即使程式碼看起來是普通的連續封鎖要求,但協同程式仍能確保網路要求不會封鎖主執行緒。

使用協同程式,確保不影響主執行緒

Kotlin 協同程式使用「調度器」,來判定哪些執行緒會用於執行協同程式。如要在主執行緒外執行程式碼,您可以指示 Kotlin 協同程式在「預設」或「IO」調度器上執行作業。在 Kotlin 中,所有協同程式都必須在調度器內執行,即使在主執行緒上執行也是如此。協同程式可以自行暫停,而調度器負責繼續執行協同程式。

為指定協同程式的執行位置,Kotlin 提供了三個調度器,供您使用:

  • Dispatchers.Main - 使用這個調度器在 Android 主執行緒上執行協同程式。這個調度器應只用於與 UI 互動及執行快速作業。例如,呼叫 suspend 函式、執行 Android UI 架構作業,以及更新 LiveData 物件。
  • Dispatchers.IO - 這個調度器已完成最佳化調整,以便在主執行緒外執行磁碟或網路 I/O。例如,使用 Room 元件、讀取或寫入檔案,以及執行任何網路作業。
  • Dispatchers.Default - 這個調度器已完成最佳化調整,以便在主執行緒外執行大量使用 CPU 的工作。用途包括為清單排序和剖析 JSON。

延續上一個範例,您可以使用調度器重新定義 get 函式。在 get 的主體中,呼叫 withContext(Dispatchers.IO) 來建立在 IO 執行緒集區上執行的區塊。您放入該區塊的所有程式碼一律會透過 IO 調度器執行。由於 withContext 本身是暫停函式,所以 get 函式也是暫停函式。

suspend fun fetchDocs() { // Dispatchers.Main
    val result = get("developer.android.com") // Dispatchers.Main
    show(result) // Dispatchers.Main
}

suspend fun get(url: String) = // Dispatchers.Main
    withContext(Dispatchers.IO) { // Dispatchers.IO (main-safety block)
        /* perform network IO here */ // Dispatchers.IO (main-safety block)
    } // Dispatchers.Main

透過協同程式,您可以使用精細的控制項來調派執行緒。withContext() 可讓您控管任何程式碼行的執行緒集區,而不會導入回呼,因此您可以將此程式碼套用到極小的函式,例如從資料庫讀取資料,或執行網路要求。建議您使用 withContext(),確認每個函式都「不影響主執行緒」,也就是可以從主執行緒呼叫函式。如此一來,呼叫端就不必考慮使用哪個執行緒來執行函式。

在上一個範例中,fetchDocs() 會在主執行緒上執行;但它可以安全呼叫 get,後者會在背景中執行網路要求。由於協同程式支援 suspend 和 resume,所以在執行 withContext 區塊後,主執行緒上的協同程式會立即繼續執行 get 結果。

withContext() 的效能

相較於同等的回呼型實作,withContext() 不會產生額外的負荷。而且在某些情況下,還能將 withContext() 呼叫最佳化,使其效能超越同等的回呼型實作。例如,如果函式呼叫網路十次,您可以使用外部 withContext(),指示 Kotlin 只切換一次執行緒。這樣的話,即使網路程式庫多次使用 withContext(),程式庫仍會留在同一個調度器上,不會切換執行緒。此外,Kotlin 會對 Dispatchers.Default 和 Dispatchers.IO 之間的切換作業進行最佳化調整,盡量避免切換執行緒。

啟動協同程式

您可以透過下列任一方式啟動協同程式:

  • launch 會啟動新的協同程式,但不會將結果傳回呼叫端。任何視為「射後不理」的工作都可以使用 launch 啟動。
  • async 會啟動新的協同程式,並透過 await 暫停函式傳回結果。

一般函式無法呼叫 await,因此您通常從一般函式對新協同程式執行 launch 作業。只有在其他協同程式內時,或在暫停函式內,並執行平行分解時,才使用 async。

平行分解

在 suspend 函式內啟動的所有協同程式,都必須在函式傳回時停止,因此您可能需要確保,這些協同程式要在傳回值前執行完畢。使用 Kotlin 中的「結構化並行」,您就可以定義 coroutineScope 來啟動一個或多個協同程式。然後,使用 await() (針對一個協同程式) 或 awaitAll() (針對多個協同程式),就可確保這些協同程式在從函式傳回值前執行完畢。

例如,我們來定義 coroutineScope,用於以非同步方式擷取兩份文件。在每個延遲的參照上呼叫 await(),就能確保,兩項 async 作業都會在傳回值之前執行完畢:

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

您也可以在集合上使用 awaitAll(),如以下範例所示:

suspend fun fetchTwoDocs() = // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf( // fetch two docs at the same time
            async { fetchDoc(1) }, // async returns a result for the first doc
            async { fetchDoc(2) } // async returns a result for the second doc
        )
        deferreds.awaitAll() // use awaitAll to wait for both network requests
    }

即使 fetchTwoDocs() 使用 async 啟動新的協同程式,函式還是會使用 awaitAll() 來等待已啟動的協同程式執行完畢,然後才會傳回值。不過請注意,即使未呼叫 awaitAll(),coroutineScope 建構工具也不會繼續執行呼叫了 fetchTwoDocs 的協同程式,直到所有新協同程式執行完畢為止。

此外,coroutineScope 會擷取協同程式擲回的例外狀況,並轉送至呼叫端。

如要進一步瞭解平行分解,請參閱「撰寫暫停函式」。

協同程式概念

CoroutineScope

CoroutineScope 會使用 launch 或 async,來追蹤其建立的任何協同程式。您可以隨時呼叫 scope.cancel(),來取消進行中的工作 (即執行中的協同程式)。在 Android 中,部分 KTX 程式庫會為特定的生命週期類別提供專屬的 CoroutineScope。例如,ViewModel 有 viewModelScope,Lifecycle 則有 lifecycleScope。但與調度器不同,CoroutineScope 不執行協同程式。

viewModelScope 也用於使用協同程式的 Android 背景執行緒中列出的範例。不過,如果您需要建立自己的 CoroutineScope,來控管應用程式特定層中協同程式的生命週期,您可以按照以下方式來建立:

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

已取消的範圍無法建立更多協同程式。因此,只有在刪除控管生命週期的類別時,您才要呼叫 scope.cancel()。使用 viewModelScope 時,ViewModel 類別會在 ViewModel 的 onCleared() 方法中自動取消範圍。

工作

Job 是協同程式的控制代碼。使用 launch 或 async 建立的每個協同程式都會傳回 Job 執行個體,用來以唯一的方式識別協同程式,並管理生命週期。您也可以將 Job 傳遞至 CoroutineScope,來進一步管理生命週期,如以下範例所示:

class ExampleClass {
    // ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (condition) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

CoroutineContext 使用以下元素定義協同程式的行為:

如果是在範圍內建立的新協同程式,系統會將新的 Job 執行個體指派給新的協同程式,並從涵蓋的範圍沿用其他 CoroutineContext 元素。您可以將新的 CoroutineContext 傳遞至 launch 或 async 函式,來覆寫沿用的元素。請注意,將 Job 傳遞至 launch 或 async 不會有任何作用,因為 Job 的新執行個體始終會被指派給新的協同程式。

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

其他協同程式資源

如需取得更多協同程式資源,請參閱下列連結: