教你如何使用協程(四)協程+Kotlin+ Retrofit實現網絡請求

原文地址:https://www.bennyhuo.com/2019/04/01/basic-coroutines/#more

接觸新概念,最好的辦法就是先整體看個大概,再回過頭來細細品味

需求確認

在開始講解本文之前,我們需要先確認幾件事兒:

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

看下你的答案:

  1. 如果上面的問題的回答都是 “Yes”,那麼太好了,這篇文章非常適合你,因爲你已經意識到回調有多麼可怕,並且找到了解決方案;
  2. 如果前兩個是 “Yes”,沒問題,至少你已經開始用回調了,你是協程潛在的用戶;
  3. 如果只有第一個是 “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 線程來展示結果。這類代碼大量存在於我們的邏輯當中,它有什麼問題呢?

  1. 通過 Lambda 表達式,我們讓線程切換變得不是那麼明顯,但它仍然存在,一旦開發者出現遺漏,這裏就會出現問題
  2. 回調嵌套了兩層,看上去倒也沒什麼,但真實的開發環境中邏輯一定比這個複雜的多,例如登錄失敗的重試
  3. 重複或者分散的異常處理邏輯,在請求失敗時我們調用了一次 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 或者函數),也都包含了這段代碼的運行狀態。

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章