前言
翻譯好的文章也是一種學習方式
原文標題:Coroutines in Kotlin 1.3 explained: Suspending functions, contexts, builders and scopes
原文作者: Antonio Leiva
協(xié)程簡介
協(xié)程是
Kotlin的一大特色。使用協(xié)程,可以簡化異步編程,使代碼可讀性更好、更容易理解。使用協(xié)程,不同于傳統(tǒng)的回調方式,可以使用同步的方式編寫異步代碼。同步方法返回的結果就是異步請求的結果。
協(xié)程到底有什么魔法?馬上為您揭曉。在這之前,我們需要知道為什么協(xié)程這么重要。
自
Kotlin 1.1中 協(xié)程作為實驗特性,到現在Kotlin 1.3發(fā)布了最終的 API,協(xié)程已經可以用于生產環(huán)境中。
協(xié)程的目標:先看一下現存的一些問題
獲取文中的完整示例點擊 這里
假設要做一個登陸界面如下圖:

用戶輸入用戶名和密碼,然后點擊登陸。
假設是這樣的流程:App 首先請求服務器校驗用戶名和密碼,校驗成功后,然后請求該用戶的好友列表。
偽代碼如下:
progress.visibility = View.VISIBLE
userService.doLoginAsync(username, password) { user ->
userService.requestCurrentFriendsAsync(user) { friends ->
val finalUser = user.copy(friends = friends)
toast("User ${finalUser.name} has ${finalUser.friends.size} friends")
progress.visibility = View.GONE
}
}
步驟如下:
- 顯示一個進度條;
- 請求服務器校驗用戶名和密碼;
- 等待校驗成功后,請求服務器獲取好友列表;
- 最后,隱藏進度條;
情況還可以更復雜,想象一下,不僅要請求好友列表,還需要請求推薦好友列表,并把兩次結果合并進一個列表。
有兩種選擇:
- 最簡單的方式就是,在請求完好友列表之后,再請求推薦好友列表,但是這種方式不夠高效,因為后者并不依賴前者的請求結果;
- 這種方式相對復雜一些,同時請求好友列表和推薦好友列表,并同步兩次請求的結果;
通常情況下,想要偷懶的人可能會選擇第一種方式:
progress.visibility = View.VISIBLE
userService.doLoginAsync(username, password) { user ->
userService.requestCurrentFriendsAsync(user) { currentFriends ->
userService.requestSuggestedFriendsAsync(user) { suggestedFriends ->
val finalUser = user.copy(friends = currentFriends + suggestedFriends)
toast("User ${finalUser.name} has ${finalUser.friends.size} friends")
progress.visibility = View.GONE
}
}
}
到這里,代碼開始變得復雜了,出現了可怕的回調地獄:后一個請求總是嵌套在前一個請求的結果回調里面,縮進變得越來越多。
由于使用的是 Kotlin 的 lambdas,可能看起來并沒有那么糟糕。但是隨著請求的增多,代碼變得越來越難以管理。
別忘了,我們使用的還是一種相對簡單但并不高效的一種方式。
什么是協(xié)程(Coroutine)
簡單來說,協(xié)程像是輕量級的線程,但并不完全是線程。
首先,協(xié)程可以讓你順序地寫異步代碼,極大地降低了異步編程帶來的負擔;
其次,協(xié)程更加高效。多個協(xié)程可以共用一個線程。一個 App 可以運行的線程數是有限的,但是可以運行的協(xié)程數量幾乎是無限的;
協(xié)程實現的基礎是可中斷的方法(suspending functions)??芍袛嗟姆椒梢栽谌我獾牡胤街袛鄥f(xié)程的執(zhí)行,直到該可中斷的方法返回結果或者執(zhí)行完成。
運行在協(xié)程中的可中斷的方法(通常情況下)不會阻塞當前線程,之所以是通常情況下,因為這取決于我們的使用方式。具體下面會講到。
coroutine {
progress.visibility = View.VISIBLE
val user = suspended { userService.doLogin(username, password) }
val currentFriends = suspended { userService.requestCurrentFriends(user) }
val finalUser = user.copy(friends = currentFriends)
toast("User ${finalUser.name} has ${finalUser.friends.size} friends")
progress.visibility = View.GONE
}
上面的示例是協(xié)程的常用使用范式。首先,使用一個協(xié)程構造器(coroutine builder)創(chuàng)建一個協(xié)程,然后,一個或多個可中斷的方法運行在協(xié)程中,這些方法將會中斷協(xié)程的執(zhí)行,直到它們返回結果。
可中斷的方法返回結果后,我們在下一行代碼就可以使用這些結果,非常像順序編程。注意實際上 Kotlin 中并不存在 coroutine 和 suspended 這兩個關鍵字,上述示例只是為了便于演示協(xié)程的使用范式。
可中斷的方法(suspending functions)
可中斷的方法有能力中斷協(xié)程的執(zhí)行,當可中斷的方法執(zhí)行完畢后,接著就可以使用它們返回的結果。
val user = suspended { userService.doLogin(username, password) }
val currentFriends = suspended { userService.requestCurrentFriends(user) }
可中斷的方法可以運行在相同的或不同的線程,這取決于你的使用方式。可中斷的方法只能運行在協(xié)程中或其他可中斷的方法中。
聲明一個可中斷的方法,只需要使用 suspend 保留字:
suspend fun suspendingFunction() : Int {
// Long running task
return 0
}
回到最初的示例,你可能會問上述代碼運行在哪個線程,我們先看這一行代碼:
coroutine {
progress.visibility = View.VISIBLE
...
}
你認為這行代碼運行在哪個線程呢?你確定它是運行在 UI 線程嗎?如果不是,App 就會崩潰,所以弄明白運行在哪個線程很重要。
答案就是這取決于協(xié)程上下文(coroutine context)的設置。
協(xié)程上下文(Coroutine Context)
協(xié)程上下文是一系列規(guī)則和配置的集合,它決定了協(xié)程的運行方式。也可以理解為,它包含了一系列的鍵值對。
現在,你只需要知道 dispatcher 是其中的一個配置,它可以指定協(xié)程運行在哪個線程。
dispatcher 有兩種方式可以配置:
- 明確指定需要使用的
dispatcher; - 由協(xié)程作用域(
coroutine scope)決定。這里先不展開說,后面會詳細說明;
具體來說,協(xié)程構造器(coroutine builder)接收一個協(xié)程上下文(coroutine context)作為第一個參數,我們可以傳入要使用的 dispatcher。因為 dispatcher 實現了協(xié)程上下文,所以可以作為參數傳入:
coroutine(Dispatchers.Main) {
progress.visibility = View.VISIBLE
...
}
現在,改變進度條可見性的代碼就運行在了 UI 線程。不僅如此,協(xié)程內的所有代碼都運行在 UI 線程。那么問題來了,可中斷的方法會怎么運行?
coroutine {
...
val user = suspended { userService.doLogin(username, password) }
val currentFriends = suspended { userService.requestCurrentFriends(user) }
...
}
這些請求服務的代碼也是運行在主線程嗎?如果真是這樣的話,它們會阻塞主線程。到底是不是呢,還是那句話,這取決于你的使用方式。
可中斷的方法有多種辦法配置要使用的 dispatcher,其中最常用的方法是 withContext。
withContext
在協(xié)程內部,這個方法可以輕易地改變代碼運行時所在的上下文。它是一個可中斷的方法,所以調用它會中斷協(xié)程的執(zhí)行,直到該方法執(zhí)行完成。
這樣以來,我們就可以讓示例中那些可中斷的方法運行在不同的線程中:
suspend fun suspendLogin(username: String, password: String) =
withContext(Dispatchers.Main) {
userService.doLogin(username, password)
}
上面這些代碼會運行在主線程,所以仍然會阻塞 UI 。但是,現在我們可以輕易地指定使用不同的 dispatcher:
suspend fun suspendLogin(username: String, password: String) =
withContext(Dispatchers.IO) {
userService.doLogin(username, password)
}
現在我們使用了 IO dispatcher, 上述代碼會運行在子線程。另外,withContext 本身就是一個可中斷的方法,所以,我們沒必要讓它運行在另一個可中斷方法中。所以我們也可以這樣寫:
val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
val currentFriends = withContext(Dispatchers.IO) { userService.requestCurrentFriends(user) }
目前為止,我們認識了兩個 dispatcher,下面我們詳細介紹一下所有的 dispatcher 的使用場景。
Default: 當我們未指定
dispatcher的時候會默認使用,當然,我們也可以明確設置使用它。它一般用于 CPU 密集型的任務,特別是涉及到計算、算法的場景。它可以使用和 CPU 核數一樣多的線程。正因為是密集型的任務,同時運行多個線程并沒有意義,因為 CPU 將會很繁忙。IO: 它用于輸入/輸出的場景。通常,涉及到會阻塞線程,需要等待另一個系統(tǒng)響應的任務,比如:網絡請求、數據庫操作、文件讀寫等,都可以使用它。因為它不使用 CPU ,可以同一時間運行多個線程,默認是數量為 64 的線程池。Android App 中有很多網絡請求的操作,所以你可能會經常用到它。
UnConfined: 如果你不在乎啟動了多少個線程,那么你可以使用它。它使用的線程是不可控制的,除非你特別清楚你在做什么,否則不建議使用它。
Main: 這是 UI 相關的協(xié)程庫里面的一個
dispatcher,在 Android 編程中,它使用的是 UI 線程。
現在,你應該可以很靈活地使用各種 dispatcher 了。
協(xié)程構造器(Coroutine Builders)
現在,你可以輕松地切換線程了。接下來,我們學習一下如何啟動一個新的協(xié)程:當然要靠協(xié)程構造器了。
根據實際情況,我們可以選擇使用不同的協(xié)程構造器,當然我們也可以創(chuàng)建自定義的協(xié)程構造器。不過通常情況下,協(xié)程庫提供的已經滿足我們的使用了。具體如下:
runBlocking
這個協(xié)程構造器會阻塞當前線程,直到協(xié)程內的所有任務執(zhí)行完畢。這好像違背了我們使用協(xié)程的初衷,所以什么場景下會用到它呢?
runBlocking 對于測試可中斷的方法非常有用。在測試的時候,將可中斷的方法運行在 runBlocking 構建的協(xié)程內部,這樣就可以保證,在這些可中斷的方法返回結果前當前測試線程不會結束,這樣,我們就可以校驗測試結果了。
fun testSuspendingFunction() = runBlocking {
val res = suspendingTask1()
assertEquals(0, res)
}
但是,除了這個場景外,你也許不會用到 runBlocking 了。
launch
這個協(xié)程構造器很重要,因為它可以很輕易地創(chuàng)建一個協(xié)程,你可能會經常用到它。和 runBlocking 相反的是,它不會阻塞當前線程(前提是我們使用了合適的 dispatcher)。
這個協(xié)程構造器通常需要一個作用域(scope),關于作用域的概念后面會講到,我們暫時使用全局作用域(GlobalScope):
GlobalScope.launch(Dispatchers.Main) {
...
}
launch 方法會返回一個 Job,Job 繼承了協(xié)程上下文(CoroutineContext)。
Job 提供了很多有用的方法。需要明確的是:一個 Job 可以有一個父 Job,父 Job 可以控制子 Job。下面介紹一下 Job 的方法:
job.join
這個方法可以中斷與當前 Job 關聯的協(xié)程,直到所有子 Job 執(zhí)行完成。協(xié)程內的所有可中斷的方法與當前 Job 相關聯,直到子 Job 全部執(zhí)行完成,與當前 Job 關聯的協(xié)程才能繼續(xù)執(zhí)行。
val job = GlobalScope.launch(Dispatchers.Main) {
doCoroutineTask()
val res1 = suspendingTask1()
val res2 = suspendingTask2()
process(res1, res2)
}
job.join()
job.join() 是一個可中斷的方法,所以它應該在協(xié)程內部被調用。
job.cancel
這個方法可以取消所有與其關聯的子 Job,假如 suspendingTask1() 正在執(zhí)行的時候 Job 調用了 cancel() 方法,這時候,res1 不會再被返回,而且 suspendingTask2() 也不會再執(zhí)行。
val job = GlobalScope.launch(Dispatchers.Main) {
doCoroutineTask()
val res1 = suspendingTask1()
val res2 = suspendingTask2()
process(res1, res2)
}
job.cancel()
job.cancel() 是一個普通方法,所以它不必運行在協(xié)程內部。
async
這個協(xié)程構造器將會解決我們在剛開始演示示例的時候提到的一些難題。
async 允許并行地運行多個子線程任務,它不是一個可中斷方法,所以當調用 async 啟動子協(xié)程的同時,后面的代碼也會立即執(zhí)行。async 通常需要運行在另外一個協(xié)程內部,它會返回一個特殊的 Job,叫作 Deferred。
Deferred 有一個新的方法叫做 await(),它是一個可中斷的方法,當我們需要獲取 async 的結果時,需要調用 await() 方法等待結果。調用 await() 方法后,會中斷當前協(xié)程,直到其返回結果。
在下面的示例中,第二個和第三個請求需要依賴第一個請求的結果,請求好友列表和推薦好友列表本來可以并行請求的,如果都使用 withContext,顯然會浪費時間:
GlobalScope.launch(Dispatchers.Main) {
val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
val currentFriends = withContext(Dispatchers.IO) { userService.requestCurrentFriends(user) }
val suggestedFriends = withContext(Dispatchers.IO) { userService.requestSuggestedFriends(user) }
val finalUser = user.copy(friends = currentFriends + suggestedFriends)
}
假如每個請求耗時 2 秒,總共需要使用 6 秒。如果我們使用 async 替代呢:
GlobalScope.launch(Dispatchers.Main) {
val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
val currentFriends = async(Dispatchers.IO) { userService.requestCurrentFriends(user) }
val suggestedFriends = async(Dispatchers.IO) { userService.requestSuggestedFriends(user) }
val finalUser = user.copy(friends = currentFriends.await() + suggestedFriends.await())
}
這時,第二個和第三個請求會并行運行,所以總耗時將會減少到 4 秒。
作用域(Scope)
到目前為止,我們使用簡單的方式輕松地實現了復雜的操作。但是,仍有一個問題未解決。
假如我們要使用 RecyclerView 顯示朋友列表,當請求仍在進行的時候,客戶關閉了 activity,此時 activity 處于 isFinishing 的狀態(tài),任何更新 UI 的操作都會導致 App 崩潰。
我們怎么處理這種場景呢?當然是使用作用域(scope)了。先來看看都有哪些作用域:
Global scope
它是一個全局的作用域,如果協(xié)程的運行周期和 App 的生命周期一樣長的話,創(chuàng)建協(xié)程的時候可以使用它。所以它不應該和任何可以被銷毀的組件綁定使用。
它的使用方式是這樣的:
GlobalScope.launch(Dispatchers.Main) {
...
}
當你使用它的時候,要再三確定,要創(chuàng)建的協(xié)程是否需要伴隨 App 整個生命周期運行,并且這個協(xié)程沒有和界面、組件等綁定。
自定義協(xié)程作用域
任何類都可以繼承 CoroutineScope 作為一個作用域。你需要做的唯一一件事就是重寫 coroutineContext 這個屬性。
在此之前,你需要明確兩個重要的概念 dispatcher 和 Job。
不知道你是否還記得,一個上下文(context)可以是多個上下文的組合。組合的上下文需要是不同的類型。所以,你需要做兩件事情:
- 一個
dispatcher: 用于指定協(xié)程默認使用的dispatcher; - 一個
job: 用于在任何需要的時候取消協(xié)程;
class MainActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
private lateinit var job: Job
}
操作符號 + 用于組合上下文。如果兩種不同類型的上下文相組合,會生成一個組合的上下文(CombinedContext),這個新的上下文會同時擁有被組合上下文的特性。
如果兩個相同類型的上下文相組合,新的上下文等同于第二個上下文。即 Dispatchers.Main + Dispatchers.IO == Dispatchers.IO。
我們可以使用延遲初始化(lateinit)的方式創(chuàng)建一個 Job。這樣我們就可以在 onCreate() 方法中初始化它,在 onDestroy() 方法中取消它。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
job = Job()
...
}
override fun onDestroy() {
job.cancel()
super.onDestroy()
}
這樣以來,使用協(xié)程就方便多了。我們只管創(chuàng)建協(xié)程,而不用關心使用的上下文。因為我們已經在自定義的作用域里面聲明了上下文,也就是包含了 main dispatcher 的那個上下文:
launch {
...
}
如果你的所有 activity 都需要使用協(xié)程,將上述代碼提取到一個父類中是很有必要的。
附錄1 - 回調方式轉為協(xié)程
如果你已經考慮將協(xié)程用于現有的項目,你可能會考慮怎么將現有的回調風格的代碼轉為協(xié)程:
suspend fun suspendAsyncLogin(username: String, password: String): User =
suspendCancellableCoroutine { continuation ->
userService.doLoginAsync(username, password) { user ->
continuation.resume(user)
}
}
suspendCancellableCoroutine() 這個方法返回一個 continuation 對象,continuation 可以用于返回回調的結果。只要調用 continuation.resume() 方法,這個回調結果就可以作為這個可中斷方法的結果返回給協(xié)程。
附錄2 - 協(xié)程和 RxJava
每次提到協(xié)程都會有人問起,協(xié)程可以替代 RxJava 嗎?簡單地回答就是:不可以。
客觀地來說,根據情況而定:
- 如果你使用
RxJava只是用來從主線程切換到子線程。你也看到了,協(xié)程可以輕松地實現這一點。這種情況下,完全可以替代RxJava。 - 如果你使用
RxJava用來流式編程,合并流、轉換流等。RxJava依然更有優(yōu)勢。協(xié)程中有一個Channels的概念,可以替代RxJava實現一些簡單的場景,但是通常情況下,你可能更傾向于使用RxJava的流式編程。
值得一提的是,這里有一個開源庫,可以在協(xié)程中使用 RxJava,你可能會感興趣。
總結
協(xié)程為我們打開了一個充滿無限可能性、更簡單實現異步編程的世界。在此之前,這是不可想象的。
強烈推薦把協(xié)程用于你現有的項目當中。如果你想查看完整的示例代碼,點擊這里。
趕快開啟你的協(xié)程之旅吧!