前言
翻譯好的文章也是一種學習方式
原文標題:Coroutines in Kotlin 1.3 explained: Suspending functions, contexts, builders and scopes
原文作者: Antonio Leiva
協(xié)程簡介
協(xié)程是
Kotlin
的一大特色兔综。使用協(xié)程,可以簡化異步編程如孝,使代碼可讀性更好岁诉、更容易理解。使用協(xié)程,不同于傳統(tǒng)的回調(diào)方式谓苟,可以使用同步的方式編寫異步代碼官脓。同步方法返回的結果就是異步請求的結果。
協(xié)程到底有什么魔法涝焙?馬上為您揭曉卑笨。在這之前,我們需要知道為什么協(xié)程這么重要仑撞。
自
Kotlin 1.1
中 協(xié)程作為實驗特性湾趾,到現(xiàn)在Kotlin 1.3
發(fā)布了最終的 API,協(xié)程已經(jīng)可以用于生產(chǎn)環(huán)境中派草。
協(xié)程的目標:先看一下現(xiàn)存的一些問題
獲取文中的完整示例點擊 這里
假設要做一個登陸界面如下圖:
用戶輸入用戶名和密碼搀缠,然后點擊登陸。
假設是這樣的流程: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
}
}
}
到這里,代碼開始變得復雜了本谜,出現(xiàn)了可怕的回調(diào)地獄:后一個請求總是嵌套在前一個請求的結果回調(diào)里面初家,縮進變得越來越多偎窘。
由于使用的是 Kotlin
的 lambdas
乌助,可能看起來并沒有那么糟糕溜在。但是隨著請求的增多,代碼變得越來越難以管理他托。
別忘了掖肋,我們使用的還是一種相對簡單但并不高效的一種方式。
什么是協(xié)程(Coroutine
)
簡單來說赏参,協(xié)程像是輕量級的線程志笼,但并不完全是線程。
首先把篓,協(xié)程可以讓你順序地寫異步代碼纫溃,極大地降低了異步編程帶來的負擔;
其次韧掩,協(xié)程更加高效紊浩。多個協(xié)程可以共用一個線程。一個 App 可以運行的線程數(shù)是有限的疗锐,但是可以運行的協(xié)程數(shù)量幾乎是無限的坊谁;
協(xié)程實現(xiàn)的基礎是可中斷的方法(suspending functions
)』可中斷的方法可以在任意的地方中斷協(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é)程的運行方式。也可以理解為澈段,它包含了一系列的鍵值對悠菜。
現(xiàn)在,你只需要知道 dispatcher
是其中的一個配置败富,它可以指定協(xié)程運行在哪個線程悔醋。
dispatcher
有兩種方式可以配置:
- 明確指定需要使用的
dispatcher
; - 由協(xié)程作用域(
coroutine scope
)決定。這里先不展開說兽叮,后面會詳細說明芬骄;
具體來說,協(xié)程構造器(coroutine builder
)接收一個協(xié)程上下文(coroutine context
)作為第一個參數(shù)鹦聪,我們可以傳入要使用的 dispatcher
账阻。因為 dispatcher
實現(xiàn)了協(xié)程上下文,所以可以作為參數(shù)傳入:
coroutine(Dispatchers.Main) {
progress.visibility = View.VISIBLE
...
}
現(xiàn)在泽本,改變進度條可見性的代碼就運行在了 UI 線程淘太。不僅如此,協(xié)程內(nèi)的所有代碼都運行在 UI 線程规丽。那么問題來了蒲牧,可中斷的方法會怎么運行?
coroutine {
...
val user = suspended { userService.doLogin(username, password) }
val currentFriends = suspended { userService.requestCurrentFriends(user) }
...
}
這些請求服務的代碼也是運行在主線程嗎赌莺?如果真是這樣的話冰抢,它們會阻塞主線程。到底是不是呢艘狭,還是那句話挎扰,這取決于你的使用方式翠订。
可中斷的方法有多種辦法配置要使用的 dispatcher
营密,其中最常用的方法是 withContext
缓醋。
withContext
在協(xié)程內(nèi)部,這個方法可以輕易地改變代碼運行時所在的上下文贸营。它是一個可中斷的方法骇吭,所以調(diào)用它會中斷協(xié)程的執(zhí)行橙弱,直到該方法執(zhí)行完成歧寺。
這樣以來燥狰,我們就可以讓示例中那些可中斷的方法運行在不同的線程中:
suspend fun suspendLogin(username: String, password: String) =
withContext(Dispatchers.Main) {
userService.doLogin(username, password)
}
上面這些代碼會運行在主線程,所以仍然會阻塞 UI 斜筐。但是龙致,現(xiàn)在我們可以輕易地指定使用不同的 dispatcher
:
suspend fun suspendLogin(username: String, password: String) =
withContext(Dispatchers.IO) {
userService.doLogin(username, password)
}
現(xiàn)在我們使用了 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 核數(shù)一樣多的線程残拐。正因為是密集型的任務途茫,同時運行多個線程并沒有意義,因為 CPU 將會很繁忙溪食。IO: 它用于輸入/輸出的場景囊卜。通常,涉及到會阻塞線程错沃,需要等待另一個系統(tǒng)響應的任務边败,比如:網(wǎng)絡請求、數(shù)據(jù)庫操作捎废、文件讀寫等笑窜,都可以使用它。因為它不使用 CPU 登疗,可以同一時間運行多個線程排截,默認是數(shù)量為 64 的線程池嫌蚤。Android App 中有很多網(wǎng)絡請求的操作,所以你可能會經(jīng)常用到它断傲。
UnConfined: 如果你不在乎啟動了多少個線程脱吱,那么你可以使用它。它使用的線程是不可控制的认罩,除非你特別清楚你在做什么箱蝠,否則不建議使用它。
Main: 這是 UI 相關的協(xié)程庫里面的一個
dispatcher
垦垂,在 Android 編程中宦搬,它使用的是 UI 線程。
現(xiàn)在劫拗,你應該可以很靈活地使用各種 dispatcher
了间校。
協(xié)程構造器(Coroutine Builders
)
現(xiàn)在,你可以輕松地切換線程了页慷。接下來憔足,我們學習一下如何啟動一個新的協(xié)程:當然要靠協(xié)程構造器了。
根據(jù)實際情況酒繁,我們可以選擇使用不同的協(xié)程構造器滓彰,當然我們也可以創(chuàng)建自定義的協(xié)程構造器。不過通常情況下州袒,協(xié)程庫提供的已經(jīng)滿足我們的使用了揭绑。具體如下:
runBlocking
這個協(xié)程構造器會阻塞當前線程,直到協(xié)程內(nèi)的所有任務執(zhí)行完畢稳析。這好像違背了我們使用協(xié)程的初衷洗做,所以什么場景下會用到它呢?
runBlocking
對于測試可中斷的方法非常有用彰居。在測試的時候诚纸,將可中斷的方法運行在 runBlocking
構建的協(xié)程內(nèi)部,這樣就可以保證陈惰,在這些可中斷的方法返回結果前當前測試線程不會結束畦徘,這樣,我們就可以校驗測試結果了抬闯。
fun testSuspendingFunction() = runBlocking {
val res = suspendingTask1()
assertEquals(0, res)
}
但是井辆,除了這個場景外,你也許不會用到 runBlocking
了溶握。
launch
這個協(xié)程構造器很重要杯缺,因為它可以很輕易地創(chuàng)建一個協(xié)程,你可能會經(jīng)常用到它睡榆。和 runBlocking
相反的是萍肆,它不會阻塞當前線程(前提是我們使用了合適的 dispatcher
)袍榆。
這個協(xié)程構造器通常需要一個作用域(scope
),關于作用域的概念后面會講到塘揣,我們暫時使用全局作用域(GlobalScope
):
GlobalScope.launch(Dispatchers.Main) {
...
}
launch
方法會返回一個 Job
包雀,Job
繼承了協(xié)程上下文(CoroutineContext
)。
Job
提供了很多有用的方法亲铡。需要明確的是:一個 Job
可以有一個父 Job
才写,父 Job
可以控制子 Job
。下面介紹一下 Job
的方法:
job.join
這個方法可以中斷與當前 Job
關聯(lián)的協(xié)程奖蔓,直到所有子 Job
執(zhí)行完成赞草。協(xié)程內(nèi)的所有可中斷的方法與當前 Job
相關聯(lián),直到子 Job
全部執(zhí)行完成锭硼,與當前 Job
關聯(lián)的協(xié)程才能繼續(xù)執(zhí)行房资。
val job = GlobalScope.launch(Dispatchers.Main) {
doCoroutineTask()
val res1 = suspendingTask1()
val res2 = suspendingTask2()
process(res1, res2)
}
job.join()
job.join()
是一個可中斷的方法蜕劝,所以它應該在協(xié)程內(nèi)部被調(diào)用檀头。
job.cancel
這個方法可以取消所有與其關聯(lián)的子 Job
,假如 suspendingTask1()
正在執(zhí)行的時候 Job
調(diào)用了 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é)程內(nèi)部。
async
這個協(xié)程構造器將會解決我們在剛開始演示示例的時候提到的一些難題唉俗。
async
允許并行地運行多個子線程任務嗤朴,它不是一個可中斷方法,所以當調(diào)用 async
啟動子協(xié)程的同時虫溜,后面的代碼也會立即執(zhí)行雹姊。async
通常需要運行在另外一個協(xié)程內(nèi)部,它會返回一個特殊的 Job
衡楞,叫作 Deferred
吱雏。
Deferred
有一個新的方法叫做 await()
,它是一個可中斷的方法瘾境,當我們需要獲取 async
的結果時歧杏,需要調(diào)用 await()
方法等待結果。調(diào)用 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
)
到目前為止,我們使用簡單的方式輕松地實現(xiàn)了復雜的操作行瑞。但是奸腺,仍有一個問題未解決。
假如我們要使用 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é)程占婉,而不用關心使用的上下文。因為我們已經(jīng)在自定義的作用域里面聲明了上下文甫恩,也就是包含了 main dispatcher
的那個上下文:
launch {
...
}
如果你的所有 activity
都需要使用協(xié)程逆济,將上述代碼提取到一個父類中是很有必要的。
附錄1 - 回調(diào)方式轉為協(xié)程
如果你已經(jīng)考慮將協(xié)程用于現(xiàn)有的項目磺箕,你可能會考慮怎么將現(xiàn)有的回調(diào)風格的代碼轉為協(xié)程:
suspend fun suspendAsyncLogin(username: String, password: String): User =
suspendCancellableCoroutine { continuation ->
userService.doLoginAsync(username, password) { user ->
continuation.resume(user)
}
}
suspendCancellableCoroutine()
這個方法返回一個 continuation
對象奖慌,continuation
可以用于返回回調(diào)的結果。只要調(diào)用 continuation.resume()
方法滞磺,這個回調(diào)結果就可以作為這個可中斷方法的結果返回給協(xié)程升薯。
附錄2 - 協(xié)程和 RxJava
每次提到協(xié)程都會有人問起莱褒,協(xié)程可以替代 RxJava
嗎击困?簡單地回答就是:不可以。
客觀地來說广凸,根據(jù)情況而定:
- 如果你使用
RxJava
只是用來從主線程切換到子線程阅茶。你也看到了,協(xié)程可以輕松地實現(xiàn)這一點谅海。這種情況下脸哀,完全可以替代RxJava
。 - 如果你使用
RxJava
用來流式編程扭吁,合并流撞蜂、轉換流等。RxJava
依然更有優(yōu)勢侥袜。協(xié)程中有一個Channels
的概念蝌诡,可以替代RxJava
實現(xiàn)一些簡單的場景,但是通常情況下枫吧,你可能更傾向于使用RxJava
的流式編程浦旱。
值得一提的是,這里有一個開源庫九杂,可以在協(xié)程中使用 RxJava
颁湖,你可能會感興趣宣蠕。
總結
協(xié)程為我們打開了一個充滿無限可能性、更簡單實現(xiàn)異步編程的世界甥捺。在此之前抢蚀,這是不可想象的。
強烈推薦把協(xié)程用于你現(xiàn)有的項目當中镰禾。如果你想查看完整的示例代碼思币,點擊這里。
趕快開啟你的協(xié)程之旅吧羡微!