原文地址:https://www.bennyhuo.com/2019/04/01/basic-coroutines/#more
接觸新概念,最好的辦法就是先整體看個大概,再回過頭來細細品味
需求確認
在開始講解本文之前,我們需要先確認幾件事兒:
- 你用過線程對吧?
- 你寫過回調對吧?
- 你用過 RxJava 類似的框架嗎?
看下你的答案:
- 如果上面的問題的回答都是 “Yes”,那麼太好了,這篇文章非常適合你,因爲你已經意識到回調有多麼可怕,並且找到了解決方案;
- 如果前兩個是 “Yes”,沒問題,至少你已經開始用回調了,你是協程潛在的用戶;
- 如果只有第一個是 “Yes”,那麼,可能你剛剛開始學習線程,那你還是先打好基礎再來吧~
一個常規例子
我們通過 Retrofit 發送一個網絡請求,其中接口如下:
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)
}
那麼我們請求網絡時:
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()) }
}
})
請求結果回來之後,我們切換線程到 UI 線程來展示結果。這類代碼大量存在於我們的邏輯當中,它有什麼問題呢?
- 通過 Lambda 表達式,我們讓線程切換變得不是那麼明顯,但它仍然存在,一旦開發者出現遺漏,這裏就會出現問題
- 回調嵌套了兩層,看上去倒也沒什麼,但真實的開發環境中邏輯一定比這個複雜的多,例如登錄失敗的重試
- 重複或者分散的異常處理邏輯,在請求失敗時我們調用了一次 showError,在數據讀取失敗時我們又調用了一次,真實的開發環境中可能會有更多的重複
Kotlin 本身的語法已經讓這段代碼看上去好很多了,如果用 Java 寫的話,你的直覺都會告訴你:你在寫 Bug。
如果你不是 Android 開發者,那麼你可能不知道 handler 是什麼東西,沒關係,你可以替換爲 SwingUtilities.invokeLater{ … } (Java Swing),或者 setTimeout({ … }, 0) (Js) 等等。
改造成協程
你當然可以改造成 RxJava 的風格,但 RxJava 比協程抽象多了,因爲除非你熟練使用那些 operator,不然你根本不知道它在幹嘛(試想一下 retryWhen)。協程就不一樣了,畢竟編譯器加持,它可以很簡潔的表達出代碼的邏輯,不要想它背後的實現邏輯,它的運行結果就是你直覺告訴你的那樣。
對於 Retrofit,改造成協程的寫法,有兩種,分別是通過 CallAdapter 和 suspend 函數。
4.1 CallAdapter 的方式
我們先來看看 CallAdapter 的方式,這個方式的本質是讓接口的方法返回一個協程的 Job:
interface GitHubServiceApi {
@GET("users/{login}")
fun getUser(@Path("login") login: String): Deferred<User>
}
注意 Deferred 是 Job 的子接口。
那麼我們需要爲 Retrofit 添加對 Deferred 的支持,這需要用到開源庫:
implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'
構造 Retrofit 實例時添加:
val gitHubServiceApi by lazy {
val retrofit = retrofit2.Retrofit.Builder()
.baseUrl("https://api.github.com")
.addConverterFactory(GsonConverterFactory.create())
//添加對 Deferred 的支持
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.build()
retrofit.create(GitHubServiceApi::class.java)
}
那麼這時候我們發起請求就可以這麼寫了:
GlobalScope.launch(Dispatchers.Main) {
try {
showUser(gitHubServiceApi.getUser("bennyhuo").await())
} catch (e: Exception) {
showError(e)
}
}
說明: Dispatchers.Main 在不同的平臺上的實現不同,如果在 Android 上爲 HandlerDispatcher,在 Java Swing 上爲 SwingDispatcher 等等。
首先我們通過 launch 啓動了一個協程,這類似於我們啓動一個線程,launch 的參數有三個,依次爲協程上下文、協程啓動模式、協程體:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext, // 上下文
start: CoroutineStart = CoroutineStart.DEFAULT, // 啓動模式
block: suspend CoroutineScope.() -> Unit // 協程體
): Job
啓動模式不是一個很複雜的概念,不過我們暫且不管,默認直接允許調度執行。
上下文可以有很多作用,包括攜帶參數,攔截協程執行等等,多數情況下我們不需要自己去實現上下文,只需要使用現成的就好。上下文有一個重要的作用就是線程切換,Dispatchers.Main 就是一個官方提供的上下文,它可以確保 launch 啓動的協程體運行在 UI 線程當中(除非你自己在 launch 的協程體內部進行線程切換、或者啓動運行在其他有線程切換能力的上下文的協程)。
換句話說,在例子當中整個 launch 內部你看到的代碼都是運行在 UI 線程的,儘管 getUser 在執行的時候確實切換了線程,但返回結果的時候會再次切回來。這看上去有些費解,因爲直覺告訴我們,getUser 返回了一個 Deferred 類型,它的 await 方法會返回一個 User 對象,意味着 await 需要等待請求結果返回纔可以繼續執行,那麼 await 不會阻塞 UI 線程嗎?
答案是:不會。當然不會,不然那 Deferred 與 Future 又有什麼區別呢?這裏 await 就很可疑了,因爲它實際上是一個 suspend 函數,這個函數只能在協程體或者其他 suspend 函數內部被調用,它就像是回調的語法糖一樣,它通過一個叫 Continuation 的接口的實例來返回結果:
@SinceKotlin("1.3")
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
1.3 的源碼其實並不是很直接,儘管我們可以再看下 Result 的源碼,但我不想這麼做。更容易理解的是之前版本的源碼:
@SinceKotlin("1.1")
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resume(value: T)
public fun resumeWithException(exception: Throwable)
}
相信大家一下就能明白,這其實就是個回調嘛。如果還不明白,那就對比下 Retrofit 的 Callback:
public interface Callback<T> {
void onResponse(Call<T> call, Response<T> response);
void onFailure(Call<T> call, Throwable t);
}
有結果正常返回的時候,Continuation 調用 resume 返回結果,否則調用 resumeWithException 來拋出異常,簡直與 Callback 一模一樣。
所以這時候你應該明白,這段代碼的執行流程本質上是一個異步回調:
GlobalScope.launch(Dispatchers.Main) {
try {
//showUser 在 await 的 Continuation 的回調函數調用後執行
showUser(gitHubServiceApi.getUser("bennyhuo").await())
} catch (e: Exception) {
showError(e)
}
}
而代碼之所以可以看起來是同步的,那就是編譯器的黑魔法了,你當然也可以叫它“語法糖”。
這時候也許大家還是有問題:我並沒有看到 Continuation 啊,沒錯,這正是我們前面說的編譯器黑魔法了,在 Java 虛擬機上,await 這個方法的簽名其實並不像我們看到的那樣:
public suspend fun await(): T
它真實的簽名其實是:
kotlinx/coroutines/Deferred.await (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
即接收一個 Continuation 實例,返回 Object 的這麼個函數,所以前面的代碼我們可以大致理解爲:
//注意以下不是正確的代碼,僅供大家理解協程使用
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 當中,大致就是:
//注意以下並不是真實的實現,僅供大家理解協程使用
fun await(continuation: Continuation<User>): Any {
... // 切到非 UI 線程中執行,等待結果返回
try {
val user = ...
handler.post{ continuation.resume(user) }
} catch(e: Exception) {
handler.post{ continuation.resumeWithException(e) }
}
}
這樣的回調大家一看就能明白。講了這麼多,請大家記住一點:從執行機制上來講,協程跟回調沒有什麼本質的區別。
suspend 函數的方式
suspend 函數是 Kotlin 編譯器對協程支持的唯一的黑魔法(表面上的,還有其他的我們後面講原理的時候再說)了,我們前面已經通過 Deferred 的 await 方法對它有了個大概的瞭解,我們再來看看 Retrofit 當中它還可以怎麼用。
Retrofit 當前的 release 版本是 2.5.0,還不支持 suspend 函數。因此想要嘗試下面的代碼,需要最新的 Retrofit 源碼的支持;當然,也許你看到這篇文章的時候,Retrofit 的新版本已經支持這一項特性了呢。
首先我們修改接口方法:
@GET("users/{login}")
suspend fun getUser(@Path("login") login: String): User
這種情況 Retrofit 會根據接口方法的聲明來構造 Continuation,並且在內部封裝了 Call 的異步請求(使用 enqueue),進而得到 User 實例,具體原理後面我們有機會再介紹。使用方法如下:
GlobalScope.launch {
try {
showUser(gitHubServiceApi.getUser("bennyhuo"))
} catch (e: Exception) {
showError(e)
}
}
它的執行流程與 Deferred.await 類似,我們就不再詳細分析了。
協程到底是什麼
好,堅持讀到這裏的朋友們,你們一定是異步代碼的“受害者”,你們肯定遇到過“回調地獄”,它讓你的代碼可讀性急劇降低;也寫過大量複雜的異步邏輯處理、異常處理,這讓你的代碼重複邏輯增加;因爲回調的存在,還得經常處理線程切換,這似乎並不是一件難事,但隨着代碼體量的增加,它會讓你抓狂,線上上報的異常因線程使用不當導致的可不在少數。
而協程可以幫你優雅的處理掉這些。
協程本身是一個脫離語言實現的概念,我們“很嚴謹”(哈哈)的給出維基百科的定義:
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.
簡單來說就是,協程是一種非搶佔式或者說協作式的計算機程序併發調度的實現,程序可以主動掛起或者恢復執行。這裏還是需要有點兒操作系統的知識的,我們在 Java 虛擬機上所認識到的線程大多數的實現是映射到內核的線程的,也就是說線程當中的代碼邏輯在線程搶到 CPU 的時間片的時候纔可以執行,否則就得歇着,當然這對於我們開發者來說是透明的;而經常聽到所謂的協程更輕量的意思是,協程並不會映射成內核線程或者其他這麼重的資源,它的調度在用戶態就可以搞定,任務之間的調度並非搶佔式,而是協作式的。
關於併發和並行:正因爲 CPU 時間片足夠小,因此即便一個單核的 CPU,也可以給我們營造多任務同時運行的假象,這就是所謂的“併發”。並行纔是真正的同時運行。併發的話,更像是 Magic。
如果大家熟悉 Java 虛擬機的話,就想象一下 Thread 這個類到底是什麼吧,爲什麼它的 run 方法會運行在另一個線程當中呢?誰負責執行這段代碼的呢?顯然,咋一看,Thread 其實是一個對象而已,run 方法裏面包含了要執行的代碼——僅此而已。協程也是如此,如果你只是看標準庫的 API,那麼就太抽象了,但我們開篇交代了,學習協程不要上來去接觸標準庫,kotlinx.coroutines 框架纔是我們用戶應該關心的,而這個框架裏面對應於 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()
...
}
我們再來看看 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 { ... }
...
}
這裏我們非常貼心的省略了一些註釋和不太相關的接口。我們發現,Thread 與 Job 基本上功能一致,它們都承載了一段代碼邏輯(前者通過 run 方法,後者通過構造協程用到的 Lambda 或者函數),也都包含了這段代碼的運行狀態。
而真正調度時二者纔有了本質的差異,具體怎麼調度,我們只需要知道調度結果就能很好的使用它們了。