關(guān)鍵詞:Kotlin 協(xié)程 入門
假定你對(duì)協(xié)程(Coroutine)一點(diǎn)兒都不了解狞甚,通過閱讀本文看看是否能讓你明白協(xié)程是怎么一回事秸苗。
1. 引子
我之前寫過一些協(xié)程的文章捞镰,很久以前了础钠。那會(huì)兒還是很痛苦的怖竭,畢竟 kotlinx.coroutines 這樣強(qiáng)大的框架還在襁褓當(dāng)中锥债,于是乎我寫的幾篇協(xié)程的文章幾乎就是在告訴大家如何寫這樣一個(gè)框架——那種感覺簡(jiǎn)直糟糕透了,因?yàn)闆]有幾個(gè)人會(huì)有這樣的需求痊臭。
這次準(zhǔn)備從協(xié)程用戶(也就是程序員你我他啦)的角度來寫一下哮肚,希望對(duì)大家能有幫助。
2. 需求確認(rèn)
在開始講解協(xié)程之前广匙,我們需要先確認(rèn)幾件事兒:
- 你用過線程對(duì)吧允趟?
- 你寫過回調(diào)對(duì)吧?
- 你用過 RxJava 類似的框架嗎鸦致?
看下你的答案:
- 如果上面的問題的回答都是 “Yes”潮剪,那么太好了涣楷,這篇文章非常適合你,因?yàn)槟阋呀?jīng)意識(shí)到回調(diào)有多么可怕抗碰,并且找到了解決方案狮斗;
- 如果前兩個(gè)是 “Yes”,沒問題弧蝇,至少你已經(jīng)開始用回調(diào)了碳褒,你是協(xié)程潛在的用戶;
- 如果只有第一個(gè)是 “Yes”看疗,那么沙峻,可能你剛剛開始學(xué)習(xí)線程,那你還是先打好基礎(chǔ)再來吧~
3. 一個(gè)常規(guī)例子
我們通過 Retrofit 發(fā)送一個(gè)網(wǎng)絡(luò)請(qǐng)求鹃觉,其中接口如下:
interface GitHubServiceApi {
@GET("users/{login}")
fun getUser(@Path("login") login: String): Call<User>
}
data class User(val id: String, val name: String, val url: String)
Retrofit 初始化如下:
val gitHubServiceApi by lazy {
val retrofit = retrofit2.Retrofit.Builder()
.baseUrl("https://api.github.com")
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofit.create(GitHubServiceApi::class.java)
}
那么我們請(qǐng)求網(wǎng)絡(luò)時(shí):
gitHubServiceApi.getUser("bennyhuo").enqueue(object : Callback<User> {
override fun onFailure(call: Call<User>, t: Throwable) {
handler.post { showError(t) }
}
override fun onResponse(call: Call<User>, response: Response<User>) {
handler.post { response.body()?.let(::showUser) ?: showError(NullPointerException()) }
}
})
請(qǐng)求結(jié)果回來之后专酗,我們切換線程到 UI 線程來展示結(jié)果。這類代碼大量存在于我們的邏輯當(dāng)中盗扇,它有什么問題呢?
- 通過 Lambda 表達(dá)式沉填,我們讓線程切換變得不是那么明顯疗隶,但它仍然存在,一旦開發(fā)者出現(xiàn)遺漏翼闹,這里就會(huì)出現(xiàn)問題
- 回調(diào)嵌套了兩層斑鼻,看上去倒也沒什么,但真實(shí)的開發(fā)環(huán)境中邏輯一定比這個(gè)復(fù)雜的多猎荠,例如登錄失敗的重試
- 重復(fù)或者分散的異常處理邏輯坚弱,在請(qǐng)求失敗時(shí)我們調(diào)用了一次
showError
,在數(shù)據(jù)讀取失敗時(shí)我們又調(diào)用了一次关摇,真實(shí)的開發(fā)環(huán)境中可能會(huì)有更多的重復(fù)
Kotlin 本身的語法已經(jīng)讓這段代碼看上去好很多了荒叶,如果用 Java 寫的話,你的直覺都會(huì)告訴你:你在寫 Bug输虱。
如果你不是 Android 開發(fā)者些楣,那么你可能不知道 handler 是什么東西,沒關(guān)系宪睹,你可以替換為
SwingUtilities.invokeLater{ ... }
(Java Swing)愁茁,或者setTimeout({ ... }, 0)
(Js) 等等。
4. 改造成協(xié)程
你當(dāng)然可以改造成 RxJava 的風(fēng)格亭病,但 RxJava 比協(xié)程抽象多了鹅很,因?yàn)槌悄闶炀毷褂媚切?operator,不然你根本不知道它在干嘛(試想一下 retryWhen
)罪帖。協(xié)程就不一樣了促煮,畢竟編譯器加持邮屁,它可以很簡(jiǎn)潔的表達(dá)出代碼的邏輯,不要想它背后的實(shí)現(xiàn)邏輯污茵,它的運(yùn)行結(jié)果就是你直覺告訴你的那樣樱报。
對(duì)于 Retrofit,改造成協(xié)程的寫法泞当,有兩種迹蛤,分別是通過 CallAdapter 和 suspend 函數(shù)。
4.1 CallAdapter 的方式
我們先來看看 CallAdapter 的方式襟士,這個(gè)方式的本質(zhì)是讓接口的方法返回一個(gè)協(xié)程的 Job:
interface GitHubServiceApi {
@GET("users/{login}")
fun getUser(@Path("login") login: String): Deferred<User>
}
注意 Deferred 是 Job 的子接口盗飒。
那么我們需要為 Retrofit 添加對(duì) Deferred
的支持,這需要用到開源庫:
implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'
構(gòu)造 Retrofit 實(shí)例時(shí)添加:
val gitHubServiceApi by lazy {
val retrofit = retrofit2.Retrofit.Builder()
.baseUrl("https://api.github.com")
.addConverterFactory(GsonConverterFactory.create())
//添加對(duì) Deferred 的支持
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.build()
retrofit.create(GitHubServiceApi::class.java)
}
那么這時(shí)候我們發(fā)起請(qǐng)求就可以這么寫了:
GlobalScope.launch(Dispatchers.Main) {
try {
showUser(gitHubServiceApi.getUser("bennyhuo").await())
} catch (e: Exception) {
showError(e)
}
}
說明:
Dispatchers.Main
在不同的平臺(tái)上的實(shí)現(xiàn)不同陋桂,如果在 Android 上為HandlerDispatcher
逆趣,在 Java Swing 上為SwingDispatcher
等等。
首先我們通過 launch
啟動(dòng)了一個(gè)協(xié)程嗜历,這類似于我們啟動(dòng)一個(gè)線程宣渗,launch
的參數(shù)有三個(gè),依次為協(xié)程上下文梨州、協(xié)程啟動(dòng)模式痕囱、協(xié)程體:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext, // 上下文
start: CoroutineStart = CoroutineStart.DEFAULT, // 啟動(dòng)模式
block: suspend CoroutineScope.() -> Unit // 協(xié)程體
): Job
啟動(dòng)模式不是一個(gè)很復(fù)雜的概念,不過我們暫且不管暴匠,默認(rèn)直接允許調(diào)度執(zhí)行鞍恢。
上下文可以有很多作用,包括攜帶參數(shù)每窖,攔截協(xié)程執(zhí)行等等帮掉,多數(shù)情況下我們不需要自己去實(shí)現(xiàn)上下文,只需要使用現(xiàn)成的就好窒典。上下文有一個(gè)重要的作用就是線程切換蟆炊,Dispatchers.Main
就是一個(gè)官方提供的上下文,它可以確保 launch
啟動(dòng)的協(xié)程體運(yùn)行在 UI 線程當(dāng)中(除非你自己在 launch
的協(xié)程體內(nèi)部進(jìn)行線程切換崇败、或者啟動(dòng)運(yùn)行在其他有線程切換能力的上下文的協(xié)程)盅称。
換句話說,在例子當(dāng)中整個(gè) launch
內(nèi)部你看到的代碼都是運(yùn)行在 UI 線程的后室,盡管 getUser
在執(zhí)行的時(shí)候確實(shí)切換了線程缩膝,但返回結(jié)果的時(shí)候會(huì)再次切回來。這看上去有些費(fèi)解岸霹,因?yàn)橹庇X告訴我們疾层,getUser
返回了一個(gè) Deferred
類型,它的 await
方法會(huì)返回一個(gè) User
對(duì)象贡避,意味著 await
需要等待請(qǐng)求結(jié)果返回才可以繼續(xù)執(zhí)行痛黎,那么 await
不會(huì)阻塞 UI 線程嗎予弧?
答案是:不會(huì)。當(dāng)然不會(huì)湖饱,不然那 Deferred
與 Future
又有什么區(qū)別呢掖蛤?這里 await
就很可疑了,因?yàn)樗鼘?shí)際上是一個(gè) suspend 函數(shù)井厌,這個(gè)函數(shù)只能在協(xié)程體或者其他 suspend 函數(shù)內(nèi)部被調(diào)用蚓庭,它就像是回調(diào)的語法糖一樣,它通過一個(gè)叫 Continuation
的接口的實(shí)例來返回結(jié)果:
@SinceKotlin("1.3")
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
1.3 的源碼其實(shí)并不是很直接仅仆,盡管我們可以再看下 Result
的源碼器赞,但我不想這么做刨仑。更容易理解的是之前版本的源碼:
@SinceKotlin("1.1")
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resume(value: T)
public fun resumeWithException(exception: Throwable)
}
相信大家一下就能明白匿沛,這其實(shí)就是個(gè)回調(diào)嘛。如果還不明白胳赌,那就對(duì)比下 Retrofit 的 Callback
:
public interface Callback<T> {
void onResponse(Call<T> call, Response<T> response);
void onFailure(Call<T> call, Throwable t);
}
有結(jié)果正常返回的時(shí)候咳榜,Continuation
調(diào)用 resume
返回結(jié)果夏醉,否則調(diào)用 resumeWithException
來拋出異常,簡(jiǎn)直與 Callback
一模一樣涌韩。
所以這時(shí)候你應(yīng)該明白授舟,這段代碼的執(zhí)行流程本質(zhì)上是一個(gè)異步回調(diào):
GlobalScope.launch(Dispatchers.Main) {
try {
//showUser 在 await 的 Continuation 的回調(diào)函數(shù)調(diào)用后執(zhí)行
showUser(gitHubServiceApi.getUser("bennyhuo").await())
} catch (e: Exception) {
showError(e)
}
}
而代碼之所以可以看起來是同步的,那就是編譯器的黑魔法了贸辈,你當(dāng)然也可以叫它“語法糖”。
這時(shí)候也許大家還是有問題:我并沒有看到 Continuation
啊肠槽,沒錯(cuò)擎淤,這正是我們前面說的編譯器黑魔法了,在 Java 虛擬機(jī)上秸仙,await
這個(gè)方法的簽名其實(shí)并不像我們看到的那樣:
public suspend fun await(): T
它真實(shí)的簽名其實(shí)是:
kotlinx/coroutines/Deferred.await (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
即接收一個(gè) Continuation
實(shí)例嘴拢,返回 Object
的這么個(gè)函數(shù),所以前面的代碼我們可以大致理解為:
//注意以下不是正確的代碼寂纪,僅供大家理解協(xié)程使用
GlobalScope.launch(Dispatchers.Main) {
gitHubServiceApi.getUser("bennyhuo").await(object: Continuation<User>{
override fun resume(value: User) {
showUser(value)
}
override fun resumeWithException(exception: Throwable){
showError(exception)
}
})
}
而在 await
當(dāng)中席吴,大致就是:
//注意以下并不是真實(shí)的實(shí)現(xiàn),僅供大家理解協(xié)程使用
fun await(continuation: Continuation<User>): Any {
... // 切到非 UI 線程中執(zhí)行捞蛋,等待結(jié)果返回
try {
val user = ...
handler.post{ continuation.resume(user) }
} catch(e: Exception) {
handler.post{ continuation.resumeWithException(e) }
}
}
這樣的回調(diào)大家一看就能明白孝冒。講了這么多,請(qǐng)大家記住一點(diǎn):從執(zhí)行機(jī)制上來講拟杉,協(xié)程跟回調(diào)沒有什么本質(zhì)的區(qū)別庄涡。
4.2 suspend 函數(shù)的方式
suspend 函數(shù)是 Kotlin 編譯器對(duì)協(xié)程支持的唯一的黑魔法(表面上的,還有其他的我們后面講原理的時(shí)候再說)了搬设,我們前面已經(jīng)通過 Deferred
的 await
方法對(duì)它有了個(gè)大概的了解穴店,我們?cè)賮砜纯?Retrofit 當(dāng)中它還可以怎么用撕捍。
Retrofit 當(dāng)前的 release 版本是 2.5.0,還不支持 suspend 函數(shù)泣洞。因此想要嘗試下面的代碼忧风,需要最新的 Retrofit 源碼的支持;當(dāng)然球凰,也許你看到這篇文章的時(shí)候狮腿,Retrofit 的新版本已經(jīng)支持這一項(xiàng)特性了呢。
首先我們修改接口方法:
@GET("users/{login}")
suspend fun getUser(@Path("login") login: String): User
這種情況 Retrofit 會(huì)根據(jù)接口方法的聲明來構(gòu)造 Continuation
弟蚀,并且在內(nèi)部封裝了 Call
的異步請(qǐng)求(使用 enqueue)蚤霞,進(jìn)而得到 User
實(shí)例,具體原理后面我們有機(jī)會(huì)再介紹义钉。使用方法如下:
GlobalScope.launch {
try {
showUser(gitHubServiceApi.getUser("bennyhuo"))
} catch (e: Exception) {
showError(e)
}
}
它的執(zhí)行流程與 Deferred.await
類似昧绣,我們就不再詳細(xì)分析了。
5. 協(xié)程到底是什么
好捶闸,堅(jiān)持讀到這里的朋友們夜畴,你們一定是異步代碼的“受害者”,你們肯定遇到過“回調(diào)地獄”删壮,它讓你的代碼可讀性急劇降低贪绘;也寫過大量復(fù)雜的異步邏輯處理、異常處理央碟,這讓你的代碼重復(fù)邏輯增加税灌;因?yàn)榛卣{(diào)的存在,還得經(jīng)常處理線程切換亿虽,這似乎并不是一件難事菱涤,但隨著代碼體量的增加,它會(huì)讓你抓狂洛勉,線上上報(bào)的異常因線程使用不當(dāng)導(dǎo)致的可不在少數(shù)粘秆。
而協(xié)程可以幫你優(yōu)雅的處理掉這些。
協(xié)程本身是一個(gè)脫離語言實(shí)現(xiàn)的概念收毫,我們“很嚴(yán)謹(jǐn)”(哈哈)的給出維基百科的定義:
Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed. Coroutines are well-suited for implementing familiar program components such as cooperative tasks, exceptions, event loops, iterators, infinite lists and pipes.
簡(jiǎn)單來說就是攻走,協(xié)程是一種非搶占式或者說協(xié)作式的計(jì)算機(jī)程序并發(fā)調(diào)度的實(shí)現(xiàn),程序可以主動(dòng)掛起或者恢復(fù)執(zhí)行此再。這里還是需要有點(diǎn)兒操作系統(tǒng)的知識(shí)的昔搂,我們?cè)?Java 虛擬機(jī)上所認(rèn)識(shí)到的線程大多數(shù)的實(shí)現(xiàn)是映射到內(nèi)核的線程的,也就是說線程當(dāng)中的代碼邏輯在線程搶到 CPU 的時(shí)間片的時(shí)候才可以執(zhí)行引润,否則就得歇著巩趁,當(dāng)然這對(duì)于我們開發(fā)者來說是透明的;而經(jīng)常聽到所謂的協(xié)程更輕量的意思是,協(xié)程并不會(huì)映射成內(nèi)核線程或者其他這么重的資源议慰,它的調(diào)度在用戶態(tài)就可以搞定蠢古,任務(wù)之間的調(diào)度并非搶占式,而是協(xié)作式的别凹。
關(guān)于并發(fā)和并行:正因?yàn)?CPU 時(shí)間片足夠小草讶,因此即便一個(gè)單核的 CPU,也可以給我們營(yíng)造多任務(wù)同時(shí)運(yùn)行的假象炉菲,這就是所謂的“并發(fā)”堕战。并行才是真正的同時(shí)運(yùn)行。并發(fā)的話拍霜,更像是 Magic嘱丢。
如果大家熟悉 Java 虛擬機(jī)的話,就想象一下 Thread 這個(gè)類到底是什么吧祠饺,為什么它的 run 方法會(huì)運(yùn)行在另一個(gè)線程當(dāng)中呢越驻?誰負(fù)責(zé)執(zhí)行這段代碼的呢?顯然道偷,咋一看缀旁,Thread 其實(shí)是一個(gè)對(duì)象而已,run 方法里面包含了要執(zhí)行的代碼——僅此而已勺鸦。協(xié)程也是如此并巍,如果你只是看標(biāo)準(zhǔn)庫的 API,那么就太抽象了换途,但我們開篇交代了懊渡,學(xué)習(xí)協(xié)程不要上來去接觸標(biāo)準(zhǔn)庫,kotlinx.coroutines 框架才是我們用戶應(yīng)該關(guān)心的军拟,而這個(gè)框架里面對(duì)應(yīng)于 Thread 的概念就是 Job 了距贷,大家可以看下它的定義:
public interface Job : CoroutineContext.Element {
...
public val isActive: Boolean
public val isCompleted: Boolean
public val isCancelled: Boolean
public fun start(): Boolean
public fun cancel(cause: CancellationException? = null)
public suspend fun join()
...
}
我們?cè)賮砜纯?Thread 的定義:
public class Thread implements Runnable {
...
public final native boolean isAlive();
public synchronized void start() { ... }
@Deprecated
public final void stop() { ... }
public final void join() throws InterruptedException { ... }
...
}
這里我們非常貼心的省略了一些注釋和不太相關(guān)的接口。我們發(fā)現(xiàn)吻谋,Thread 與 Job 基本上功能一致,它們都承載了一段代碼邏輯(前者通過 run 方法现横,后者通過構(gòu)造協(xié)程用到的 Lambda 或者函數(shù))漓拾,也都包含了這段代碼的運(yùn)行狀態(tài)。
而真正調(diào)度時(shí)二者才有了本質(zhì)的差異戒祠,具體怎么調(diào)度骇两,我們只需要知道調(diào)度結(jié)果就能很好的使用它們了。
6. 小結(jié)
我們先通過例子來引入姜盈,從大家最熟悉的代碼到協(xié)程的例子開始低千,演化到協(xié)程的寫法,讓大家首先能從感性上對(duì)協(xié)程有個(gè)認(rèn)識(shí),最后我們給出了協(xié)程的定義示血,也告訴大家協(xié)程究竟能做什么棋傍。
這篇文章沒有追求什么內(nèi)部原理,只是企圖讓大家對(duì)協(xié)程怎么用有個(gè)第一印象难审。如果大家仍然感覺到迷惑瘫拣,不怕,后面我將再用幾篇文章從例子入手來帶著大家分析協(xié)程的運(yùn)行告喊,而原理的分析麸拄,會(huì)放到大家能夠熟練掌握協(xié)程之后再來探討。
歡迎關(guān)注 Kotlin 中文社區(qū)黔姜!
中文官網(wǎng):https://www.kotlincn.net/
中文官方博客:https://www.kotliner.cn/
公眾號(hào):Kotlin
知乎專欄:Kotlin
CSDN:Kotlin中文社區(qū)
簡(jiǎn)書:Kotlin中文社區(qū)
開發(fā)者頭條:Kotlin中文社區(qū)