本系列文章主要关注 Kotlin Coroutine(协程) 是如何工作的,以及如何解决实际的 Android 问题。但是相对来说不会过于深入底层,更偏向于应用。

协程解决了什么?

Kotlin 协程提供了一个全新的,更加简单地方式来实现异步。协程在 Kotlin 1.3 中正式发布,API 已经稳定,可以用于生产环境。事实上,协程的概念始终存在,最早是 Simula 语言在1967年探索了协程的使用。

在过去的几年,协程正逐步流行开来,众多的语言,例如 js, c#, python, ruby, go 都有所支持。Kotlin 协程基于这些基础,可以用于构建大型应用程序。

在 Android 上,协程可以优雅地解决下面两个问题:

  1. 在主线程运行长时间任务导致阻塞。
  2. 从主线程上安全地调用一切 suspend 函数。也即主线程安全(Main-safety)
主线程安全指的是:调用者不必关心这个函数应当在那个线程(调度器)调用,总能“同步”取得正确的返回值且不会阻塞。

下面从这两个角度入手,深入看看协程任何高效整洁地解决这些问题。

长时间运行的任务

调用 API 等行为会涉及到网络请求。读取数据库或加载配置文件涉及到文件读写。这类工作就是所谓的长时间任务,因为相对来说他们花费的时间很长,应用必须停下来等待他们。

在 Android 每个应用都有一个主线程来处理 UI 与交互。如果在这个线程执行了太繁重的工作,应用就会变卡,导致非常糟糕的用户体验。任何长时间运行的任务都不应该阻塞主线程,这样应用才能流畅运行,避免掉帧或未响应的出现。

为了在子线程执行网络请求,最常见的模式是使用回调(Callback)。回调提供了一个引用,它可以在在未来的某个时间通知代码继续运行,使用回调,代码可能是这样的:

class ViewModel: ViewModel() {
   fun fetchDocs() {
       get("developer.android.com") { result ->
           show(result)
       }
    }
}

尽管 get 函数在主线程被调用,但他会在另一个线程执行网络请求。然后一旦得到相应,回调函数就会在主线程执行。这是一个很棒的模式来出来长时间运行的任务。Retorfit 就是一个非常著名的网络请求库帮助你完成这些工作。

使用协程来解决

协程可以大幅简化上面的代码,现在我们用协程来重写上面的功能:

// Dispatchers.Main
suspend fun fetchDocs() {
    // Dispatchers.IO
    val result = get("developer.android.com")
    // Dispatchers.Main
    show(result)
}
// 在下个章节会讲到
suspend fun get(url: String) = withContext(Dispatchers.IO){/*...*/}

这里似乎没有启动线程也没有回调,它会不会阻塞主线程?它如何在不阻塞不等待网络响应的情况下返回结果的?事实上,Kotlin 协程就是提供了一种方案,可以在不阻塞的情况下正确运行上面代码。

协程依赖于在一个普通的函数上添加两个操作,除了常规的 invoke(调用) 和 return(返回) 外,还添加了 suspendresume

  • suspend:暂停当前协程的执行,保存所有的临时变量。
  • resume:从暂停的位置继续执行被挂起的协程。

上述操作只需要给函数添加 suspend 关键字就可以实现。Suspend 函数只能在 Suspend 函数中调用,或者通过一个协程构建器(例如 launch)来启动一个新的协程。

协程利用暂停与恢复操作来代替回调

Suspend and resume work together to replace callbacks.

在上面的例子中,get 将在启动网络请求之前挂起协程,但它依然会在主线程之外继续执行。当得到响应后,可以简单地恢复协程继续运行下面的代码,而不是通知回调。

这个动画演示了如何利用挂起与恢复代替回调

通过 fetchDocs 的执行过程,可以看到 suspend 函数是如何工作的。当协程被挂起时,当前的堆栈(Kotlin 用于跟踪函数执行进度与保持临时变量的地方)将被复制并保持,以备后用。当恢复时,堆栈被复制回来并继续运行。正如动画中间所示,当所有主线程上的协程被挂起时,可以继续执行其他任务(例如 UI 渲染与输入事件响应,很完美!

当所有主线程的协程被挂起,主线程可以自由地执行其他工作

这样,尽管这些代码看起来是顺序执行的,网络请求会阻塞线程。事实上协程会按照我们理想的方式运行而不不会阻塞主线程!

下面,来研究下如何实现主线程安全,并了解一下调度器(dispatchers)。

协程的主线程安全

在 Kotlin 协程中,一个优秀的 suspend 函数应当是可以安全地被从主线程调用。不管内部做了什么,都应该允许任意线程调用他们。

但是在 Android 中,很多操作都太慢了不适合放在主线程,例如:网络请求、JSON 解析、读写数据库,或者仅仅是处理一个大型列表。这些都有可能造成应用掉帧或未响应,应该放在主线程之外。

使用 suspend 不会告诉 Kotlin 另起一个线程来执行这个函数。 明确的说,协程依然在主线程运行。当需要操作 UI 时,通过 Dispatchers.Main.immediate 来启动协程是一个很好的办法。

协程可能会运行在主线程,suspend 不意味着子线程。

Coroutines will run on the main thread, and suspend does not mean background.

如果要让一个太过繁重而不适合在主线程运行的函数做到「主线程安全」,可以要求 Kotlin 协程在 DefaultIO 调度器(dispatcher) 上来运行。在 Kotlin 中,一切协程必须由调度器执行,即使那些在主线程执行的也是如此。协程可以挂起(suspend)自己,而调度器知道如何恢复(resume)它们。

为了指定协程在哪个线程运行,Kotlin 默认提供了三个常用调度器供选择:

Dispatchers.Main

对应 Android 上的主线程,负责处理 UI,适合执行轻量工作。它可以:

  • 调用 suspend 函数
  • 更新 UI
  • 更新 LiveData

Dispatchers.IO

一个子线程,用于处理本地文件或网络请求,它适合于:

  • 数据库操作[1]
  • 文件读写
  • 网络请求[2]

Dispatchers.Default

一个子线程,用于处理其他繁重的任务。例如:

  • 数据排序
  • 解析 JSON
  • 差异比对(例如 DiffUtils

[1] Room 会默认提供主线程安全,这里可以参考。RxJavaLiveData 也是一样。

[2] 网络请求库,例如 RetrofitVolley 管理着他们自己的线程,当与协程一起使用时,无需显式地保证主线程安全。

继续上面的例子,现在给 get 函数指明一个调度器。在 get 函数内部调用 withContext(Dispatchers.IO) 来创建一个运行在 IO 调度器中的代码块。代码块中的任何代码一定会在 IO 调度器中被执行。由于 withContext本身是一个suspend 函数,它将会依靠协程来提供主线程安全。

// Dispatchers.Main
suspend fun fetchDocs() {
    // Dispatchers.Main
    val result = get("developer.android.com")
    // Dispatchers.Main
    show(result)
}
// Dispatchers.Main
suspend fun get(url: String) =
    withContext(Dispatchers.IO) {
        // Dispatchers.IO
        /* perform blocking network IO here */
    }
    // Dispatchers.Main

协程可以提供细粒度的线程分配。因为 withContext 让你控制代码在哪个线程上执行而无需使用回调来取得结果。可以用于一些非常细粒度的函数,例如从数据库读取一个数据,或执行一次网络请求。因此,利用 withContext 来确保每一个函数在任意一个调度器被调用都是安全的,包括 Main。这样调用者不必关心应当使用哪个线程来执行。

在这个例子中,fetchDocs 在主线程执行,但是可以安全地调用一个在子线程进行网络请求的函数 get,因为协程支持挂起和恢复,只有当 withContext 中的代码执行完毕时,主线程才会自动恢复继续执行。期间不会造成阻塞。

优秀的 suspend 函数总是可以安全地从主线程调用

Well written suspend functions are always safe to call from the main thread (or main-safe).

我们应当确保每一个 suspend 函数都是主线程安全的。如果一段的代码进行了网络请求、文件操作,或仅仅是占用了太多的 CPU,使用 withContext 让他可以安全地从主线程调用。这就是基于协程的库所采取的模式。如果在整个项目中采取这种风格,代码会简单很多,且避免将线程问题与业务逻辑糅杂在一起。并且协程可以自由地从主线程启动,解决掉帧与未响应问题。

withContext 的性能

withContext 与回调或 RxJava 一样快,有时通过优化甚至更快。如果一个函数请求了10次数据库,你可以使 withContext 告诉 Kotlin 切换一次,内部执行10次操作。尽管数据库相关函数会反复调用 withContext 10次,但实际上只切换了1次并保持。除此之外,Dispatchers.DefaultDispatchers.IO 之间会自动进行优化,以免进行不必要的线程切换。

总结

这一节中,我们探索了协程适合解决哪些问题。协程是一个非常古老的概念,但因能简化网络请求之类的代码,近期又变得流行。

在 Android 中,可以利用它来解决两个非常普遍的问题:

  1. 简化长时间运行的代码,例如网络请求、文件读写、解析 JSON。
  2. 提供主线程安全,确保不会意外阻塞主线程并保持代码简洁易懂。

下一章我们会探索如何适配 Android,让协程与 UI 组件生命周期保持一致!

Last modification:July 1, 2019