原文作者 :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 協程基於以往構建大型應用中已建立的一些概念。
在安卓中,協程很好的解決了兩個問題:
- 耗時任務,執行時間過長阻塞主執行緒
- 主執行緒安全,允許你在主執行緒中呼叫任意 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()
方法在進行網路請求之前會掛起協程,它也負責進行網路請求。然後,當網路請求結束時,它僅僅只需要恢復之前掛起的協程,而不是呼叫回撥函式來通知主執行緒。
看一下 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 |
+-----------------------------------+
複製程式碼
繼續上面的例子,讓我們使用排程器來定義 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.Default
和 Dispatchers.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 原始碼解析,掃碼關注我吧!