原文連結 Kotlin 協程使用手冊。
最近抽出閒暇,把 kotlinx.coroutines 官方的三份入手指南翻譯了一下,掛在了 GitBook ,可以直接去這裡檢視。不過,文件的內容其實還是比較多的,為了釐清協程的特殊之處,下面我就總結一番。
協程是什麼
協程的定義其實不太好描述,那我乾脆由用途及定義,簡述一下協程。
輕量級的執行緒
標題的說法可能不太準確,但也能一窺其功用。協程是工作線上程之上的。我們知道執行緒是由系統(語言系統或者作業系統)進行排程的,切換時有著一定的開銷。而協程,它的切換由程式自己來控制,無論是 CPU 的消耗還是記憶體的消耗都大大降低。
從這一點出發,它的應用場景可能就在於提高硬體效能的瓶頸。譬如說,你啟動十萬個協程不會有什麼問題,但你啟動十萬個執行緒試試?
可暫停的程式
相較於第一點,這才是協程的本質;同時也是由這一點,協程發揮了很大的作用。在協程中,某段程式碼可以暫停,轉而去執行另外的協程程式碼;被暫停的程式碼也可以在你的控制下隨時恢復執行。
這在前端程式設計中有一個很大的用處——避免回撥地獄。就 Android 程式設計而言,在 Rx 之前,要獲取某個非同步操作的返回結果,標準做法就是定義介面,用回撥來接收結果。而 Rx 出現之後,以其巧妙的轉換,通過響應式的程式碼,以一層的回撥(輔以 lambda 表示式,看起來就像沒有回撥一樣)鏈解決了回撥地獄的問題。但在這裡,習慣以命令式寫法寫程式碼的同學就需要稍稍理解一些函式式的程式設計思維了。協程不一樣,它的程式碼是可以暫停的!也就是說,在我通過 getUser()
方法非同步獲取資料的時候,呼叫它的程式碼塊就可以選擇掛起,等到獲取到資料,再恢復執行。程式碼看起來就這樣:
val user = getUser() // 這兒的 getUser 就是 suspend function
複製程式碼
是不是和同步程式碼看起來一樣?
寫過 JS 的同學可能就覺著很眼熟了:
async function getUser() {
try {
const response = await fetchUser();
// ...
} catch (e) {
// error handle
}
}
複製程式碼
沒錯,通過協程,Kotlin 是可以寫出類似程式碼來的!
協程的使用
協程的骨架
首先,需要通過構造器來啟動協程。官方目前提供的基礎構造器有兩個:
launch
runBlocking
它們都會啟動一個協程,區別在於前者不會阻塞當前執行緒,並且會返回一個協程的引用,而後者會等待協程的程式碼執行結束,再執行剩下的程式碼。
其次,關於協程,Kotlin 新增了一個關鍵字:suspend
,被該關鍵字修飾的函式/方法/程式碼塊只能由協程程式碼(也就是上述構造器的程式碼塊引數內部)或者被 suspend
修飾的函式/方法/程式碼塊呼叫。說簡單一點,suspend fun
只能被 suspend fun
呼叫(協程構造器的最後一個引數的型別宣告就是 suspend CoroutineScope.() -> Unit
)。
知道了這兩點,就可以寫出最簡單的協程程式碼:
fun main(args: Array<String>) {
repeat(100_000) { // 啟動十萬個協程試試
launch { suspendPrint() }
}
Thread.sleep(1200) // 等待協程程式碼的結束
}
suspend fun suspendPrint() {
delay(1000)
println(".")
}
複製程式碼
其中的 delay
就是一個 suspend fun
。
除了以上兩點,另一個很重要的概念就是上下文(context
)。協程雖然是依賴於執行緒的,但一個協程並非就綁死在一個執行緒上。啟動協程的時候可以指定上下文,在協程內部也可以通過 withContext
切換上下文。而這個上下文,也就是一個 CoroutineDispatcher
類的物件,從名字可以看出,就是由它去進行協程排程。比如,如果你需要新建一個執行緒去跑協程的程式碼,可以這樣:
launch(context = newSingleThreadContext("new-thread")) { delay(1000) }
複製程式碼
以上三點是我個人認為重要的內容,當然還有協程的取消、協程的生命週期、協程與子協程的關係等等,這些要點可以去官方文件或者我的翻譯檢視,內容寫得很棒。
常規操作
async 與 await
就我個人所知,async
與 await
作為 JS 與 C# 的兩個關鍵字,精簡了非同步操作(當然,這兩門語言的細節並不一樣)。但是在 Kotlin 中,async 其實是一個普通的函式:
fun main(args: Array<String>) = runBlocking<Unit> {
val result: Deferred<String> = async { doSomethingTimeout() }
println("I will got the result ${result.await()}")
}
suspend fun doSomethingTimeout(): String {
delay(1000)
return "Result"
}
複製程式碼
在這裡, async
程式碼塊會新啟動一個協程後立即執行,並且返回一個 Deferred
型別的值,呼叫它的 await
方法後會暫停當前協程,直到獲取到 async
程式碼塊執行結果,當前協程才會繼續執行。
其實談到這個,就不得不提一下 Retrofit 了,作為 RESTful 架構的優秀解決方案,有人已經為其適配了協程版的 adapter 了。我知道的有兩個:
- Kotlin Coroutines for Retrofit 提供的功能豐富,有異常處理方案,文件也很完善;
- Kotlin Coroutine (Experimental) Adapter Jake Wharton 寫的。
其實前者並不是 Retrofit 的 Adapter,Andrey Mischenko 只是為 Call
類新增了擴充套件函式而已。但是它們都是使用 Deferred
物件來處理結果。
channel 相關
這兒有個 channel 的概念,顧名思義,它的作用就在於收發事件。呼叫它的 send
與 receive
方法,就是最簡單的使用了。不過要注意,這兩個方法會互相等待,所以它們肯定得執行在不同的協程。
fun main(args: Array<String>) = runBlocking<Unit> {
val channel = Channel()
launch {
for (x in 1..5) channel.send(x)
channel.close()
}
for (x in 1..5) println(channel.receive())
// or `for (x in channel) println(x)`
}
複製程式碼
如上所示,channel 其實本身就可以迭代,迭代的結束條件就是 channel.close()
。
一個自定義的 onClick
方法
官方文件提供了一個 channel 版的 onClick
方法的實現,我覺得比較好用:
fun View.onClick(action: suspend (View) -> Unit) {
val eventActor = actor<View>(UI) {
for (event in channel) action(event)
}
setOnClickListener {
eventActor.offer(it)
}
}
複製程式碼
這裡的 actor
內部有一個 channel 用於接收外部的資料,點選事件產生的時候,通過 actor
向其傳送資料,channel 迭代就會向前移動,呼叫傳入的 action
。這裡還可以通過引數處理背壓的問題。
從這個應用可以延展開來,凡是由事件觸發的操作,都可以用類似的思路來實現。當然,無論實現方式的好與壞。
自定義 Rx 操作符
截至目前來看,協程與 Rx 似乎不能共存,它們的功用大多重複,導致許多場景非此即彼。不過通過官方的第三份手冊,我才發現協程還專門為 Rx 寫了一個模組,讓我們能夠以協程的方式寫 Rx 程式碼。需要介紹的是 publish
函式,他就是兩者的橋樑:
fun range(context: CoroutineContext, start: Int, count: Int): Publisher<Int> =
publish<Int>(context) {
for (x in start until start + count) send(x)
}
複製程式碼
publish
內部可以用 channel 的方式去組織程式碼,通過 send
方法將資料流向下一級,它返回的 Publisher
就是 Rx 標準中的那個,可以通過擴充套件方法 consumeEach
來接收每一項資料。
range(CommonPool, 1, 5).consumeEach { println(it) }
複製程式碼
最後
前後幾天時間,翻譯了三篇指南,切身體會到看一遍與寫一遍的差距。這篇文章旨在羅列要點,許多細節並未說明,更詳盡的內容還是需要文件。當然,也可以加入 kotlinlang 的 coroutine channel 參與討論。