忘了RxJava吧—拥抱Kotlin协程(Part 1/2)

忘了RxJava吧—拥抱Kotlin协程(Part 1/2)

一、前言

我非常喜欢 Kotlin ,也非常喜欢 Kotlin Coroutines 协程,在看到了这篇文章后心里真是激动啊!于是乎——就有了这篇谷歌自动人工翻译,以飨观众!希望大家喜欢。哈哈。 :sunglasses:

作者:Vladimir Ivanov阅读时间: 4 分钟原文链接:proandroiddev.com/forge

二、正文

嗨! RxJava 确实是一项令人惊奇的技术,特别是对于 Android 应用程序开发人员来说,它在这几年里为我们提供了完全不同的开发体验,它省去了那些无穷无尽的AsyncTasksLoaders 和其他工具的烦恼,代替的是简洁而又直白的函数式风格代码。

举个例子,使用 RxJava 来创建一个 GitHub API 的相关应用程序,里面的网络层接口一般如下所示:

interface ApiClientRx {

    fun login(auth: Authorization) : Single<GithubUser>
    fun getRepositories(reposUrl: String, auth: Authorization) : Single<List<GithubRepository>>
    fun searchRepositories(query: String) : Single<List<GithubRepository>>

}

虽然 RxJava 是一个功能非常强大的库,但这并不意味着它就一定要作为管理异步工作的工具。它只是一个事件处理库。

我们通常使用 Single 来表示网络层操作的结果,得到的结果要么是某个值或者获取失败。

activity/fragment 中使用上面那个接口的代码一般如下所示(稍后我会考虑对它进行单元测试):

private fun attemptLoginRx() {
  val login = email.text.toString()
  val pass = password.text.toString()

  val auth = BasicAuthorization(login, pass)
  val apiClient = ApiClientRx.ApiClientRxImpl()
  showProgressVisible(true)
  compositeDisposable.add(apiClient.login(auth)
    .flatMap {
        user -> apiClient.getRepositories(user.reposUrl, auth)
    }
    .map { list -> list.map { it.fullName } }
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .doAfterTerminate { showProgressVisible(false) }
    .subscribe(
            { list -> showRepositories(this@LoginActivity, list) },
            { error -> Log.e("TAG", "Failed to show repos", error) }
    ))
}

这段代码看起来或多或少还是可以理解的,但是这里有几个隐藏的缺陷:

性能开销问题

这段代码的每一行都会生成一个内部对象(或者好几个)来完成这项工作。当前代码下,它产生了 19 个对象。想象一下,在更复杂的情形下这个数字会变成多少。



io.reactivex包下内存存储堆


堆栈信息可读性差

假设你在代码中犯了一个错误,或者疏忽了某些情形判断,然后这些问题在产品的质量检查期间并没有被发现,之后产品投入生产。现在,你的产品崩溃报告打印出了一堆堆栈跟踪信息:

at com.epam.talks.github.model.ApiClientRx$ApiClientRxImpl$login$1.call(ApiClientRx.kt:16)
at io.reactivex.internal.operators.single.SingleFromCallable.subscribeActual(SingleFromCallable.java:44)
at io.reactivex.Single.subscribe(Single.java:3096)
at io.reactivex.internal.operators.single.SingleFlatMap.subscribeActual(SingleFlatMap.java:36)
at io.reactivex.Single.subscribe(Single.java:3096)
at io.reactivex.internal.operators.single.SingleMap.subscribeActual(SingleMap.java:34)
at io.reactivex.Single.subscribe(Single.java:3096)
at io.reactivex.internal.operators.single.SingleSubscribeOn$SubscribeOnObserver.run(SingleSubscribeOn.java:89)
at io.reactivex.Scheduler$DisposeTask.run(Scheduler.java:463)
at io.reactivex.internal.schedulers.ScheduledRunnable.run(ScheduledRunnable.java:66)
at io.reactivex.internal.schedulers.ScheduledRunnable.call(ScheduledRunnable.java:57)
at java.util.concurrent.FutureTask.run(FutureTask.java:266)
at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:301)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1162)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:636)
at java.lang.Thread.run(Thread.java:764)

很酷啊,整个堆栈信息就来自你那代码里的一行!

陡峭的学习曲线

你还记得花了多少时间来理解 map()flatMap() 之间的区别吗?更别说其他成千上万的操作符了。时间开销是每个新手开发人员学习进入响应式编程世界所必需面对的。

可读性

代码可读性是可以的,但是我们还是传递了并不能代表我们普通思维模型的回调函数(我们是按顺序进行思考的)。

如果Kotlin Coroutines可以让我们的生活更美好,那会怎么样?

首先让我们来看下我们是否可以替换 Single 这个对象。在协程的世界里,最合适的对象就是 Deferred 接口了。它看起来长这样子的:

public actual interface Deferred<out T> : Job {
   public suspend fun await(): T
}

interface Job : CoroutineContext.Element {
    public val isActive: Boolean
    public val isCompleted: Boolean
    public val isCancelled: Boolean
    public fun getCancellationException(): CancellationException
    public fun start(): Boolean
}

这里,使用我们的 ApiClient 接口进行一个简单的重构,结果如下:

interface ApiClient {

    fun login(auth: Authorization) : Deferred<GithubUser>
    fun getRepositories(reposUrl: String, auth: Authorization) : Deferred<List<GithubRepository>>

}

实现部分也会相应地做出改变:我们需要将 Single.fromCallable 替换为协程构建器。 async 在这里派上用场:

override fun login(auth: Authorization) : Deferred<GithubUser?> = async {
    val response = get("https://api.github.com/user", auth = auth)
    if (response.statusCode != 200) {
        throw RuntimeException("Incorrect login or password")
    }

    val jsonObject = response.jsonObject
    with (jsonObject) {
        return@async GithubUser(getString("login"), getInt("id"),
            getString("repos_url"), getString("name"))
    }
}

您应该知道,使用 RxJava 需要您为异步代码的运行选择 Scheduler 调度,在协程代码中,类似的实体称为 Dispatcher 派发器。默认情况下, asynclaunch 协程构筑器是使用 CommonPool 这个派发器,当然您可以传递任何其他派发器。 OK ,让我们来看看修改后的客户端代码:

job = launch(UI) {
    showProgressVisible(true)
    val auth = BasicAuthorization(login, pass)
    try {
        val userInfo = login(auth).await()
        val repoUrl = userInfo.reposUrl
        val repos = getRepositories(repoUrl, auth).await()
        val pullRequests = getPullRequests(repos[0], auth).await()
        showRepositories(this, repos.map { it -> it.fullName })
    } catch (e: RuntimeException) {
        Toast.makeText(this, e.message, LENGTH_LONG).show()
    } finally {
        showProgressVisible(false)
    }
}

哇塞!代码变得如此的清晰、符合直觉!这看上去根本没有产生异步嘛 :) ,顺便说一下,在 RxJava 版本中,我们把订阅器添加到 compositeDisposable 中以方便在onStop() 中调用它的 dispose() 方法。在协程版本中,我们保存为 job ,然后在同一个地方调用 job.cancel() 方法。请继续关注我即将发表的文章中有关生命周期和协程的更多信息!

那么关于我们在 RxJava 代码中找到的那些缺点去哪了呢?在协程中都解决了吗?

性能开销问题

协程代码产生的对象数量下降到了 11 (下降了三分之一)。



堆栈信息可读性差

堆栈信息可读性差

堆栈跟踪信息还是有些无关,但问题已经在解决当中了

可读性

代码更易于阅读和编写了,这是由于异步代码是使用同步方式编写出来的。

我该如何重构单元测试?

使用 RxJava ,我们使用以下代码做单元测试:

@Test
fun login() {
    val apiClientImpl = ApiClientRx.ApiClientRxImpl()
    val genericResponse = mockLoginResponse()

    staticMockk("khttp.KHttp").use {
        every { get("https://api.github.com/user", auth = any()) } returns genericResponse

        val githubUser = apiClientImpl.login(BasicAuthorization("login", "pass"))

        githubUser.subscribe { githubUser ->
            Assert.assertNotNull(githubUser)
            Assert.assertEquals("name", githubUser.name)
            Assert.assertEquals("url", githubUser.reposUrl)
        }
    }
}

在这里我使用的是 KHttpmockk

使用 Kotlin 协程,测试代码如下:

@Test
fun login() {
    val apiClientImpl = ApiClient.ApiClientImpl()
    val genericResponse = mockLoginResponse()

    staticMockk("khttp.KHttp").use {
        every { get("https://api.github.com/user", auth = any()) } returns genericResponse

        runBlocking {
            val githubUser = apiClientImpl.login(BasicAuthorization("login", "pass")).await()
                
            assertNotNull(githubUser)
            assertEquals("name", githubUser.name)
            assertEquals("url", githubUser.repos_url)
        }
    }
}

测试代码没有太大的改变——我们删除了订阅函数调用,添加了 runBlocking 协程构建器——这样我们的测试就不会在测试代码还没有完全运行完之前提前退出了。

是否有进一步的改进呢?

当然有了。我们可以在我们的业务逻辑对象中抛弃任何包装器,不需要返回 Deferred包装对象,假装没有任何实际的异步操作发生。在这里,我们使用 suspend 修饰符来替换 Deferred 对象:

interface SuspendingApiClient {

    suspend fun login(auth: Authorization) : GithubUser
    suspend fun getRepositories(reposUrl: String, auth: Authorization) : List<GithubRepository>
    suspend fun searchRepositories(searchQuery: String) : List<GithubRepository>

}

哇塞!你难道不喜欢变得如此干净利落的界面吗?我已经做了很多了。让我们来看看我们的客户端代码和测试变化后的样子:

private fun attemptLoginSuspending() {
    val login = email.text.toString()
    val pass = password.text.toString()
    val apiClient = SuspendingApiClient.SuspendingApiClientImpl()
    job = launch(UI) {
        showProgressVisible(true)
        val auth = BasicAuthorization(login, pass)
        try {
            val userInfo = async(parent = job) { apiClient.login(auth) }.await()
            val repoUrl = userInfo.repos_url
            val list = async(parent = job) { apiClient.getRepositories(reposUrl, auth) }.await()
            showRepositories(this, list.map { it -> it.fullName })
        } catch (e: RuntimeException) {
            Toast.makeText(this, e.message, LENGTH_LONG).show()
        } finally {
            showProgressVisible(false)
        }
    }
}

代码看起来好像只是添加了 async(parent = job) {} 的调用而已。其余的部分保持不变。

在这里传递父对象是必须的,这是为了能在 onStop() 中取消 job 的同时取消协程运行。

另外,我们可以用一种更奇幻的方式测试我们的 presenter

@Test
fun testLogin() = runBlocking {
    val apiClient = mockk<SuspendingApiClient.SuspendingApiClientImpl>()
    val githubUser = GithubUser("login", 1, "url", "name")
    val repositories = GithubRepository(1, "repos_name", "full_repos_name")

    coEvery { apiClient.login(any()) } returns githubUser
    coEvery { apiClient.getRepositories(any(), any()) } returns repositories.asList()

    val loginPresenterImpl = SuspendingLoginPresenterImpl(apiClient, CommonPool)
    runBlocking {
        val repos = loginPresenterImpl.doLogin("login", "password")
        assertNotNull(repos)
    }
}

在第 7 行,我们使用 suspend 修饰符 mock 我们的函数,以立即返回业务对象。

对于那些使用 Mockito 的朋友来说, mock 一个挂起函数的代码是这样的:

given {
    runBlocking {
        apiClient.login(any())
    }
}.willReturn (githubUser)

相比 mockk 还是有点丑陋的,不过效果一样。在这里使用 runBlocking 是作为一个协程构建器,它能阻塞协同程序运行的所在线程。在这里查看更多

概要

好吧,在这里我们设法重构一些使用了 Singles 的代码,替换为 Kotlin 协程并从中感受到一些好处。在此系列的下一章节中,我们将考虑使用协程来处理比 RxJava 更高级的一些主题。

如果您喜欢这篇文章,请在 推特 上和我打个招呼吧。关于 Kotlin/Android 的更多通知和一些其他想法都在那里了。

三、其他

利用谷歌翻译总算翻译完了,不知道这个文章的代码你是否 get 到了呢? :smile:

我的博客地址: http://liuqingwen.me ,欢迎关注我的微信公众号:

weixin.qq.com/r/PB3M1BD (二维码自动识别)

发布于 2018-09-02

文章被以下专栏收录

    掘金翻译计划,可能是世界最大最好的英译中技术社区,最懂读者和译者的翻译平台。内容覆盖区块链、人工智能、Android、iOS、前端、后端、设计、产品和其他 等领域,以及各大型优质官方文档及手册,读者为热爱新技术的新锐开发者。期待你的加入 https://github.com/xitu/gold-miner