【译文】kotlin1.3 版本的协程

原文链接:https://antonioleiva.com/coroutines/

协程是 kotlin 中让人激动的特性之一,使用协程,可以用一种优雅的方式来简化异步编程,让代码更加可读和易于理解。
使用协程,你可以用同步的方式写异步代码,而不是传统的 Callback 方式来写。同步方法的返回值就是异步计算的结果。
到底有什么魔力发生呢?我们马上即可学习它,在此之前,让我们了解下为什么协程很有必要。
协程是 kotlin1.1 推出的实验特性,在 kotlin1.3 版本发布了最终的 Api,现在已经可以投入生产。

Coroutines goal: The problem

假设你需要开发一个登录页面,UI 如下:

用户输入用户名和密码,然后点击登录按钮。

你的 App 代码实现里,需要向服务端发起请求来校验登录,然后请求该用户的好友列表,最后显示在屏幕上。

使用 kotlin 写出来的代码像这样:

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
    }
 
}
}

代码开始变得难以理解,我们看到了令人恐惧的嵌套回调,即下一个请求总是嵌套在了上一个请求的 callback 里。

kotlin 的 lambdas 表达式,让其不至于那么难看。但谁知道呢?将来你依然需要添加请求,使其变的越来越糟糕。

此外,别忘了我们这使用的是简单的方式,也就没那么高效了。

What are coroutines?

为了轻松的理解协程。我们可以说协程就像线程一样,但比线程更好。

首先,协程可以让你有顺序的写异步代码,大大的减轻了写异步代码的负担。

其次,它们更加的高效,多个协程可以在同一个线程上跑起来。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
}

在上面的例子中,是一个协程通用的结构。有一个协程 builder(构造器) ,和一些的 在返回结果前中断了 协程执行的中断函数

然后,你可以在下一行代码使用这个中断函数的返回结果,像极了有序的编码。kotlin中并不存在 coroutine 和 suspended 这两个关键字,上述例子我们先了解通用的结构

Suspending functions

中断函数(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

协程的上下文,是用于定义协程怎么执行的规则和配置集合。它可以看作一个map,存储了一系列 keys 和values。

dispatcher 是其中一个配置,dispatcher 可以指定协程执行在哪个线程。

dispatcher 提供两种使用方式:

1.在需要使用的地方明确设置 dispatcher 类型。

2.通过协程的作用域(scope):关于scope在后面会细说

对于明确指定的使用方式,协程的构造器接收一个协程的上下文,作为第一个参数,我们可以直接将dispatcher当做第一个参数传进协程的构造器,dispatcher实现了CoroutineContext接口,因此可以这样使用:

coroutine(Dispatchers.Main) {

​    progress.visibility = View.VISIBLE

​    ...

}

现在,这行changes the visibility将会在UI线程执行,这个协程里的一切代码都是在UI线程执行都。那中断函数呢?

coroutine {

​    ...

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

​    val currentFriends = suspended { userService.requestCurrentFriends(user) }

​    ...

}

网络请求也是运行在UI线程么?如果是这样的情况的话,他们将会阻塞UI线程

中断函数在使用的时候,有不同的方式来配置 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) }

你可能想了解我们有哪些 dispatchers 和什么时候使用,让我们来认识它们:

Dispatchers

正如你看见的,Dispatchers是一个线程上下文,可以指定协程里的代码运行在哪个线程。有的dispatchers仅使用一个线程,如main线程。其他的Dispatchers定义了一个线程池。将运行所有接收到的协程

如果你记得,最开始的时候,我们使用了一个线程来运行多个协程,因此系统不会为每一个协程都创建一个线程,但是会尝试复用已经存在的线程。

我们有四个主要的 Dispatchers

Default:当没有指定Dispatcher时它会被使用,我们可以明确的指定Dispatcher,这个Dispatcher可用于cpu密集型的计算任务。如一些计算,算法场景,它可以使用的线程数和cpu核心数一样多,对于密集型任务,cpu是很繁忙的,所以同时运行多个线程没有意义。

IO:当你需要运行 输入 / 输出 时会使用到它,通常的,当需要等待其他的系统回应时,即会阻塞线程的的任务,如服务端请求,读写数据库,文件,它不使用 cpu,可以同时让多个线程跑起来,是线程数为64的线程池。Android的应用是设备和网络请求的交互,因此你可能更多的时候会使用到他们。

Unconfined:如果你不关心线程的使用,你可以使用这个。它很难控制线程的使用,因此当你不是很确定你要做什么的时候,不建议使用它。

Main:这是一个和UI关联的协程库里的特殊的Dispatcher,在Android中,他会使用UI线程。

你现在已经可以灵活的使用Dispatcher了。

Coroutine Builders

现在你能够很轻松的切换执行线程,你需要学习怎么创建一个新的协程,当然是使用协程构造器。

我们可以根据场景,选择不同的协程构造器,你也可以自定义自己的协程构造器,通常情况下,协程库提供给我们的已经足够了,让我们来看看:

runBlocking

这个协程构造器会阻塞当前的线程,直到在协程里所有的任务都执行完毕,这违背了我们使用协程的初衷。所以他有什么用处呢?

runBlocking对于测试中断函数非常有用,在你的测试程序里,runBlocking代码体里包含了中断函数,你可以断言结果,防止在中断函数结束之前,测试线程提前结束。

fun testSuspendingFunction() = runBlocking {

​    val res = suspendingTask1()

​    assertEquals(0, res)

}

除了这个场景,你可能没有更多的地方需要用到runBlocking

launch

这是主要的构造器,你会经常使用它,因为它是创建协程最简单的方式,区别于runBlocking,它不阻塞当前线程(前提是正确使用了dispatchers)

这个构造器总是需要作用域的,在接下来会学习到作用域,在那之前,我们先使用GlobalScope。

GlobalScope.launch(Dispatchers.Main) {

​    ...

}

launch会返回一个Job对象,一个实现了CoroutineContext的类。

Jobs有几个有用的方法很有用,需要重点了解的是,一个job有一个父job,这个父 job可以控制子job

接下来介绍下Job的方法:

job.join

对于这个方法,可以阻塞当前job关联的协程,直到所有的子jobs结束。所有在协程里边调用的中断函数,都绑定了这个job。当所有子job结束后,当前协程才继续执行。

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

​    doCoroutineTask()

​    val res1 = suspendingTask1()

​    val res2 = suspendingTask2()

​    process(res1, res2)

 }

job.join()

job.join()本身是一个中断函数,因此你需要在协程里调用它

job.cancel

这个函数可以取消与其关联的所有子jobs,举个例子,suspendingTask1在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。

这个对象有一个新的函数叫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秒。

除此之外,同步两个结果是简单的,只需要调用两者的await,让协程框架完成。

Scopes

到目前为止,我们有一套很不错的代码,使用简单的方式来解决一些复杂的场景。但我们仍然还有一个问题。

想象下我们需要显示好友列表在一个RecyclerView,但是当我们运行在其中一个后台任务时,这个用户关闭了Activity,这个Activity将会isFinishing状态,因此所有的UI更新都会抛出异常。

我们可以怎样解决这种场景呢?使用Scopes。我们看下各种Scopes的用法。

Global scope

这是一个常用scope,当协程的生命周期和App的生命周期一样长久的话,使用这个scope,因此他们不应该和可以销毁的组件绑定。我们在上述使用过它,因此现在应该简单了。

GlobalScope.launch(Dispatchers.Main) {

​    ...

}

当你使用GlobalScope,总是需要问自己这个协程是不是伴随整个App生命周期的。而不是仅仅一个页面或组件。

Implement CoroutineScope

所有的类都可以实现这个接口(CoroutineScope)成为一个作用域,这个仅仅需要重写 coroutineContext属性。

这里,有至少两个重要的东西需要配置,dispatcher和job

你需要记住,一个context可以组合多个context,组合的context需要不同的类型,所以这里,通常的,你将定义两个东西,

dispatcher,用于指定协程的dispatcher

job,可以在任何时候取消协程。

class MainActivity : AppCompatActivity(), CoroutineScope {

 

​    override val coroutineContext: CoroutineContext

​        get() = Dispatchers.Main + job

 

​    private lateinit var job: Job

 

}

这个plus(+)操作符在组合context时使用,如果两个不同类型的context组合时。会创建一个CombinedContext,CombinedContext拥有两个上下文的配置。

另一方面,如果两个相同类型context组合,新的上下文使用第二个上下文的配置,即 instance:Dispatchers.Main + Dispatchers.IO == Dispatchers.IO

我们创建job可以使用了lateinit,在onCreate延迟初始化它。它将在onDestroy时被取消。

override fun onCreate(savedInstanceState: Bundle?) {

​    super.onCreate(savedInstanceState)

​    job = Job()

​    ...

}

 

override fun onDestroy() {

​    job.cancel()

​    super.onDestroy()

}

现在,当使用协程时,代码变的简单。你可以使用构造器,跳过协程的context,因为已经在自定义作用域定义了包含 main dispatcher的上下文。

launch {

​    ...

}

当然,如果你的activitiy里使用协程,将其提取到父类是很值得的

补充1 - 从 callbacks 转为协程

如果你开始考虑将协程应用到你的项目中,你可能会考虑将当前使用callback的代码,转为协程。

suspend fun suspendAsyncLogin(username: String, password: String): User =

​    suspendCancellableCoroutine { continuation ->

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

​            continuation.resume(user)

​        }

​    }

suspendCancellableCoroutine方法返回一个continuation对象,这个对象可以返回callback的结果。只需要调用continuation.resume。结果将会通过中断函数返回给父协程。

补充2 - 关于协程与 Rxjava

是的,提到协程,我也有相同的问题:"协程可以替代Rxjava么?"答案是不。

如果你使用Rxjava仅是用于从主线程切换到子线程,你发现协程可以更加简单的完成这工作,是的,你可以不需要Rxjava

如果你使用流式操作,转换流,合并流等,Rxjava依然做的更出色,在协程有一个叫Channels 的东西能够替代Rxjava的多数简单使用场景,但是Rxjava流式操作更加让人喜欢

值得一提的是kotlin有一个开源库,可以在协程里使用rxjava。

总结

协程提供了更多的可能性,以一种你可能没想到的方式来简化异步编程。
赶快来体验协程吧!

你可能感兴趣的:(Android开发)