使用 Kotlin 协程提升应用性能

Kotlin 协程使您能够编写简洁、简化的异步代码,在管理网络调用或磁盘操作等长时间运行的任务时,能让您的应用保持响应。

本主题详细介绍了 Android 上的协程。如果您不熟悉协程,请务必先阅读Android 上的 Kotlin 协程,然后再阅读本主题。

管理长时间运行的任务

协程通过添加两个操作来处理长时间运行的任务,从而在常规函数的基础上进行了扩展。除了 invoke(或 call)和 return 之外,协程还添加了 suspendresume

  • suspend 会暂停当前协程的执行,并保存所有局部变量。
  • resume 从暂停的地方继续执行被挂起的协程。

您只能从其他 suspend 函数中调用 suspend 函数,或者使用协程构建器(例如 launch)启动新协程。

以下示例展示了针对假设的长时间运行任务的简单协程实现

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) { /* ... */ }

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

Kotlin 使用栈帧来管理正在运行的函数以及任何局部变量。当挂起协程时,当前的栈帧会被复制并保存以备后用。恢复时,栈帧会从保存的地方复制回来,函数再次开始运行。即使代码看起来像普通的顺序阻塞请求,协程也能确保网络请求不会阻塞主线程。

使用协程实现主线程安全

Kotlin 协程使用调度程序来确定哪些线程用于协程执行。要在主线程之外运行代码,您可以指示 Kotlin 协程在 DefaultIO 调度程序上执行工作。在 Kotlin 中,所有协程都必须在调度程序中运行,即使它们在主线程上运行也是如此。协程可以自行挂起,而调度程序负责恢复它们。

为了指定协程应该在哪里运行,Kotlin 提供了三个可供使用的调度程序

  • Dispatchers.Main - 使用此调度程序在 Android 主线程上运行协程。此调度程序只能用于与界面交互和执行轻量级工作。例如:调用 suspend 函数、运行 Android 界面框架操作以及更新 LiveData 对象。
  • Dispatchers.IO - 此调度程序专为在主线程之外执行磁盘或网络 I/O 而优化。例如:使用 Room 组件、读写文件以及运行任何网络操作。
  • Dispatchers.Default - 此调度程序专为在主线程之外执行占用大量 CPU 资源的工作而优化。例如:对列表排序和解析 JSON。

继续上一个示例,您可以使用调度程序重新定义 get 函数。在 get 的主体内,调用 withContext(Dispatchers.IO) 来创建一个在 IO 线程池上运行的代码块。您放入该代码块中的任何代码始终通过 IO 调度程序执行。由于 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)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

通过协程,您可以精细地控制线程调度。由于 withContext() 让您无需引入回调即可控制任何代码行的线程池,因此您可以将其应用于非常小的函数,例如从数据库读取数据或执行网络请求。一种好的做法是使用 withContext() 来确保每个函数都是主线程安全 (main-safe) 的,这意味着您可以从主线程调用该函数。这样,调用者永远不需要考虑应该使用哪个线程来执行该函数。

在之前的示例中,fetchDocs() 在主线程上执行;但是,它可以安全地调用 get,后者在后台执行网络请求。由于协程支持 suspendresume,当 withContext 代码块完成时,主线程上的协程会立即恢复并得到 get 的结果。

withContext() 的性能

withContext() 与等效的基于回调的实现相比,不会增加额外的开销。此外,在某些情况下,可以对 withContext() 调用进行优化,使其优于等效的基于回调的实现。例如,如果函数进行了十次网络调用,您可以通过使用外部 withContext() 告诉 Kotlin 只切换一次线程。这样,即使网络库多次使用 withContext(),它也会保持在同一个调度程序上,从而避免了线程切换。此外,Kotlin 会优化 Dispatchers.DefaultDispatchers.IO 之间的切换,以尽可能避免线程切换。

启动协程

您可以通过以下两种方式之一启动协程

  • launch 启动一个新协程且不向调用者返回结果。任何被视为“发射并忘记 (fire and forget)”的工作都可以使用 launch 启动。
  • async 启动一个新协程,并允许您通过名为 await 的挂起函数返回结果。

通常,您应该从常规函数 launch 一个新协程,因为常规函数无法调用 await。仅在另一个协程内部,或者在挂起函数内部执行并行分解时,才使用 async

并行分解

所有在 suspend 函数内部启动的协程都必须在该函数返回时停止,因此您可能需要确保这些协程在返回之前完成。利用 Kotlin 中的结构化并发,您可以定义一个启动一个或多个协程的 coroutineScope。然后,使用 await()(针对单个协程)或 awaitAll()(针对多个协程),您可以确保这些协程在函数返回之前完成。

作为一个示例,让我们定义一个异步获取两个文档的 coroutineScope。通过在每个延迟引用上调用 await(),我们保证了两个 async 操作在返回结果之前都会完成

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

您也可以在集合上使用 awaitAll(),如下例所示

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf(     // fetch two docs at the same time
            async { fetchDoc(1) },  // async returns a result for the first doc
            async { fetchDoc(2) }   // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    }

尽管 fetchTwoDocs() 使用 async 启动了新协程,但该函数使用 awaitAll() 等待这些已启动的协程完成后才返回。但请注意,即使我们没有调用 awaitAll()coroutineScope 构建器也不会恢复调用 fetchTwoDocs 的协程,直到所有新协程都完成为止。

此外,coroutineScope 会捕获协程抛出的任何异常,并将它们路由回调用者。

有关并行分解的详细信息,请参阅组合挂起函数

协程概念

CoroutineScope

CoroutineScope 会跟踪其使用 launchasync 创建的任何协程。可以通过随时调用 scope.cancel() 来取消正在进行的工作(即正在运行的协程)。在 Android 中,一些 KTX 库为特定的生命周期类提供了它们自己的 CoroutineScope。例如,ViewModel 具有 viewModelScope,而 Lifecycle 具有 lifecycleScope。但是,与调度程序不同,CoroutineScope 不会运行协程。

viewModelScope 也用于Android 上的后台线程:使用协程中的示例。但是,如果您需要创建自己的 CoroutineScope 来控制应用特定层中协程的生命周期,可以按如下方式创建一个

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

已取消的范围无法创建更多协程。因此,您应该仅在控制其生命周期的类被销毁时调用 scope.cancel()。当使用 viewModelScope 时,ViewModel 类会在 ViewModel 的 onCleared() 方法中自动为您取消该范围。

作业 (Job)

Job 是协程的句柄。您使用 launchasync 创建的每个协程都会返回一个 Job 实例,该实例唯一标识协程并管理其生命周期。您还可以将 Job 传递给 CoroutineScope 以进一步管理其生命周期,如下例所示

class ExampleClass {
    ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

CoroutineContext 使用以下一组元素定义协程的行为

对于在作用域内创建的新协程,会为新协程分配一个新的 Job 实例,其他 CoroutineContext 元素则从包含该协程的作用域继承而来。您可以通过向 launchasync 函数传递新的 CoroutineContext 来覆盖继承的元素。请注意,将 Job 传递给 launchasync 没有效果,因为总是会为新协程分配一个新的 Job 实例。

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

更多协程资源

更多协程资源,请参考以下链接