[译] 使用 Kotlin 协程改进应用性能

3,237 阅读10分钟

协程是一种并发设计模式,你可以在 Android 上使用它来简化异步代码。协程是在 Kotlin 1.3 时正式发布的,它吸收了一些其他语言已经成熟的经验。

在 Android 上,协程可用于帮助解决两个主要问题:

  • 管理耗时任务,防止它们阻塞主线程
  • 提供主线程安全,或从主线程安全地调用网络或磁盘操作

本主题描述如何使用 Kotlin 协程来解决这些问题,让你能够写出更清晰、更简洁的代码。

管理耗时任务

在 Android 上,每个应用都有一个主线程来处理用户界面和管理用户交互。如果你的应用给主线程分配了太多工作,应用可能会变得很卡。网络请求、JSON 解析、读写数据库,甚至只是遍历大型列表,都可能导致应用运行的足够慢,从而导致可见的延迟或直接卡住。这些耗时任务都应该放在主线程之外运行。

下面的例子显示了一个虚构的耗时任务的简单协程实现:

suspend fun fetchDocs() {                             // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result)                                      // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

协程通过在常规函数的基础上,添加两个操作符来处理长时间运行的任务。除了调用(invoke or call) 和 返回(return),协程还添加了挂起 (suspend) 和恢复 (resume):

  • suspend 挂起当前协程,保存本地变量
  • resume 让从一个挂起协程从挂起点恢复执行

你只能从另外一个挂起函数里调用挂起函数,或者使用协程构建器例如 launch 来启动一个新的协程。

在上面的例子中,get() 仍然在主线程运行,但是它会在启动网络请求之前挂起协程。当网络请求完成时,get() 恢复挂起的协程,而不是使用回调来通知主线程。

Kotlin 使用堆栈来管理哪个函数和哪个局部变量一起运行。挂起协程时,将复制当前堆栈帧并保存。当恢复时,堆栈帧将从保存它的位置复制回来,函数将重新开始允许。即使代码看起来像顺序执行的代码会阻塞请求,协程也能确保网络请求不在主线程上。

使用协程确保主线程安全

Kotlin 协程使用调度器来确定哪些线程用于协程执行。要在主线程之外运行代码,可以告诉 Kotlin 协程在 Default 调度器或 IO 调度器上执行工作。在 Kotlin 中,所有协程都必须在调度器中运行,即使它们在主线程上运行。协程可用挂起它们自己,而调度器负责恢复它们。

要指定协程应该运行在哪里,Kotlin 提供了三个调度器给你使用:

  • Dispatchers.Main 使用这个调度器在 Android 主线程上运行一个协程。这应该只用于与 UI 交互和一些快速工作。示例包括调用挂起函数、运行 Android UI 框架操作和更新 LiveData 对象。
  • Dispatchers.IO 这个调度器被优化在主线程之外执行磁盘或网络 I/O。例如包括使用 Room 组件、读写文件,以及任何网络操作。
  • Dispatchers.Default 这个调度器经过优化,可以在主线程之外执行 cpu 密集型的工作。例如对列表进行排序和解析 JSON。

继续前面的示例,你可以使用调度器重新定义 get()函数。在get()的主体中,调用 withContext(Dispactchers.IO) 创建一个运行在 IO 线程池上的代码块。在这个代码块中的任何代码都将通过 I/O 调度器执行。因为withContext 本身是一个挂起函数,所以 get() 也是一个挂起函数。

suspend fun fetchDocs() {                      // Dispatchers.Main
    val result = get("developer.android.com")  // Dispatchers.Main
    show(result)                               // Dispatchers.Main
}

suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* 在这里执行网络请求 */                  // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

使用协程,你可以更细化的来分派线程。因为withContext() 允许你控制任何一行代码的线程池,而不需要引入回调,所以你可以将它应用于非常小的函数,比如从数据库读取数据或执行网络请求。一个好的实践是使用withContext() 来确保每个函数的调用都是主线程安全的,这意味着可以从主线程安全调用该函数。这样调用者就不需要考虑应该使用哪个线程来执行函数。

在前面的例子中,fetchDocs() 在主线程上执行;但是,它可以安全地调用get()get() 在后台执行网络请求。因为协程支持挂起和恢复,所以一旦withContext()块完成,主线程上的协程就会带着 get()的返回值恢复。

重要提示:使用 suspend 不会告诉 Kotlin 在后台线程上运行函数。挂起函数在主线程上操作是正常的。在主线程上启动协程也是很常见的。当遇到需要保护主线程安全时,例如读写磁盘、执行网络操作或运行 cpu 密集型操作时,应该始终在挂起函数中使用 withContext()

withContext() 的性能

与等价的基于回调的实现相比,withContext()不会增加额外的开销。此外,在某些情况下,基于回调的实现,witchContext 的调用还可以优化。例如,如果一个函数对一个网络进行了 10 次调用,你可以在外面通过使用 withContext() 告诉 Kotlin 只切换一次线程。然后,即使网络库多次使用 withContext(),它仍然保持在同一个调度器上,并且避免切换线程。此外 Kotlin 还优化了调度器之间的切换。在 Defalut 和 I/O 调度器之间尽可能的避免线程切换。

重要提示:像线程池一样使用 I/O 和 Default 调度器不会保证代码块里面从上到下的代码在同一线程上执行。在某些情况下,Kotlin 协程可能会在挂起并恢复之后将执行移动到另一个线程。这意味着在 withContext() 代码块中,线程局部变量可能不会总是相同。

指定作用域

在定义协程时,必须指定它的协程作用域。协程作用域管理一个或多个相关的协程。你还可以使用指定的协程作用域在它的作用域内启动新的协程。但是,协程作用域和调度器不一样,它不负责运行协程。

协程作用域的一个主要功能是当用户离开应用中的内容区域时停止协程的执行。使用协程作用域,可以确保任何正在运行的操作都正确的停止。

Android 架构组件上配合协程作用域

在 Android 上,你可以将协程作用域与组件生命周期关联。这使你可以避免内存泄露或为用户不在相关的 Activity 或 Fragment 做额外的工作。在使用 Jetpack 组件时,它们和 ViewModel 很适合。因为 ViewModel 在配置更改(比如旋转屏幕)期间不会被销毁,所以你不必担心协程被取消或重新启动。

作用域会记住它们启动的每个协程。这意味着你可以随时取消作用域中启动的所有东西。作用域还会自行传递,因此如果一个协程启动另一个协程,两个协程具有相同的作用域。这意味着即使其他库从你的作用域启动了一个协程,你也可以随时取消它们。如果在 ViewModel 中运行协程,这一点尤其重要。如果 ViewModel 因为用户离开界面而被销毁,则必须停止它正在执行的所有异步工作。否则,你将浪费系统资源并可能造成内存泄露。如果在销毁 ViewModel 之后还有异步工作需要继续,那么应该在你的应用架构底层完成。

警告:协程通过抛出 CancellationException 来取消协程。异常捕获会在协程取消时被触发。

使用 Android 架构体系组件的 ktx 库时,你还可以使用一个扩展属性 viewModelScope 来创建协程,这些创建出的协程可以一直运行到 ViewModel 被销毁时。

开启一个协程

你可以通过以下两种方式启动协程:

  • launch 启动一个新的协程,但不会将结果返回给调用者。任何被认为是"发射后不管(fire and forget)"的工作都可以使用 launch 启动。
  • async 启动一个新的协程,并允许你调用 await 返回挂起函数的结果。

通常,你在常规函数应该用 launch 启动一个新的协程,因为常规函数不能调用 await 。仅当在另一个协程中或在挂起函数中执行「并行分解」时才使用 async 的方式。

基于前面的例子,这里有一个带有 viewModelScope 的 ktx 扩展属性的协程,它使用 luanch 将常规函数切换到协程:

fun onDocsNeeded() {
    viewModelScope.launch {    // Dispatchers.Main
        fetchDocs()            // Dispatchers.Main (suspend function call)
    }
}

警告:launchasync 处理异常的方式不同。由于 async 期望在 await 时被最终调用,所以它的异常会保留到 await 被调用的时候重新抛出。这意味着,如果你使用 await 从常规函数启动一个新的协程,你可能会悄悄的"抛出”一个异常(这个“抛出”的异常不会出现在你的异常监控里,也不会在 logcat 中被发现)。

并行分解

由挂起函数启动的所有协程,必须在该函数返回时已经停止,因此你可能需要确保这些协程在返回前已经做完工作。使用 Kotlin 中的结构化并发,你可以定义一个启动一或多个协程的协程作用域。然后,使用 await() (针对单个协程)或 awaitAll() (针对多个协程),用来确保这些协程在函数返回之前完成。

例如,让我们定义会异步获取两个文档的协程作用域。通过在每个 deferred 引用上调用 await() ,我们保证异步操作都在返回值返回之前完成。

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

你还可以对集合使用 awaitAll() ,如下面的示例所示:

suspend fun fetchTwoDocs() =        // 在任何调度器上调用(任何线程包括主线程)
    coroutineScope {
        val deferreds = listOf(     // 同时获取两个文档
            async { fetchDoc(1) },  // 异步返回第一个文档
            async { fetchDoc(2) }   // 异步返回第二个文档
        )
        deferreds.awaitAll()        // 使用 awaitAll 等待两个网络请求返回
    }

即使 fetchTwoDocs() 使用 async 启动新的协程,这个函数仍然使用 awaitAll() 来等待哪些启动的协程完成后返回。但是,请注意,即使我们没有调用awaitAll(),协程作用域构建器也不会在所有协程都完成之前恢复调用 fetchTwoDocs 的协程。

此外,协程作用域捕获的任何异常,会通过它们返回指定的调用者。

有关并行分解的更多信息,请参见组合挂起函数.。

内置协程支持的架构组件

一些架构组件,包括 ViewModelLifeCycle ,包含了内置的协程作用域成员。

例如,ViewModel 包含了一个内置的 viewModelScope。这提供了在 ViewModel 范围内启动协程的标准方法,如下所示:

class MyViewModel : ViewModel() {

    fun launchDataLoad() {
        viewModelScope.launch {
            sortList()
            // 修改 UI
        }
    }

    /**
    * 不能在主线程执行的重量型操作
    */
    suspend fun sortList() = withContext(Dispatchers.Default) {
        // 大量操作
    }
}

LiveData 同样使用 liveData 块来使用协程:

liveData {
    // 运行在自己的特定于 LiveData 的范围内
}

有关架构组件中内置的协程支持的更多信息,请参见使用 Kotlin 协程的架构组件

更多信息

有关协作程序的更多信息,请参见以下链接: