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

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

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

管理長時間執行的工作

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

  • 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,後者會在背景中執行網路要求。由於協同程式支援 suspendresume,所以在執行 withContext 區塊後,主執行緒上的協同程式會立即繼續執行 get 結果。

withContext() 的效能

相較於同等的回呼型實作,withContext() 不會產生額外的負荷。而且在某些情況下,還能將 withContext() 呼叫最佳化,使其效能超越同等的回呼型實作。例如,如果函式呼叫網路十次,您可以使用外部 withContext(),指示 Kotlin 只切換一次執行緒。這樣的話,即使網路程式庫多次使用 withContext(),程式庫仍會留在同一個調度器上,不會切換執行緒。此外,Kotlin 會對 Dispatchers.DefaultDispatchers.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 會使用 launchasync,來追蹤其建立的任何協同程式。您可以隨時呼叫 scope.cancel(),來取消進行中的工作 (即執行中的協同程式)。在 Android 中,部分 KTX 程式庫會為特定的生命週期類別提供專屬的 CoroutineScope。例如,ViewModelviewModelScopeLifecycle 則有 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 是協同程式的控制代碼。使用 launchasync 建立的每個協同程式都會傳回 Job 執行個體,用來以唯一的方式識別協同程式,並管理生命週期。您也可以將 Job 傳遞至 CoroutineScope,來進一步管理生命週期,如以下範例所示:

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

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

CoroutineContext

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

如果是在範圍內建立的新協同程式,系統會將新的 Job 執行個體指派給新的協同程式,並從涵蓋的範圍沿用其他 CoroutineContext 元素。您可以將新的 CoroutineContext 傳遞至 launchasync 函式,來覆寫沿用的元素。請注意,將 Job 傳遞至 launchasync 不會有任何作用,因為 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)
        }
    }
}

其他協同程式資源

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