[译] Kotlin 协程高级使用技巧

学习一些障碍以及如何绕过它们

协程从 1.3 开始成为稳定版!前端

开始 Kotlin 协程很是简单:只需将一些耗时操做放在 launch 中便可,你作到了,对不?固然,这是针对简单的状况。但很快,并发与并行的复杂性会慢慢堆积起来。android

当你深刻研究协程时,如下是一些你须要知道的事情。ios

取消 + 阻塞操做 = 😈

没有办法绕过它:在某些时候,你不得不用原生 Java 流。这里的问题(不少状况下 😉)是使用流将会堵塞当前线程。这在协程中是一个坏消息。如今,若是你想要取消一个协程,在可以继续执行以前,你不得不等待读写操做完成。git

做为一个简单可重复的例子,让咱们打开 ServerSocket 而且等待 1 秒的超时链接:github

runBlocking(Dispatchers.IO) {
    withTimeout(1000) {
        val socket = ServerSocket(42)

         // 咱们将卡在这里直到有人接收该链接。难道你不想知道为何吗?😜
        socket.accept()
    }
}
复制代码

应该能够运行,对吗?不。后端

如今你的感觉有点像:😖。 那么咱们如何解决呢?bash

Closeable APIs 构建良好时,它们支持从任何线程关闭流并适当地失败。并发

注意:一般状况下,JDK 中的 APIs 遵循了这些最佳实践,但需注意第三方 Closeable APIs 可能并无遵循。 你被提醒过了。app

幸好 suspendCancellableCoroutine 函数,当一个协程被取消时咱们能够关闭任何流:socket

public suspend inline fun <T : Closeable?, R> T.useCancellably(
        crossinline block: (T) -> R
): R = suspendCancellableCoroutine { cont ->
    cont.invokeOnCancellation { this?.close() }
    cont.resume(use(block))
}
复制代码

确保这适用于你正在使用的 API !

如今阻塞的 accept 调用被 useCancellably 包裹,该协程会在超时触发的时候失败。

runBlocking(Dispatchers.IO) {
    withTimeout(1000) {
        val socket = ServerSocket(42)

        // 抛出 `SocketException: socket closed` 异常。好极了!
        socket.useCancellably { it.accept() }
    }
}
复制代码

成功!

若是你不支持取消怎么办?如下是你须要注意的事项:

  • 若是你使用协程封装类中的任何属性或方法,即便取消了协程也会存在泄漏。若是你认为你正在 onDestroy 中清理资源,这尤为重要。解决方法: 将协同程序移动到 ViewModel 或其余上下文无关的类中并订阅它的处理结果。
  • 确保使用 Dispatchers.IO 来处理阻塞操做,由于这可让 Kotlin 留出一些线程来进行无限等待。
  • 尽量使用 suspendCancellableCoroutine 替换 suspendCoroutine

launch vs. async

因为上面关于这两个特性的回答已通过时,我想我会再次分析它们的差别。

launch 异常冒泡

当一个协程崩溃时,它的父节点将被取消,从而取消全部父节点的子节点。一旦整个树节点中的协程完成取消操做,异常将会发送到当前上线文的异常处理程序。在 Android 中,这意味着 你的 程序将会 崩溃,而无论你使用什么来进行调度。

async 持有本身的异常

这意味着 await() 显式处理全部异常,安装 CoroutineExceptionHandler 将无任何效果。

launch “blocks” 父做用域

虽然该函数会当即返回,但其父做用域将 不会 结束,直到使用 launch 构建的全部协程以某种方式完成。所以若是你只是想等待全部协程完成,在父做用域末尾调用全部子做业的 join() 就没有必要了。

与你指望的可能不一样,即便未调用 await(),外部做用域仍将等待async协程完成。

async 返回值

这一部分至关简单:若是你须要协程的返回值,async 是惟一的选择。若是你不须要返回值,使用 launch 来建立反作用。而且在继续执行以前须要完成这些反作用才须要使用 join()

join() vs. await()

join()await()不会 从新抛出异常。但若是发生错误,join() 会取消你的协程,这意味着在 join() 挂起后调用任何代码都不会起做用。

记录异常

如今你了解了你所使用不一样构造器异常处理机制的差别,你会陷入两难境地:你想记录异常而不崩溃(因此咱们不能使用 launch),可是你不想手动调用 try/catch (因此咱们不能使用 async)。因此这让咱们无所适从?谢天谢地。

记录异常是 CoroutineExceptionHandler 派上用场的地方。但首先,让咱们花点时间了解在协程中抛出异常时究竟发生了什么:

  1. 捕获异常,而后经过 Continuation 恢复。
  2. 若是你的代码没有处理异常而且该异常不是 CancellationException,那么将经过当前的 CoroutineContext 请求第一个 CoroutineExceptionHandler
  3. 若是未找处处理程序或处理程序有错误,那么异常将发送到平台中的特定代码。
  4. 在 JVM 上,ServiceLoader 用于定位全局处理程序。
  5. 一旦调用了全部处理程序或有一个处理程序出现错误,就会调用当前线程的异常处理程序。
  6. 若是当前线程没有处理该异常,它会冒泡到线程组并最终到达默认异常处理程序。
  7. 崩溃!

考虑到这一点,咱们有如下几个选择:

  • 为每一个线程安装一个处理程序,但这是不现实的。
  • 安装默认处理程序,但主线程中的错误不会让你的应用崩溃,而且你将处于潜在的不良状态。
  • 将处理程序添加为服务 当使用 launch 的任何协程崩溃时都会调用它(hacky)。
  • 使用你本身的自定义域与附加的处理程序来替换 GlobalScope,或将处理程序添加到你使用的每一个做用域,但这很烦人并使日志记录由默认变成了可选。

最后一个方案是所推荐的,由于它具备灵活性而且须要最少的代码和技巧。

对于应用程序范围内的做业,你将使用带有日志记录处理程序的 AppScope。对于其余业务,你能够在日志记录崩溃的适当位置添加处理程序。

val LoggingExceptionHandler = CoroutineExceptionHandler { _, t ->
    Crashlytics.logException(t)
}
val AppScope = GlobalScope + LoggingExceptionHandler
复制代码
class ViewModelBase : ViewModel(), CoroutineScope {
    override val coroutineContext = Job() + LoggingExceptionHandler

    override fun onCleared() = coroutineContext.cancel()
}
复制代码

不是很糟糕

最后的思考

任什么时候候咱们必须处理边缘状况,事情每每会很快变得混乱。我但愿这篇文章可以帮助你了解在非标准条件下可能遇到的各类问题,以及你可使用的解决方案。

Happy Kotlining!

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索