在 Android 上使用協程(一):Getting The Background

秉心說TM發表於2019-05-29

原文作者 :Sean McQuillan

原文地址: Coroutines on Android (part I): Getting the background

譯者 : 秉心說

這是關於在 Android 中使用協程的一系列文章。本篇讓我們先來看看協程是如何工作的以及它解決了什麼問題。

協程解決了什麼問題 ?

Kotlin 的 Coroutines (協程) 帶來了一種新的併發方式,在 Android 上,它可以用來簡化非同步程式碼。儘管 Kotlin 1.3 才帶來穩定版的協程,但是自程式語言誕生以來,協程的概念就已經出現了。第一個使用協程的語言是釋出於 1967 年的 Simula

在過去的幾年中,協程變得越來越流行。現在許多流行的程式語言都加入了協程,例如 Javascript , C# , Python , Ruby , Go 等等。Kotlin 協程基於以往構建大型應用中已建立的一些概念。

在安卓中,協程很好的解決了兩個問題:

  1. 耗時任務,執行時間過長阻塞主執行緒
  2. 主執行緒安全,允許你在主執行緒中呼叫任意 suspend(掛起) 函式

下面讓我們深入瞭解協程如何幫助我們構建更乾淨的程式碼!

耗時任務

獲取網頁,和 API 進行互動,都涉及到了網路請求。同樣的,從資料庫讀取資料,從硬碟中載入圖片,都涉及到了檔案讀取。這些就是我們所說的耗時任務,App 不可能特地暫停下來等待它們執行完成。

和網路請求相比,很難具體的想象現代智慧手機執行程式碼的速度有多快。Pixel 2 的一個 CPU 時鐘週期不超過 0.0000000004 秒,這是一個對人類來說很難理解的一個數字。但是如果你把一次網路請求的耗時想象成一次眨眼,大概 0.4 s,這就很好理解 CPU 執行的到底有多快了。在一次眨眼的時間內,或者一次較慢的網路請求,CPU 可以執行超過一百萬次時鐘週期。

在 Android 中,每個 app 都有一個主執行緒,負責處理 UI(例如 View 的繪製)和使用者互動。如果在主執行緒中處理過多工,應用將會變得卡頓,隨之帶來了不好的使用者體驗。任何耗時任務都不應該阻塞主執行緒,

為了避免在主執行緒中進行網路請求,一種通用的模式是使用 CallBack(回撥),它可以在將來的某一時間段回撥進入你的程式碼。使用回撥訪問 developer.android.com 如下所示:

class ViewModel: ViewModel() {
   fun fetchDocs() {
       get("developer.android.com") { result ->
           show(result)
       }
    }
}
複製程式碼

儘管 get() 方法是在主執行緒呼叫的,但它會在另一個執行緒中進行網路請求。一旦網路請求的結果可用了,回撥就會在主執行緒中被呼叫。這是處理耗時任務的一種好方式,像 Retrofit 就可以幫助你進行網路請求並且不阻塞主執行緒。

使用協程處理耗時任務

用協程來處理耗時任務可以簡化程式碼。以上面的 fetchDocs() 方法為例,我們使用協程來重寫之前的回撥邏輯。

// Dispatchers.Main
suspend fun fetchDocs() {
    // Dispatchers.IO
    val result = get("developer.android.com")
    // Dispatchers.Main
    show(result)
}
// look at this in the next section
suspend fun get(url: String) = withContext(Dispatchers.IO){/*...*/}
複製程式碼

上面的程式碼會阻塞主執行緒嗎?它是如何在不暫停等待網路請求或者阻塞主執行緒的情況下得到 get() 的返回值的?事實證明,Kotlin 協程提供了一種永遠不會阻塞主執行緒的程式碼執行方式。

協程新增了兩個操作來構建一些常規功能。除了 invoke(or call)return ,它額外新增了 suspend(掛起)resume(恢復)

  • suspend —— 掛起當前協程的執行,儲存所有區域性變數
  • resume —— 從被掛起協程掛起的地方繼續執行

在 Kotlin 中,通過給函式新增 suspend 關鍵字來實現此功能。你只能在掛起函式中呼叫掛起函式,或者通過協程構造器,例如 launch ,來開啟一個新的協程。

掛起和恢復共同工作來替代回撥。

在上面的例子中,get() 方法在進行網路請求之前會掛起協程,它也負責進行網路請求。然後,當網路請求結束時,它僅僅只需要恢復之前掛起的協程,而不是呼叫回撥函式來通知主執行緒。

在 Android 上使用協程(一):Getting The Background

看一下 fetchDocs 是如何執行的,你就會明白 suspend 是如何工作的了。無論一個協程何時被掛起,它的當前棧幀(用來追蹤正在執行的函式及其變數)將被複制並儲存。當進行 resume 時,棧幀將從之前被儲存的地方複製回來並重新執行。在上面動畫的中間部分,當主執行緒上的所有協程都被掛起,就有時間去更新 UI,處理使用者事件。總之,掛起和恢復替代了回撥,相當的整潔!

當主執行緒上的所有協程都被掛起,它就有時間做其他事情了。

即使我們直接順序書寫程式碼,看起來就像是會導致阻塞的網路請求一樣,但是協程會按我們所希望的那樣執行,不會阻塞主執行緒。

下面,讓我們看看協程是如何做到主執行緒安全的,並且探索一下 disaptchers(排程器)

協程的主執行緒安全

在 Kotlin 協程中,編寫良好的掛起函式在主執行緒中呼叫總是安全的。無論掛起函式做了什麼,總是應該允許任何執行緒呼叫它們。

但是,在 Android 應用中,我們如果把很多工作都放在主執行緒做會導致 APP 執行緩慢,例如網路請求,JSON 解析,讀寫資料庫,甚至是大集合的遍歷。它們中任何一個都會導致應用卡頓,降低使用者體驗。所以它們不應該執行在主執行緒。

使用 suspend 並不意味著告訴 Kotlin 一定要在後臺執行緒執行函式。值得一提的是,協程經常執行在主執行緒。事實上,當啟動一個用於響應使用者事件的協程時,使用 Dispatchers.Main.immediate 是一個好主意。

協程也會執行在主執行緒,suspend 並不一定意味著後臺執行。

為了讓一個函式不會使主執行緒變慢,我們可以告訴 Kotlin 協程使用 Default 或者 IO 排程器。在 Kotlin 中,所有的協程都需要使用排程器,即使它們執行在主執行緒。協程可以掛起自己,而排程器就是用來告訴它們如何恢復執行的。

為了指定協程在哪裡執行,Kotlin 提供了 Dispatchers 來處理執行緒排程。

+-----------------------------------+
|         Dispatchers.Main          |
+-----------------------------------+
| Main thread on Android, interact  |
| with the UI and perform light     |
| work                              |
+-----------------------------------+
| - Calling suspend functions       |
| - Call UI functions               |
| - Updating LiveData               |
+-----------------------------------+

+-----------------------------------+
|          Dispatchers.IO           |
+-----------------------------------+
| Optimized for disk and network IO |
| off the main thread               |
+-----------------------------------+
| - Database*                       |
| - Reading/writing files           |
| - Networking**                    |
+-----------------------------------+

+-----------------------------------+
|        Dispatchers.Default        |
+-----------------------------------+
| Optimized for CPU intensive work  |
| off the main thread               |
+-----------------------------------+
| - Sorting a list                  |
| - Parsing JSON                    |
| - DiffUtils                       |
+-----------------------------------+
複製程式碼
  • Room 在你使用 掛起函式RxJavaLiveData 時自動提供主執行緒安全。

  • RetrofitVolley 等網路框架一般自己管理執行緒排程,當你使用 Kotlin 協程的時候不需要再顯式保證主執行緒安全。

繼續上面的例子,讓我們使用排程器來定義 get 函式。在 get 函式的方法體內使用 withContext(Dispatchers.IO) 定義一段程式碼塊,這個程式碼塊將在排程器 Dispatchers.IO 中執行。方法塊中的任何程式碼總是會執行在 IO 排程器中。由於 withContext 本身就是一個掛起函式,所以它通過協程提供了主執行緒安全。

// Dispatchers.Main
suspend fun fetchDocs() {
    // Dispatchers.Main
    val result = get("developer.android.com")
    // Dispatchers.Main
    show(result)
}
// Dispatchers.Main
suspend fun get(url: String) =
    // Dispatchers.IO
    withContext(Dispatchers.IO) {
        // Dispatchers.IO
        /* perform blocking network IO here */
    }
    // Dispatchers.Main
複製程式碼

通過協程,你可以細粒度的控制執行緒排程,因為 withContext 讓你可以控制任意一行程式碼執行在什麼執行緒上,而不用引入回撥來獲取結果。你可將其應用在很小的函式中,例如資料庫操作和網路請求。所以,比較好的做法是,使用 withContext 確保每個函式在任意排程器上執行都是安全的,包括 Main,這樣呼叫者在呼叫函式時就不需要考慮應該執行在什麼執行緒上。

編寫良好的掛起函式被任意執行緒呼叫都應該是安全的。

保證每個掛起函式主執行緒安全無疑是個好主意,如果它設計到任何磁碟,網路,或者 CPU 密集型的任務,請使用 withContext 來確保主執行緒呼叫是安全的。這也是基於協程的庫所遵循的設計模式。如果你的整個程式碼庫都遵循這一原則,你的程式碼將會變得更加簡單,執行緒問題和程式邏輯也不會再混在一起。協程可以自由的從主執行緒啟動,資料庫和網路請求的程式碼會更簡單,且能保證使用者體驗。

withContext 的效能

對於提供主執行緒安全性,withContext 與回撥或 RxJava 一樣快。在某些情況下,甚至可以使用協程上下文 withContext 來優化回撥。如果一個函式將對資料庫進行10次呼叫,那麼您可以告訴 Kotlin 在外部的 withContext 中呼叫一次切換。儘管資料庫會重複呼叫 withContext ,但是他它將在同一個排程器下,尋找最快路徑。此外,Dispatchers.DefaultDispatchers.IO 之間的協程切換已經過優化,以儘可能避免執行緒切換。

What’s next

在這篇文章中我們探索了協程解決了什麼問題。協程是程式語言中一個非常古老的概念,由於它們能夠使與網路互動的程式碼更簡單,因此最近變得更加流行。

在安卓上,你可以使用協程解決兩個常見問題:

  • 簡化耗時任務的程式碼,例如網路請求,磁碟讀寫,甚至大量 JSON 的解析
  • 提供準確的主執行緒安全,在不會讓程式碼更加臃腫的情況下保證不阻塞主執行緒

在下一篇文章中,我們將探索它們是如何適應 Android 的,以便跟蹤您從螢幕開始的所有工作!(In the next post we’ll explore how they fit in on Android to keep track of all the work you started from a screen! )

下一篇:在 Android 上使用協程(二):Getting started

譯者說: 自我感覺翻譯的有點災難,不過災難也得翻譯下去,權當學習英語了!

文章首發微信公眾號: 秉心說 , 專注 Java 、 Android 原創知識分享,LeetCode 題解。

更多 JDK 原始碼解析,掃碼關注我吧!

在 Android 上使用協程(一):Getting The Background

相關文章