[譯] 使用 Kotlin 協程改進應用效能

高傑發表於2019-05-19

協程是一種併發設計模式,你可以在 Android 上使用它來簡化非同步程式碼。協程是在 Kotlin 1.3 時正式釋出的,它吸收了一些其他語言已經成熟的經驗。

在 Android 上,協程可用於幫助解決兩個主要問題:

  • 管理耗時任務,防止它們阻塞主執行緒
  • 提供主執行緒安全,或從主執行緒安全地呼叫網路或磁碟操作

本主題描述如何使用 Kotlin 協程來解決這些問題,讓你能夠寫出更清晰、更簡潔的程式碼。

管理耗時任務

在 Android 上,每個應用都有一個主執行緒來處理使用者介面和管理使用者互動。如果你的應用給主執行緒分配了太多工作,應用可能會變得很卡。網路請求、JSON 解析、讀寫資料庫,甚至只是遍歷大型列表,都可能導致應用執行的足夠慢,從而導致可見的延遲或直接卡住。這些耗時任務都應該放在主執行緒之外執行。

下面的例子顯示了一個虛構的耗時任務的簡單協程實現:

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) { /* ... */ }
複製程式碼

協程通過在常規函式的基礎上,新增兩個操作符來處理長時間執行的任務。除了呼叫(invoke or call) 和 返回(return),協程還新增了掛起 (suspend) 和恢復 (resume):

  • suspend 掛起當前協程,儲存本地變數
  • resume 讓從一個掛起協程從掛起點恢復執行

你只能從另外一個掛起函式裡呼叫掛起函式,或者使用協程構建器例如 launch 來啟動一個新的協程。

在上面的例子中,get() 仍然在主執行緒執行,但是它會在啟動網路請求之前掛起協程。當網路請求完成時,get() 恢復掛起的協程,而不是使用回撥來通知主執行緒。

Kotlin 使用堆疊來管理哪個函式和哪個區域性變數一起執行。掛起協程時,將複製當前堆疊幀並儲存。當恢復時,堆疊幀將從儲存它的位置複製回來,函式將重新開始允許。即使程式碼看起來像順序執行的程式碼會阻塞請求,協程也能確保網路請求不在主執行緒上。

使用協程確保主執行緒安全

Kotlin 協程使用排程器來確定哪些執行緒用於協程執行。要在主執行緒之外執行程式碼,可以告訴 Kotlin 協程在 Default 排程器或 IO 排程器上執行工作。在 Kotlin 中,所有協程都必須在排程器中執行,即使它們在主執行緒上執行。協程可用掛起它們自己,而排程器負責恢復它們。

要指定協程應該執行在哪裡,Kotlin 提供了三個排程器給你使用:

  • Dispatchers.Main 使用這個排程器在 Android 主執行緒上執行一個協程。這應該只用於與 UI 互動和一些快速工作。示例包括呼叫掛起函式、執行 Android UI 框架操作和更新 LiveData 物件。
  • Dispatchers.IO 這個排程器被優化在主執行緒之外執行磁碟或網路 I/O。例如包括使用 Room 元件、讀寫檔案,以及任何網路操作。
  • Dispatchers.Default 這個排程器經過優化,可以在主執行緒之外執行 cpu 密集型的工作。例如對列表進行排序和解析 JSON。

繼續前面的示例,你可以使用排程器重新定義 get()函式。在get()的主體中,呼叫 withContext(Dispactchers.IO) 建立一個執行在 IO 執行緒池上的程式碼塊。在這個程式碼塊中的任何程式碼都將通過 I/O 排程器執行。因為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)
        /* 在這裡執行網路請求 */                  // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}
複製程式碼

使用協程,你可以更細化的來分派執行緒。因為withContext() 允許你控制任何一行程式碼的執行緒池,而不需要引入回撥,所以你可以將它應用於非常小的函式,比如從資料庫讀取資料或執行網路請求。一個好的實踐是使用withContext() 來確保每個函式的呼叫都是主執行緒安全的,這意味著可以從主執行緒安全呼叫該函式。這樣呼叫者就不需要考慮應該使用哪個執行緒來執行函式。

在前面的例子中,fetchDocs() 在主執行緒上執行;但是,它可以安全地呼叫get()get() 在後臺執行網路請求。因為協程支援掛起和恢復,所以一旦withContext()塊完成,主執行緒上的協程就會帶著 get()的返回值恢復。

重要提示:使用 suspend 不會告訴 Kotlin 在後臺執行緒上執行函式。掛起函式在主執行緒上操作是正常的。在主執行緒上啟動協程也是很常見的。當遇到需要保護主執行緒安全時,例如讀寫磁碟、執行網路操作或執行 cpu 密集型操作時,應該始終在掛起函式中使用 withContext()

withContext() 的效能

與等價的基於回撥的實現相比,withContext()不會增加額外的開銷。此外,在某些情況下,基於回撥的實現,witchContext 的呼叫還可以優化。例如,如果一個函式對一個網路進行了 10 次呼叫,你可以在外面通過使用 withContext() 告訴 Kotlin 只切換一次執行緒。然後,即使網路庫多次使用 withContext(),它仍然保持在同一個排程器上,並且避免切換執行緒。此外 Kotlin 還優化了排程器之間的切換。在 Defalut 和 I/O 排程器之間儘可能的避免執行緒切換。

重要提示:像執行緒池一樣使用 I/O 和 Default 排程器不會保證程式碼塊裡面從上到下的程式碼在同一執行緒上執行。在某些情況下,Kotlin 協程可能會在掛起並恢復之後將執行移動到另一個執行緒。這意味著在 withContext() 程式碼塊中,執行緒區域性變數可能不會總是相同。

指定作用域

在定義協程時,必須指定它的協程作用域。協程作用域管理一個或多個相關的協程。你還可以使用指定的協程作用域在它的作用域內啟動新的協程。但是,協程作用域和排程器不一樣,它不負責執行協程。

協程作用域的一個主要功能是當使用者離開應用中的內容區域時停止協程的執行。使用協程作用域,可以確保任何正在執行的操作都正確的停止。

Android 架構元件上配合協程作用域

在 Android 上,你可以將協程作用域與元件生命週期關聯。這使你可以避免記憶體洩露或為使用者不在相關的 Activity 或 Fragment 做額外的工作。在使用 Jetpack 元件時,它們和 ViewModel 很適合。因為 ViewModel 在配置更改(比如旋轉螢幕)期間不會被銷燬,所以你不必擔心協程被取消或重新啟動。

作用域會記住它們啟動的每個協程。這意味著你可以隨時取消作用域中啟動的所有東西。作用域還會自行傳遞,因此如果一個協程啟動另一個協程,兩個協程具有相同的作用域。這意味著即使其他庫從你的作用域啟動了一個協程,你也可以隨時取消它們。如果在 ViewModel 中執行協程,這一點尤其重要。如果 ViewModel 因為使用者離開介面而被銷燬,則必須停止它正在執行的所有非同步工作。否則,你將浪費系統資源並可能造成記憶體洩露。如果在銷燬 ViewModel 之後還有非同步工作需要繼續,那麼應該在你的應用架構底層完成。

警告:協程通過丟擲 CancellationException 來取消協程。異常捕獲會在協程取消時被觸發。

使用 Android 架構體系元件的 ktx 庫時,你還可以使用一個擴充套件屬性 viewModelScope 來建立協程,這些建立出的協程可以一直執行到 ViewModel 被銷燬時。

開啟一個協程

你可以通過以下兩種方式啟動協程:

  • launch 啟動一個新的協程,但不會將結果返回給呼叫者。任何被認為是"發射後不管(fire and forget)"的工作都可以使用 launch 啟動。
  • async 啟動一個新的協程,並允許你呼叫 await 返回掛起函式的結果。

通常,你在常規函式應該用 launch 啟動一個新的協程,因為常規函式不能呼叫 await 。僅當在另一個協程中或在掛起函式中執行「並行分解」時才使用 async 的方式。

基於前面的例子,這裡有一個帶有 viewModelScope 的 ktx 擴充套件屬性的協程,它使用 luanch 將常規函式切換到協程:

fun onDocsNeeded() {
    viewModelScope.launch {    // Dispatchers.Main
        fetchDocs()            // Dispatchers.Main (suspend function call)
    }
}
複製程式碼

警告:launchasync 處理異常的方式不同。由於 async 期望在 await 時被最終呼叫,所以它的異常會保留到 await 被呼叫的時候重新丟擲。這意味著,如果你使用 await 從常規函式啟動一個新的協程,你可能會悄悄的"丟擲”一個異常(這個“丟擲”的異常不會出現在你的異常監控裡,也不會在 logcat 中被發現)。

並行分解

由掛起函式啟動的所有協程,必須在該函式返回時已經停止,因此你可能需要確保這些協程在返回前已經做完工作。使用 Kotlin 中的結構化併發,你可以定義一個啟動一或多個協程的協程作用域。然後,使用 await() (針對單個協程)或 awaitAll() (針對多個協程),用來確保這些協程在函式返回之前完成。

例如,讓我們定義會非同步獲取兩個文件的協程作用域。通過在每個 deferred 引用上呼叫 await() ,我們保證非同步操作都在返回值返回之前完成。

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }
複製程式碼

你還可以對集合使用 awaitAll() ,如下面的示例所示:

suspend fun fetchTwoDocs() =        // 在任何排程器上呼叫(任何執行緒包括主執行緒)
    coroutineScope {
        val deferreds = listOf(     // 同時獲取兩個文件
            async { fetchDoc(1) },  // 非同步返回第一個文件
            async { fetchDoc(2) }   // 非同步返回第二個文件
        )
        deferreds.awaitAll()        // 使用 awaitAll 等待兩個網路請求返回
    }
複製程式碼

即使 fetchTwoDocs() 使用 async 啟動新的協程,這個函式仍然使用 awaitAll() 來等待哪些啟動的協程完成後返回。但是,請注意,即使我們沒有呼叫awaitAll(),協程作用域構建器也不會在所有協程都完成之前恢復呼叫 fetchTwoDocs 的協程。

此外,協程作用域捕獲的任何異常,會通過它們返回指定的呼叫者。

有關並行分解的更多資訊,請參見組合掛起函式.。

內建協程支援的架構元件

一些架構元件,包括 ViewModelLifeCycle ,包含了內建的協程作用域成員。

例如,ViewModel 包含了一個內建的 viewModelScope。這提供了在 ViewModel 範圍內啟動協程的標準方法,如下所示:

class MyViewModel : ViewModel() {

    fun launchDataLoad() {
        viewModelScope.launch {
            sortList()
            // 修改 UI
        }
    }

    /**
    * 不能在主執行緒執行的重量型操作
    */
    suspend fun sortList() = withContext(Dispatchers.Default) {
        // 大量操作
    }
}
複製程式碼

LiveData 同樣使用 liveData 塊來使用協程:

liveData {
    // 執行在自己的特定於 LiveData 的範圍內
}
複製程式碼

有關架構元件中內建的協程支援的更多資訊,請參見使用 Kotlin 協程的架構元件

更多資訊

有關協作程式的更多資訊,請參見以下連結:

相關文章