使用 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 主執行緒上執行協同程式。這個調派程式只該用於與使用者介面互動及執行快速作業。例如,呼叫 suspend 函式、執行 Android 使用者介面架構作業,以及更新 LiveData 物件。
  • Dispatchers.IO - 這個調派程式已完成最佳化調整,以便在主執行緒外執行磁碟或網路 I/O。例如,使用聊天室元件、讀取或寫入檔案,以及執行任何網路作業。
  • 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() 呼叫進行最佳化調整。例如,如果函式呼叫網路十次,您可以指示 Kotlin 使用外部 withContext() 來只切換一次執行緒。接著,即使網路程式庫多次使用 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 + "BackgroundCoroutine") {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

其他協同程式資源

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