用 Kotlin 協程把網路請求玩出花來

neverwoods發表於2017-09-30

前言

通常我們做網路請求的時候,幾乎都是 callback 的形式:

request.execute(callback)複製程式碼
callback = {
    onSuccess =  { res ->
        // TODO
    }

    onFail =  { error -> 
        // TODO
    }
}複製程式碼

長久以來,我都習慣了這樣子的寫法。即便遇到困難,有過質疑,但仍然不知道能有什麼樣的替代方式。也許有的小夥伴會說 RxJava,沒錯,RxJava 在一定程度上確實可以緩解一下 callback 方式帶來的一些麻煩,但本質上subscriber 真的脫離 callback 了嗎?

request.subscribe(subscriber)
...
subscriber = ...複製程式碼
request.subscribe({
    // TODO Success
}, {
    // TODO Error
})複製程式碼

相比之下,Kotlin 提供的非同步方式更為清爽。程式碼沒有被割裂成兩塊甚至 N 塊,邏輯還是順序的。

doAsync {
    val response = request.execute()
    uiThread {
        // TODO
    }
}複製程式碼

當然這不是我這次想要說的重點,這畢竟還只是前言

####初見
前些日子學習了一下 Kotlin 的協程,坦白的講,雖然我明白了協程的概念和一定程度的理論,但是一下子讓我看那麼多那麼複雜的 API,我感覺頭好暈(其實是懶)。

關於協程是什麼,建議小夥伴們自行 google。

偶然的一天,聽朋友說 anko 支援協程了,我一下子就興奮了起來,馬上前往 github 打算觀摩一番。至於我為什麼興奮,瞭解 anko 的人應該都懂。可當我真正開啟 anko-coroutines 的 wiki 之後,我震驚了,因為在我的觀念中這麼複雜的協程,wiki 居然只寫了兩個函式的介紹?

看到這裡估計很多小夥伴要不耐煩了,好吧,我們們進入 code 時間:

fun getData(): Data { ... }
fun showData(data: Data) { ... }

async(UI) {
    val data: Deferred<Data> = bg {
        // Runs in background
        getData()
    }

    // This code is executed on the UI thread
    showData(data.await())
}複製程式碼

讓我們暫且忽略掉最外層的 async(UI) :

val data: Deferred<Data> = bg {
    // Runs in background    
    getData()
}

// This code is executed on the UI thread
showData(data.await())複製程式碼

註釋說的很清楚,bg {} 所包裹的 getData() 函式是跑在 background 的,可是接下來在 UI thread 上執行的程式碼居然直接引用了 getData 返回的物件??這於理不合吧??

聰明的小夥伴從程式碼上或許已經看出端倪了,那就是 bg {} 包裹的程式碼快最終返回的是一個 Deferred 物件,而這個 Deferred 物件的 await 函式在這裡起到了關鍵作用 —— 阻塞當前的協程,等待結果。

而至於被我們暫且忽略的 async(UI) {} ,則是指在 UI 執行緒上開闢一條非同步的協程任務。因為是非同步的,哪怕被阻塞了也不會導致整個 UI 執行緒阻塞;因為還是在 UI 執行緒上的,所以我們可以放心的做 UI 操作。相應的,bg {} 其實可以理解為 async(BACKGROUND) {},所以才可以在 Android 上做網路請求。

所以,上面的程式碼其實是 UI 執行緒上的 ui 協程,和 BG 執行緒上的 bg 協程之間的小故事。

對比

比起之前的 doAsync -- uiThread 程式碼,看著很像,但也僅僅是像而已。doAsync 是開闢一條新的執行緒,在這個執行緒中你寫的程式碼不可能再和 doAsync 外部的執行緒同步上,要想產生關聯,就得通過之前的 callback 方式。

而通過上面的程式碼我們已經看到,採用協程的方式,我們卻可以讓協程等待另一個協程,哪怕這另一個協程還是屬於另一個執行緒的。

能夠用寫同步程式碼的方式去寫非同步的任務,想必這是不少人喜歡協程的一大原因。在這裡我嘗試了一下,用協程配合 Retrofit 做網路請求:

asyncUI {
    val deferred = bg {
        // 在 BG 執行緒的 bg 協程中呼叫介面
        Server.getApiStore().login("173176360", "123456").execute()
    }

    // 模擬彈出載入進度條之類的操作,反正是在 UI 執行緒上搞事
    textView.text = "loading"

    // 等待介面呼叫的結果
    val response = deferred.await()

    // 根據介面呼叫狀況做處理,反正是在 UI 執行緒,隨便玩
    if (response.isSuccessful) {
        textView.text = response.body().toString()
    } else {
        toast(response.errorBody().string())
    }
}複製程式碼

怕你們沒耐心,我想說的話都在註釋裡了。

正文

吃瓜群眾:什麼?這才到正文嗎?
在下:當然,就上面那點內容,我好意思說玩出花?

好了,調侃歸調侃,我還是得說,如果就只是上面那一段程式碼,價值也是有的,但真不大。因為相對於傳統 callback 而言的優勢還沒能展現出來。那優勢怎麼展現呢?請看程式碼:

async(UI) {
    // 假設這是兩個不同的 api 請求
    val deferred1 = bg {
        Server.getApiStore().login("173176360", "123456").execute()
    }

    val deferred2 = bg {
        Server.getApiStore().login("173176360", "123456").execute()
    }

    val res1 = deferred1.await()
    val res2 = deferred2.await()

    // 此時兩個請求都完成了
    textView.text = res1.body().toString() + res2.body().toString()
}複製程式碼

看見了嗎?要知道我這還沒做任何封裝,像這樣的邏輯,哪怕是 RxJava 也不能寫得如此簡單。這就是用同步的程式碼寫非同步任務的魅力。

想想我們以前是怎麼寫這樣的邏輯的?如果再多來幾個這樣的呢?callback hell 是不是就有了?

稍作封裝,我們能見到這樣的請求:

asyncUI {
    val deferred = bg {
        Server.getApiStore().login("173176360", "123456").execute()
    }

    textView.text = "loading"

    // 接收 response.body 如有異常則 toast 出來
    val info = deferred.wait(TOAST) // or Log

    // 因為有, 能走到這裡一定是沒有異常
    textView.text = info.toString()
}複製程式碼

等待的同時新增一種預設的處理異常的方式,不用每次都中斷流暢的邏輯,寫 if-else 程式碼。

有人說:除了 toast 和 log,異常的時候我還想做別的事咋辦?

asyncUI {
    val deferred = bg {
        Server.getApiStore().login("173176360", "123456").execute()
    }

    textView.text = "loading"

    val info = deferred.handleException {
        // 自定義異常處理,足夠靈活 (it == errorBody)
        toast(it.string())
    }

    textView.text = info.toString()
}複製程式碼

又有人說,你這樣子讓我很難辦啊,如果我成功失敗時的做的事情都一樣,那不是同樣的程式碼要寫兩份?

asyncUI {
    val deferred = bg {
        Server.getApiStore().login("173176360", "123456").execute()
    }

    textView.text = "loading"

    // 我不關心返回來的是成功還是失敗,也不關心返回的引數
    // 我需要的是請求完成(包括成功、失敗)後執行後續任務
    deferred.wait(THROUGH)

    // type 為 through,即就算有異常發生也會走到這裡來
    textView.text = "done"
}複製程式碼

如果我只是想複用部分程式碼,成功失敗還是有不同的呢?那您老還是用最原始的 await 函式吧。。當然,我這裡還是封裝了一下的,至少可以將 Response 轉化為 Data,多多少少省點心

asyncUI {
    val deferred = bg {
        Server.getApiStore().login("1731763609", "123456").execute()
    }

    textView.text = "loading"

    // 我不關心返回來的是成功還是失敗,也不關心返回的引數
    // 我需要的是請求完成(包括成功、失敗)後執行後續任務
    val info = deferred.wait(THROUGH)

    // type 為 through,即就算有異常發生也會走到這裡來
    textView.text = "done"

    if (info.isSuccess) {
        // TODO 成功
    } else {
        // TODO 失敗
    }
}複製程式碼

結合上面的多個 api 請求的狀況

asyncUI {
    // 假設這是兩個不同的 api 請求
    val deferred1 = bg {
        Server.getApiStore().login("173176360", "123456").execute()
    }

    val deferred2 = bg {
        Server.getApiStore().login("173176360", "123456").execute()
    }

    // 後臺請求著 api,此時我還可以在 UI 協程中做我想做的事情
    textView.text = "loading"
    delay(5, TimeUnit.SECONDS)

    // 等 UI 協程中的事情做完了,專心等待 api 請求完成(其實 api 請求有可能已經完成了)
    // 通過提供 ExceptionHandleType 進行異常的過濾
    val response = deferred1.wait(TOAST)
    deferred2.wait(THROUGH) // deferred2 的結果我不關心

    // 此時兩個請求肯定都完成了,並且 deferred1 沒有異常發生
    textView.text = response.toString()
}複製程式碼

好了,這次的介紹到此為止,如果看官覺得玩得還不夠花,那麼你們也可以嘗試一下喲

相關文章