[译] Coroutines on Android(一)背景知识
本系列文章主要翻译自 medium-AndroidDevelopers.
本系列文章主要关注 Kotlin Coroutine(协程) 是如何工作的,以及如何解决实际的 Android 问题。但是相对来说不会过于深入底层,更偏向于应用。
协程解决了什么?
Kotlin 协程提供了一个全新的,更加简单地方式来实现异步。协程在 Kotlin 1.3 中正式发布,API 已经稳定,可以用于生产环境。事实上,协程的概念始终存在,最早是 Simula 语言在1967年探索了协程的使用。
在过去的几年,协程正逐步流行开来,众多的语言,例如 js, c#, python, ruby, go 都有所支持。Kotlin 协程基于这些基础,可以用于构建大型应用程序。
在 Android 上,协程可以优雅地解决下面两个问题:
- 在主线程运行长时间任务导致阻塞。
- 从主线程上安全地调用一切
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(返回) 外,还添加了 suspend 和 resume。
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 协程在 Default 或 IO 调度器(dispatcher) 上来运行。在 Kotlin 中,一切协程必须由调度器执行,即使那些在主线程执行的也是如此。协程可以挂起(suspend)自己,而调度器知道如何恢复(resume)它们。
为了指定协程在哪个线程运行,Kotlin 默认提供了三个常用调度器供选择:
对应 Android 上的主线程,负责处理 UI,适合执行轻量工作。它可以:
- 调用 suspend 函数
- 更新 UI
- 更新 LiveData
一个子线程,用于处理本地文件或网络请求,它适合于:
- 数据库操作<sup>[1]</sup>
- 文件读写
- 网络请求<sup>[2]</sup>
一个子线程,用于处理其他繁重的任务。例如:
- 数据排序
- 解析 JSON
- 差异比对(例如
DiffUtils)
[1] Room 会默认提供主线程安全,这里可以参考。RxJava 与 LiveData 也是一样。
[2] 网络请求库,例如 Retrofit 与 Volley 管理着他们自己的线程,当与协程一起使用时,无需显式地保证主线程安全。
继续上面的例子,现在给 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.Default 与 Dispatchers.IO 之间会自动进行优化,以免进行不必要的线程切换。
总结
这一节中,我们探索了协程适合解决哪些问题。协程是一个非常古老的概念,但因能简化网络请求之类的代码,近期又变得流行。
在 Android 中,可以利用它来解决两个非常普遍的问题:
- 简化长时间运行的代码,例如网络请求、文件读写、解析 JSON。
- 提供主线程安全,确保不会意外阻塞主线程并保持代码简洁易懂。
下一章我们会探索如何适配 Android,让协程与 UI 组件生命周期保持一致!