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

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

在 Android 上,协程可用于帮助解决两个主要问题: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 协程的架构组件

更多信息

有关协做程序的更多信息,请参见如下连接:

相关文章
相关标签/搜索