全民 Kotlin:协程特别篇
-
当前篇:全民 Kotlin:协程特别篇
目录
-
什么是协程
-
suspend 关键字介绍
-
集成协程
-
runBlocking 用法
-
launch 用法
-
async 用法
-
协程的线程调度器
-
协程的启动模式
-
协程设置执行超时
-
协程的生命周期控制
什么是协程
- 先来看官方对 Kotlin 协程的介绍:Coroutines basics
A coroutine is an instance of suspendable computation. It is conceptually similar to a thread, in the sense that it takes a block of code to run that works concurrently with the rest of the code. However, a coroutine is not bound to any particular thread. It may suspend its execution in one thread and resume in another one.
Coroutines can be thought of as light-weight threads, but there is a number of important differences that make their real-life usage very different from threads.
- 翻译成中文的意思是:
一个协同程序是悬浮计算的一个实例。它在概念上类似于线程,从某种意义上说,它需要一个与其余代码同时运行的代码块来运行。但是,协程不受任何特定线程的约束。它可以在一个线程中暂停其执行并在另一个线程中恢复。
协程可以被认为是轻量级线程,但是有许多重要的区别使得它们在现实生活中的使用与线程大不相同。
- 在我看来,协程是 Kotlin 对线程和 Handler 的 API 的一种封装,是一种优雅处理异步任务的解决方案,协程可以在不同的线程来回切换,这样就可以让代码通过编写的顺序来执行,并且不会阻塞当前线程,省去了在各种耗时操作写回调的情况。
suspend 关键字介绍
-
在正式开讲协程之前,先介绍一下 Kotlin 语法的关键字:suspend,suspend 的中文意思是挂起,可以理解为把当前线程暂时挂起,稍后自动切回来到原来的线程上,可用于修饰普通的方法,表示这个方法是一个耗时操作,只能在协程的环境下才能调用,又或者在另一个 suspend 方法中调用。
-
当代码执行到有 suspend 关键字修饰的方法上,会先挂起当前线程的执行,需要注意的是这里的挂起是非阻塞式的(也就是不会阻塞当前线程情况下),然后就会去先执行带有 suspend 修饰的方法上,当这个方法执行完成后,会让刚刚挂起的线程继续往下执行,这样我们看到的代码顺序就是代码执行的顺序。
-
另外需要注意的是 suspend 本身不会起到一种线程挂起或者线程切换的效果,那么它真正的作用是什么呢?其实它更多的是一种提醒,表示这是一个耗时方法,不能直接执行,需要把我放到协程中去调用,所以我们在写某个耗时方法的时候需要给它加上 suspend 关键字,这样可以有效避免我们在主线程中调用耗时操作造成应用卡顿的情况。
suspend fun getUserName(userId: Int): String {
delay(20000)
return "Android 轮子哥"
}
集成协程
dependencies {
// Kotlin 协程:https://github.com/Kotlin/kotlinx.coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
}
-
协程常见的三个操作符号
-
runBlocking:中文意思是运行阻塞,顾名思义,会阻塞当前线程执行
-
launch:中文意思是启动,不会阻塞当前线程,但是会异步执行代码
-
async:中文意思是异步,跟 launch 相似,唯一不同的是它可以有返回值
-
runBlocking 用法
- runBlocking 的中文翻译:运行阻塞。说太多没用,直接用代码测试一下
println("测试开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
runBlocking {
println("测试延迟开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
delay(20000)
println("测试延迟结束")
}
println("测试结束")
12:53:15.799 System.out: 测试开始 true
12:53:15.811 System.out: 测试延迟开始 true
12:53:35.814 System.out: 测试延迟结束
12:53:35.815 System.out: 测试结束
-
runBlocking 运行在主线程,由此可见和它的名称一样,真的会阻塞当前的线程,只有等 runBlocking 里面的代码执行完了才会执行 runBlocking 外面的代码
-
到这里大家可能有一个疑问,既然阻塞线程,那我直接用代码写不是也一样?干嘛还用 runBlocking 呢?解决这一疑问很简单,我们只需要找一个没有协程的地方,写一句
delay(20000)
就收到编译器给你的提示:
Suspend function 'delay' should be called only from a coroutine or another suspend function
挂起函数 'delay' 应该只从协程或另一个挂起函数调用
- 如果我们调用的是被 suspend 修饰的方法,那么它必须要在协程内才能被调用,所以 runBlocking 并非一无是处
launch 用法
-
launch 的中文翻译:启动,上代码测试
-
测试的时候是主线程,但是到了 launch 中就会变成子线程,这种效果类似 new Thread(),有木有?和 runBlocking 最不同的是 launch 没有执行顺序这个概念
println("测试开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
GlobalScope.launch {
println("测试延迟开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
delay(20000)
println("测试延迟结束")
}
println("测试结束")
17:19:17.190 System.out: 测试开始 true
17:19:17.202 System.out: 测试结束
17:19:17.203 System.out: 测试延迟开始 false
17:19:37.223 System.out: 测试延迟结束
async 用法
- async 的中文翻译:异步,还是老套路,直接上代码测试
println("测试开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
GlobalScope.async {
println("测试延迟开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
delay(20000)
println("测试延迟结束")
}
println("测试结束")
17:29:00.694 System.out: 测试开始 true
17:29:00.697 System.out: 测试结束
17:29:00.697 System.out: 测试延迟开始 false
17:29:20.707 System.out: 测试延迟结束
- 这结果不是跟 launch 一样么?那么这两个到底有什么区别呢?让我们先看一段测试代码
println("测试开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
val async = GlobalScope.async {
println("测试延迟开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
delay(20000)
println("测试延迟结束")
return@async "666666"
}
println("测试结束")
println("测试返回值:" + async.await())
17:50:57.117 System.out: 测试开始 true
17:50:57.120 System.out: 测试结束
17:50:57.120 System.out: 测试延迟开始 false
17:51:17.131 System.out: 测试延迟结束
17:51:17.133 System.out: 测试返回值:666666
- 看到这里你是否懂了,async 和 launch 还是有区别的,async 可以有返回值,通过它的 await 方法进行获取,需要注意的是这个方法只能在协程的操作符或者被 suspend 修饰的方法中才能调用。
协程的线程调度器
-
介绍一下线程调度器的类型,总共有四种:
-
Dispatchers.Main:主线程调度器,人如其名,会在主线程中执行
-
Dispatchers.IO:工作线程调度器,人如其名,会在子线程中执行
-
Dispatchers.Default:默认调度器,没有设置调度器时就用这个,经过测试效果基本等同于
Dispatchers.IO
-
Dispatchers.Unconfined:无指定调度器,根据当前执行的环境而定,会在当前的线程上执行,另外有一点需要注意,由于是直接拿当前线程执行,经过实践,协程块中的代码执行过程中不会有延迟,会被立马执行,除非遇到需要协程被挂起了,才会去执行协程外的代码,这个也是跟其他类型的调度器不相同的地方
-
-
啥?协程有类似 RxJava 线程调度?先用 launch 试验一下
println("测试开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
GlobalScope.launch(Dispatchers.Main) {
println("测试延迟开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
delay(20000)
println("测试延迟结束")
}
println("测试结束")
18:00:23.244 System.out: 测试开始 true
18:00:23.246 System.out: 测试结束
18:00:23.247 System.out: 测试延迟开始 true
18:00:43.256 System.out: 测试延迟结束
-
使用线程调度器可以控制协程在哪个线程上面执行,这主要归功于 Dispatchers(调度器),如果我不指定 launch 语句的调度器,那么它肯定是要子线程中执行的,但是当我指定了
Dispatchers.Main
之后,它就会变成在主线程中执行了。 -
线程调度不仅可以发生在协程的 launch 和 async 中,还可以发生在协程内部,例如:
println("测试开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
GlobalScope.launch(Dispatchers.Main) {
withContext(Dispatchers.IO) {
println("测试是否为主线程 " + (Thread.currentThread() == Looper.getMainLooper().thread))
}
println("测试延迟开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
delay(20000)
println("测试延迟结束")
}
println("测试结束")
22:21:21.355 System.out: 测试开始 true
22:21:21.367 System.out: 测试结束
22:21:21.390 System.out: 测试是否为主线程 false
22:21:22.058 System.out: 测试延迟开始 true
22:21:42.059 System.out: 测试延迟结束
-
从打印的日志来看,
withContext
的作用就是将当前线程挂起,只有当withContext
里面的代码执行完了,才会恢复当前线程的执行,你现在回过头看看代码,这里面的执行顺序是不是就按照代码的编写顺序来了呢,这个协程的魅力所在,尽管代码需要在不同线程上面执行,但是线程切换的效果十分优雅,代码从上向下执行。 -
需要注意的是
withContext
只能在协程的操作符或者被 suspend 修饰的方法中才能调用,具体的用法有两种,如下:
suspend fun getUserName(userId: Int): String {
return withContext(Dispatchers.IO) {
delay(20000)
return@withContext "Android 轮子哥"
}
}
suspend fun getUserName(userId: Int): String = withContext(Dispatchers.IO) {
delay(20000)
return@withContext "Android 轮子哥"
}
协程的启动模式
-
介绍一下线程调度器的模式,总共有四种:
-
CoroutineStart.DEFAULT:默认模式,会立即执行
-
CoroutineStart.LAZY:懒加载模式,不会执行,只有手动调用协程的 start 方法才会执行
-
CoroutineStart.ATOMIC:原子模式,跟
CoroutineStart.DEFAULT
类似,但协程在开始执行之前不能被取消,需要注意的是,这是一个实验性的 api,后面可能会发生变更。 -
CoroutineStart.UNDISPATCHED:未指定模式,会立即执行协程,经过实践得出,会导致原先设置的线程调度器失效,一开始会在原来的线程上执行,类似于
Dispatchers.Unconfined
,但是一旦协程被挂起,再恢复执行,会变成线程调度器的设置的线程上面去执行。
-
-
说了那么多,那么到底怎么用呢?拿
CoroutineStart.LAZY
举例,具体用法如下:
val job = GlobalScope.launch(Dispatchers.Default, CoroutineStart.LAZY) {
}
job.start()
-
这里额外这里介绍一下协程几个函数的用法:
-
job.start:启动协程,除了 lazy 模式,协程都不需要手动启动
-
job.cancel:取消一个协程,可以取消,但是不会立马生效,存在一定延迟
-
job.join:等待协程执行完毕,这是一个耗时操作,需要在协程中使用
-
job.cancelAndJoin:等待协程执行完毕然后再取消
-
协程设置执行超时
- 协程在执行的时候,我们其实可以给它设置一个执行时间,如果执行的耗时时间超过规定的时间,那么协程就会自动停止,具体的用法如下:
GlobalScope.launch() {
try {
withTimeout(300) {
// 重复执行 5 次里面的内容
repeat(5) { i ->
println("测试输出 " + i)
delay(100)
}
}
} catch (e: TimeoutCancellationException) {
// TimeoutCancellationException 是 CancellationException 的子类
// 其它的不用我说了吧?文章前面有介绍 CancellationException 类作用的
println("测试协程超时了")
}
}
23:52:48.415 System.out: 测试输出 0
23:52:48.518 System.out: 测试输出 1
23:52:48.618 System.out: 测试输出 2
23:52:48.715 System.out: 测试协程超时了
- 除了 withTimeout 这个用法,还有另外一个用法,那就是 withTimeoutOrNull,这个和 withTimeout 最大的不同的是不会超时之后不会抛 TimeoutCancellationException 给协程,而是直接返回 null,如果没有超时则会返回协程体里面的结果,具体用法如下:
GlobalScope.launch() {
val result = withTimeoutOrNull(300) {
// 重复执行 5 次里面的内容
repeat(5) { i ->
println("测试输出 " + i)
delay(100)
}
return@withTimeoutOrNull "执行完成了"
}
println("测试输出结果 " + result)
}
23:56:02.462 System.out: 测试输出 0
23:56:02.569 System.out: 测试输出 1
23:56:02.670 System.out: 测试输出 2
23:56:02.761 System.out: 测试输出结果 null
- 如果我们将超时从 300 毫秒改到成 1000 毫秒,那么这个案例的协程是一定不会超时的,最终打印的结果如下:
23:59:37.288 System.out: 测试输出 0
23:59:37.390 System.out: 测试输出 1
23:59:37.491 System.out: 测试输出 2
23:59:37.591 System.out: 测试输出 3
23:59:37.692 System.out: 测试输出 4
23:59:37.793 System.out: 测试输出结果 执行完成了
协程的生命周期控制
- 我们如果在代码中直接使用 GlobalScope 这个类来操作协程,会有黄色的警告:
This is a delicate API and its use requires care. Make sure you fully read and understand documentation of the declaration that is marked as a delicate API.
这是一个微妙的API,使用时需要小心。确保您充分阅读并理解标记为敏感API的声明的文档。
- 让我们先看一下谷歌对 GlobalScope 这个类的介绍:
A global [CoroutineScope] not bound to any job.
Global scope is used to launch top-level coroutines which are operating on the whole application lifetime and are not cancelled prematurely.
Active coroutines launched in
GlobalScope
do not keep the process alive. They are like daemon threads.
This is a delicate API. It is easy to accidentally create resource or memory leaks when
GlobalScope
is used. A coroutine launched inGlobalScope
is not subject to the principle of structured concurrency, so if it hangs or gets delayed due to a problem (e.g. due to a slow network), it will stay working and consuming resources.
- 翻译成中文的意思是:
全局的[CoroutineScope]不绑定到任何作业。
全局作用域用于启动顶级协程,这些协程在整个应用程序生命周期中运行,不会提前取消。
在“GlobalScope”中启动的活动协程不会使进程保持活动状态。它们就像守护线程。
这是一个非常精致的API。当使用' GlobalScope '时,很容易意外地创建资源或内存泄漏。在“GlobalScope”中启动的协程不受结构化并发原则的约束,所以如果它挂起或由于问题(例如由于网络缓慢)而延迟,它将继续工作并消耗资源。
-
通过阅读 GlobalScope 类上面的代码注释,可以了解到,通过 CoroutineScope 开启的协程是全局的,也就是不会跟随组件(例如 Activity)的生命周期,这样就可能会导致一些内存泄漏的问题。
-
那么为了解决这一问题,Jetpack 中其实有提供关于 Kotlin 协程的一些扩展组件,例如 LifecycleScope 和 ViewModelScope,集成的方式如下:
dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
}
- 如果是在 LifecycleOwner 的子类(AppCompatActivity 和 Fragment 都是它的子类)中使用,这样写出来的协程会在 Lifecycle 派发 destroy 事件的时候 cancel 掉
class TestActivity : AppCompatActivity() {
fun test() {
lifecycleScope.launch {
}
}
}
- 如果是在 ViewModel 的子类中使用,这样写出来的协程会在 ViewModel 调用 clear 方法的时候 cancel 掉
class TestViewModel : ViewModel() {
fun test() {
viewModelScope.launch() {
}
}
}
- 如果我是在 Lifecycle 或者 ViewModel 之外的地方使用协程,又担心内存泄漏,那么该怎么办呢?
val launch = GlobalScope.launch() {
}
launch.cancel()
- 可以在合适的时机手动调用 cancel 方法,这样就可以取消
println("测试开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
val job = GlobalScope.launch() {
try {
println("测试延迟开始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
delay(20000)
println("测试延迟结束")
delay(20000)
println("测试延迟结束")
} catch (e: CancellationException) {
// 在这里可以添加一个 try catch 来捕获取消的动作
// 另外需要注意的是,如果在协程体内发生 CancellationException 异常
// 会被协程内部自动消化掉,并不会导致应用程序崩溃的
// 所以一般情况下不需要捕获该异常,除非需要手动释放资源
println("测试协程被取消了")
}
}
println("测试结束")
// 手动取消协程
job.cancel()
12:35:10.005 System.out: 测试开始 true
12:35:10.022 System.out: 测试结束
12:35:10.023 System.out: 测试延迟开始 false
12:35:10.027 System.out: 测试协程取消了