破解 Kotlin 協程(1) - 入門篇

關鍵詞:Kotlin 協程 入門java

假定你對協程(Coroutine)一點兒都不瞭解,經過閱讀本文看看是否能讓你明白協程是怎麼一回事。git

1. 引子

我以前寫過一些協程的文章,好久之前了。那會兒仍是很痛苦的,畢竟 kotlinx.coroutines 這樣強大的框架還在襁褓當中,因而乎我寫的幾篇協程的文章幾乎就是在告訴你們如何寫這樣一個框架——那種感受簡直糟糕透了,由於沒有幾我的會有這樣的需求。程序員

此次準備從協程用戶(也就是程序員你我他啦)的角度來寫一下,但願對你們能有幫助。github

2. 需求確認

在開始講解協程以前,咱們須要先確認幾件事兒:api

  1. 你用過線程對吧?
  2. 你寫過回調對吧?
  3. 你用過 RxJava 相似的框架嗎?

看下你的答案:網絡

  • 若是上面的問題的回答都是 「Yes」,那麼太好了,這篇文章很是適合你,由於你已經意識到回調有多麼可怕,而且找到了解決方案;
  • 若是前兩個是 「Yes」,沒問題,至少你已經開始用回調了,你是協程潛在的用戶;
  • 若是隻有第一個是 「Yes」,那麼,可能你剛剛開始學習線程,那你仍是先打好基礎再來吧~

3. 一個常規例子

咱們經過 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 線程來展現結果。這類代碼大量存在於咱們的邏輯當中,它有什麼問題呢?ide

  • 經過 Lambda 表達式,咱們讓線程切換變得不是那麼明顯,但它仍然存在,一旦開發者出現遺漏,這裏就會出現問題
  • 回調嵌套了兩層,看上去倒也沒什麼,但真實的開發環境中邏輯必定比這個複雜的多,例如登陸失敗的重試
  • 重複或者分散的異常處理邏輯,在請求失敗時咱們調用了一次 showError,在數據讀取失敗時咱們又調用了一次,真實的開發環境中可能會有更多的重複

Kotlin 自己的語法已經讓這段代碼看上去好不少了,若是用 Java 寫的話,你的直覺都會告訴你:你在寫 Bug。

若是你不是 Android 開發者,那麼你可能不知道 handler 是什麼東西,不要緊,你能夠替換爲 SwingUtilities.invokeLater{ ... } (Java Swing),或者 setTimeout({ ... }, 0) (Js) 等等。

4. 改形成協程

你固然能夠改形成 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 線程嗎?

答案是:不會。固然不會,否則那 DeferredFuture 又有什麼區別呢?這裏 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) }
    }
}

複製代碼

這樣的回調你們一看就能明白。講了這麼多,請你們記住一點:從執行機制上來說,協程跟回調沒有什麼本質的區別。

4.2 suspend 函數的方式

suspend 函數是 Kotlin 編譯器對協程支持的惟一的黑魔法(表面上的,還有其餘的咱們後面講原理的時候再說)了,咱們前面已經經過 Deferredawait 方法對它有了個大概的瞭解,咱們再來看看 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 相似,咱們就再也不詳細分析了。

5. 協程究竟是什麼

好,堅持讀到這裏的朋友們,大家必定是異步代碼的「受害者」,大家確定遇到過「回調地獄」,它讓你的代碼可讀性急劇下降;也寫過大量複雜的異步邏輯處理、異常處理,這讓你的代碼重複邏輯增長;由於回調的存在,還得常常處理線程切換,這彷佛並非一件難事,但隨着代碼體量的增長,它會讓你抓狂,線上上報的異常因線程使用不當致使的可不在少數。

協程能夠幫你優雅的處理掉這些。

協程自己是一個脫離語言實現的概念,咱們「很嚴謹」(哈哈)的給出維基百科的定義:

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 或者函數),也都包含了這段代碼的運行狀態。

而真正調度時兩者纔有了本質的差別,具體怎麼調度,咱們只須要知道調度結果就能很好的使用它們了。

6. 小結

咱們先經過例子來引入,從你們最熟悉的代碼到協程的例子開始,演化到協程的寫法,讓你們首先能從感性上對協程有個認識,最後咱們給出了協程的定義,也告訴你們協程究竟能作什麼。

這篇文章沒有追求什麼內部原理,只是企圖讓你們對協程怎麼用有個第一印象。若是你們仍然感受到迷惑,不怕,後面我將再用幾篇文章從例子入手來帶着你們分析協程的運行,而原理的分析,會放到你們可以熟練掌握協程以後再來探討。


歡迎關注 Kotlin 中文社區!

中文官網:www.kotlincn.net/

中文官方博客:www.kotliner.cn/

公衆號:Kotlin

知乎專欄:Kotlin

CSDN:Kotlin中文社區

掘金:Kotlin中文社區

簡書:Kotlin中文社區

相關文章
相關標籤/搜索