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

協程是一種併發設計模式,你能夠在 Android 上使用它來簡化異步代碼。協程是在 Kotlin 1.3 時正式發佈的,它吸取了一些其餘語言已經成熟的經驗。html

在 Android 上,協程可用於幫助解決兩個主要問題: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 協程的架構組件

更多信息

有關協做程序的更多信息,請參見如下連接:

相關文章
相關標籤/搜索