Kotlin Coroutines(协程)讲解

8,081 阅读14分钟

前言

翻译好的文章也是一种学习方式

原文标题:Coroutines in Kotlin 1.3 explained: Suspending functions, contexts, builders and scopes

原文作者: Antonio Leiva

协程简介

协程是 Kotlin 的一大特色。使用协程,可以简化异步编程,使代码可读性更好、更容易理解。

使用协程,不同于传统的回调方式,可以使用同步的方式编写异步代码。同步方法返回的结果就是异步请求的结果。

协程到底有什么魔法?马上为您揭晓。在这之前,我们需要知道为什么协程这么重要。

Kotlin 1.1 中 协程作为实验特性,到现在 Kotlin 1.3 发布了最终的 API,协程已经可以用于生产环境中。

协程的目标:先看一下现存的一些问题

获取文中的完整示例点击 这里

假设要做一个登陆界面:用户输入用户名和密码,然后点击登陆。

假设是这样的流程:App 首先请求服务器校验用户名和密码,校验成功后,然后请求该用户的好友列表。

伪代码如下:

progress.visibility = View.VISIBLE

userService.doLoginAsync(username, password) { user ->

    userService.requestCurrentFriendsAsync(user) { friends ->

        val finalUser = user.copy(friends = friends)
        toast("User ${finalUser.name} has ${finalUser.friends.size} friends")

        progress.visibility = View.GONE
    }

}

步骤如下:

  1. 显示一个进度条;
  2. 请求服务器校验用户名和密码;
  3. 等待校验成功后,请求服务器获取好友列表;
  4. 最后,隐藏进度条;

情况还可以更复杂,想象一下,不仅要请求好友列表,还需要请求推荐好友列表,并把两次结果合并进一个列表。

有两种选择:

  1. 最简单的方式就是,在请求完好友列表之后,再请求推荐好友列表,但是这种方式不够高效,因为后者并不依赖前者的请求结果;
  2. 这种方式相对复杂一些,同时请求好友列表和推荐好友列表,并同步两次请求的结果;

通常情况下,想要偷懒的人可能会选择第一种方式:

progress.visibility = View.VISIBLE

userService.doLoginAsync(username, password) { user ->

    userService.requestCurrentFriendsAsync(user) { currentFriends ->

        userService.requestSuggestedFriendsAsync(user) { suggestedFriends ->
            val finalUser = user.copy(friends = currentFriends + suggestedFriends)
            toast("User ${finalUser.name} has ${finalUser.friends.size} friends")

            progress.visibility = View.GONE
        }

    }

}

到这里,代码开始变得复杂了,出现了可怕的回调地狱:后一个请求总是嵌套在前一个请求的结果回调里面,缩进变得越来越多。

由于使用的是 Kotlinlambdas,可能看起来并没有那么糟糕。但是随着请求的增多,代码变得越来越难以管理。

别忘了,我们使用的还是一种相对简单但并不高效的一种方式。

什么是协程(Coroutine

简单来说,协程像是轻量级的线程,但并不完全是线程。

首先,协程可以让你顺序地写异步代码,极大地降低了异步编程带来的负担;

其次,协程更加高效。多个协程可以共用一个线程。一个 App 可以运行的线程数是有限的,但是可以运行的协程数量几乎是无限的;

协程实现的基础是可中断的方法(suspending functions)。可中断的方法可以在任意的地方中断协程的执行,直到该可中断的方法返回结果或者执行完成。

运行在协程中的可中断的方法(通常情况下)不会阻塞当前线程,之所以是通常情况下,因为这取决于我们的使用方式。具体下面会讲到。

coroutine {
    progress.visibility = View.VISIBLE

    val user = suspended { userService.doLogin(username, password) }
    val currentFriends = suspended { userService.requestCurrentFriends(user) }

    val finalUser = user.copy(friends = currentFriends)
    toast("User ${finalUser.name} has ${finalUser.friends.size} friends")

    progress.visibility = View.GONE
}

上面的示例是协程的常用使用范式。首先,使用一个协程构造器(coroutine builder)创建一个协程,然后,一个或多个可中断的方法运行在协程中,这些方法将会中断协程的执行,直到它们返回结果。

可中断的方法返回结果后,我们在下一行代码就可以使用这些结果,非常像顺序编程。注意实际上 Kotlin 中并不存在 coroutinesuspended 这两个关键字,上述示例只是为了便于演示协程的使用范式。

可中断的方法(suspending functions

可中断的方法有能力中断协程的执行,当可中断的方法执行完毕后,接着就可以使用它们返回的结果。

val user = suspended { userService.doLogin(username, password) }
val currentFriends = suspended { userService.requestCurrentFriends(user) }

可中断的方法可以运行在相同的或不同的线程,这取决于你的使用方式。可中断的方法只能运行在协程中或其他可中断的方法中。

声明一个可中断的方法,只需要使用 suspend 保留字:

suspend fun suspendingFunction() : Int  {
    // Long running task
    return 0
}

回到最初的示例,你可能会问上述代码运行在哪个线程,我们先看这一行代码:

coroutine {
    progress.visibility = View.VISIBLE
    ...
}

你认为这行代码运行在哪个线程呢?你确定它是运行在 UI 线程吗?如果不是,App 就会崩溃,所以弄明白运行在哪个线程很重要。

答案就是这取决于协程上下文coroutine context)的设置。

协程上下文(Coroutine Context

协程上下文是一系列规则和配置的集合,它决定了协程的运行方式。也可以理解为,它包含了一系列的键值对。

现在,你只需要知道 dispatcher 是其中的一个配置,它可以指定协程运行在哪个线程。

dispatcher 有两种方式可以配置:

  1. 明确指定需要使用的 dispatcher;
  2. 由协程作用域(coroutine scope)决定。这里先不展开说,后面会详细说明;

具体来说,协程构造器(coroutine builder)接收一个协程上下文(coroutine context)作为第一个参数,我们可以传入要使用的 dispatcher。因为 dispatcher 实现了协程上下文,所以可以作为参数传入:

coroutine(Dispatchers.Main) {
    progress.visibility = View.VISIBLE
    ...
}

现在,改变进度条可见性的代码就运行在了 UI 线程。不仅如此,协程内的所有代码都运行在 UI 线程。那么问题来了,可中断的方法会怎么运行?

coroutine {
    ...
    val user = suspended { userService.doLogin(username, password) }
    val currentFriends = suspended { userService.requestCurrentFriends(user) }
    ...
}

这些请求服务的代码也是运行在主线程吗?如果真是这样的话,它们会阻塞主线程。到底是不是呢,还是那句话,这取决于你的使用方式。

可中断的方法有多种办法配置要使用的 dispatcher,其中最常用的方法是 withContext

withContext

在协程内部,这个方法可以轻易地改变代码运行时所在的上下文。它是一个可中断的方法,所以调用它会中断协程的执行,直到该方法执行完成。

这样以来,我们就可以让示例中那些可中断的方法运行在不同的线程中:

suspend fun suspendLogin(username: String, password: String) =
        withContext(Dispatchers.Main) {
            userService.doLogin(username, password)
        }

上面这些代码会运行在主线程,所以仍然会阻塞 UI 。但是,现在我们可以轻易地指定使用不同的 dispatcher:

suspend fun suspendLogin(username: String, password: String) =
        withContext(Dispatchers.IO) {
            userService.doLogin(username, password)
        }

现在我们使用了 IO dispatcher, 上述代码会运行在子线程。另外,withContext 本身就是一个可中断的方法,所以,我们没必要让它运行在另一个可中断方法中。所以我们也可以这样写:

val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
val currentFriends = withContext(Dispatchers.IO) { userService.requestCurrentFriends(user) }

目前为止,我们认识了两个 dispatcher,下面我们详细介绍一下所有的 dispatcher 的使用场景。

  • Default: 当我们未指定 dispatcher 的时候会默认使用,当然,我们也可以明确设置使用它。它一般用于 CPU 密集型的任务,特别是涉及到计算、算法的场景。它可以使用和 CPU 核数一样多的线程。正因为是密集型的任务,同时运行多个线程并没有意义,因为 CPU 将会很繁忙。

  • IO: 它用于输入/输出的场景。通常,涉及到会阻塞线程,需要等待另一个系统响应的任务,比如:网络请求、数据库操作、文件读写等,都可以使用它。因为它不使用 CPU ,可以同一时间运行多个线程,默认是数量为 64 的线程池。Android App 中有很多网络请求的操作,所以你可能会经常用到它。

  • UnConfined: 如果你不在乎启动了多少个线程,那么你可以使用它。它使用的线程是不可控制的,除非你特别清楚你在做什么,否则不建议使用它。

  • Main: 这是 UI 相关的协程库里面的一个 dispatcher,在 Android 编程中,它使用的是 UI 线程。

现在,你应该可以很灵活地使用各种 dispatcher 了。

协程构造器(Coroutine Builders

现在,你可以轻松地切换线程了。接下来,我们学习一下如何启动一个新的协程:当然要靠协程构造器了。

根据实际情况,我们可以选择使用不同的协程构造器,当然我们也可以创建自定义的协程构造器。不过通常情况下,协程库提供的已经满足我们的使用了。具体如下:

runBlocking

这个协程构造器会阻塞当前线程,直到协程内的所有任务执行完毕。这好像违背了我们使用协程的初衷,所以什么场景下会用到它呢?

runBlocking 对于测试可中断的方法非常有用。在测试的时候,将可中断的方法运行在 runBlocking 构建的协程内部,这样就可以保证,在这些可中断的方法返回结果前当前测试线程不会结束,这样,我们就可以校验测试结果了。

fun testSuspendingFunction() = runBlocking {
    val res = suspendingTask1()
    assertEquals(0, res)
}

但是,除了这个场景外,你也许不会用到 runBlocking 了。

launch

这个协程构造器很重要,因为它可以很轻易地创建一个协程,你可能会经常用到它。和 runBlocking 相反的是,它不会阻塞当前线程(前提是我们使用了合适的 dispatcher)。

这个协程构造器通常需要一个作用域(scope),关于作用域的概念后面会讲到,我们暂时使用全局作用域(GlobalScope):

GlobalScope.launch(Dispatchers.Main) {
    ...
}

launch 方法会返回一个 JobJob 继承了协程上下文(CoroutineContext)。

Job 提供了很多有用的方法。需要明确的是:一个 Job 可以有一个父 Job,父 Job 可以控制子 Job。下面介绍一下 Job 的方法:

job.join

这个方法可以中断与当前 Job 关联的协程,直到所有子 Job 执行完成。协程内的所有可中断的方法与当前 Job 相关联,直到子 Job 全部执行完成,与当前 Job 关联的协程才能继续执行。

val job = GlobalScope.launch(Dispatchers.Main) {

    doCoroutineTask()

    val res1 = suspendingTask1()
    val res2 = suspendingTask2()

    process(res1, res2)

}

job.join()

job.join() 是一个可中断的方法,所以它应该在协程内部被调用。

job.cancel

这个方法可以取消所有与其关联的子 Job,假如 suspendingTask1() 正在执行的时候 Job 调用了 cancel() 方法,这时候,res1 不会再被返回,而且 suspendingTask2() 也不会再执行。

val job = GlobalScope.launch(Dispatchers.Main) {

    doCoroutineTask()

    val res1 = suspendingTask1()
    val res2 = suspendingTask2()

    process(res1, res2)

}

job.cancel()

job.cancel() 是一个普通方法,所以它不必运行在协程内部。

async

这个协程构造器将会解决我们在刚开始演示示例的时候提到的一些难题。

async 允许并行地运行多个子线程任务,它不是一个可中断方法,所以当调用 async 启动子协程的同时,后面的代码也会立即执行。async 通常需要运行在另外一个协程内部,它会返回一个特殊的 Job,叫作 Deferred

Deferred 有一个新的方法叫做 await(),它是一个可中断的方法,当我们需要获取 async 的结果时,需要调用 await() 方法等待结果。调用 await() 方法后,会中断当前协程,直到其返回结果。

在下面的示例中,第二个和第三个请求需要依赖第一个请求的结果,请求好友列表和推荐好友列表本来可以并行请求的,如果都使用 withContext,显然会浪费时间:

GlobalScope.launch(Dispatchers.Main) {

    val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
    val currentFriends = withContext(Dispatchers.IO) { userService.requestCurrentFriends(user) }
    val suggestedFriends = withContext(Dispatchers.IO) { userService.requestSuggestedFriends(user) }

    val finalUser = user.copy(friends = currentFriends + suggestedFriends)
}

假如每个请求耗时 2 秒,总共需要使用 6 秒。如果我们使用 async 替代呢:

GlobalScope.launch(Dispatchers.Main) {

    val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
    val currentFriends = async(Dispatchers.IO) { userService.requestCurrentFriends(user) }
    val suggestedFriends = async(Dispatchers.IO) { userService.requestSuggestedFriends(user) }

    val finalUser = user.copy(friends = currentFriends.await() + suggestedFriends.await())

}

这时,第二个和第三个请求会并行运行,所以总耗时将会减少到 4 秒。

作用域(Scope

到目前为止,我们使用简单的方式轻松地实现了复杂的操作。但是,仍有一个问题未解决。

假如我们要使用 RecyclerView 显示朋友列表,当请求仍在进行的时候,客户关闭了 activity,此时 activity 处于 isFinishing 的状态,任何更新 UI 的操作都会导致 App 崩溃。

我们怎么处理这种场景呢?当然是使用作用域(scope)了。先来看看都有哪些作用域:

Global scope

它是一个全局的作用域,如果协程的运行周期和 App 的生命周期一样长的话,创建协程的时候可以使用它。所以它不应该和任何可以被销毁的组件绑定使用。

它的使用方式是这样的:

GlobalScope.launch(Dispatchers.Main) {
    ...
}

当你使用它的时候,要再三确定,要创建的协程是否需要伴随 App 整个生命周期运行,并且这个协程没有和界面、组件等绑定。

自定义协程作用域

任何类都可以继承 CoroutineScope 作为一个作用域。你需要做的唯一一件事就是重写 coroutineContext  这个属性。

在此之前,你需要明确两个重要的概念 dispatcher  和 Job

不知道你是否还记得,一个上下文(context)可以是多个上下文的组合。组合的上下文需要是不同的类型。所以,你需要做两件事情:

  • 一个 dispatcher: 用于指定协程默认使用的 dispatcher
  • 一个 job: 用于在任何需要的时候取消协程;
class MainActivity : AppCompatActivity(), CoroutineScope {

    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job

    private lateinit var job: Job

}

操作符号 + 用于组合上下文。如果两种不同类型的上下文相组合,会生成一个组合的上下文(CombinedContext),这个新的上下文会同时拥有被组合上下文的特性。

如果两个相同类型的上下文相组合,新的上下文等同于第二个上下文。即 Dispatchers.Main + Dispatchers.IO == Dispatchers.IO

我们可以使用延迟初始化(lateinit)的方式创建一个 Job。这样我们就可以在 onCreate() 方法中初始化它,在 onDestroy() 方法中取消它。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    job = Job()
    ...
}

override fun onDestroy() {
    job.cancel()
    super.onDestroy()
}

这样以来,使用协程就方便多了。我们只管创建协程,而不用关心使用的上下文。因为我们已经在自定义的作用域里面声明了上下文,也就是包含了 main dispatcher 的那个上下文:

launch {
    ...
}

如果你的所有 activity 都需要使用协程,将上述代码提取到一个父类中是很有必要的。

附录1 - 回调方式转为协程

如果你已经考虑将协程用于现有的项目,你可能会考虑怎么将现有的回调风格的代码转为协程:

suspend fun suspendAsyncLogin(username: String, password: String): User =
    suspendCancellableCoroutine { continuation ->
        userService.doLoginAsync(username, password) { user ->
            continuation.resume(user)
        }
    }

suspendCancellableCoroutine() 这个方法返回一个 continuation 对象,continuation 可以用于返回回调的结果。只要调用 continuation.resume() 方法,这个回调结果就可以作为这个可中断方法的结果返回给协程。

附录2 - 协程和 RxJava

每次提到协程都会有人问起,协程可以替代 RxJava 吗?简单地回答就是:不可以。

客观地来说,根据情况而定:

  1. 如果你使用 RxJava 只是用来从主线程切换到子线程。你也看到了,协程可以轻松地实现这一点。这种情况下,完全可以替代 RxJava
  2. 如果你使用 RxJava 用来流式编程,合并流、转换流等。RxJava 依然更有优势。协程中有一个 Channels 的概念,可以替代 RxJava 实现一些简单的场景,但是通常情况下,你可能更倾向于使用 RxJava 的流式编程。

值得一提的是,这里有一个开源库,可以在协程中使用 RxJava,你可能会感兴趣。

总结

协程为我们打开了一个充满无限可能性、更简单实现异步编程的世界。在此之前,这是不可想象的。

强烈推荐把协程用于你现有的项目当中。如果你想查看完整的示例代码,点击这里

赶快开启你的协程之旅吧!