用 Kotlin 协程把网络请求玩出花来

7,921 阅读6分钟

前言

通常我们做网络请求的时候,几乎都是 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()
}

好了,这次的介绍到此为止,如果看官觉得玩得还不够花,那么你们也可以尝试一下哟