Kotlin协程

协程是一种非抢占式或者说协作式的计算机程序并发调度的实现,程序可以主动挂起或者恢复执行。我们在 Java 虚拟机上所认识到的线程大多数的实现是映射到内核的线程的,也就是说线程当中的代码逻辑在线程抢到 CPU 的时间片的时候才可以执行,否则就得歇着,当然这对于我们开发者来说是透明的;而经常听到所谓的协程更轻量的意思是,协程并不会映射成内核线程或者其他这么重的资源,它的调度在用户态就可以搞定,任务之间的调度并非抢占式,而是协作式的

协程示例:

coroutineScope.launch(Dispatchers.Main) {       // 开始协程:主线程
    val token = api.getToken()                  // 网络请求:IO 线程
    val user = api.getUser(token)               // 网络请求:IO 线程
    nameTv.text = user.name                     // 更新 UI:主线程
}

协程接入:

buildscript {
    ...
    // 👇
    ext.kotlin_coroutines = '1.3.1'
    ...
}


dependencies {
    ...
    //                                       👇 依赖协程核心库
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines"
    //                                       👇 依赖当前平台所对应的平台库
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines"
    ...
}

指定协程运行在哪个线程?

答案就是这取决于协程上下文(coroutine context)的设置。
协程上下文是一系列规则和配置的集合,它决定了协程的运行方式。也可以理解为,它包含了一系列的键值对。 dispatcher 是其中的一个配置,它可以指定协程运行在哪个线程。
可挂起的方法有多种办法配置要使用的 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) 
}

这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行

coroutineScope.launch(Dispatchers.Main) {      // 👈 在 UI 线程开始
    val image = withContext(Dispatchers.IO) {  // 👈 切换到 IO 线程,并在执行完成后切回 UI 线程
        getImage(imageId)                      // 👈 将会运行在 IO 线程
    }
    avatarIv.setImageBitmap(image)             // 👈 回到 UI 线程更新 UI
} 

suspend:挂起函数
「代码执行到 suspend 函数的时候会『挂起』,并且这个『挂起』是非阻塞式的,它不会阻塞你当前的线程。」
挂起函数的本质:
协程中挂起的对象是协程本身

  1. 协程的代码块中,线程执行到了 suspend 函数这里的时候,就暂时不再执行剩余的协程代码,跳出协程的代码块,接下来线程就会被回收或者做他应该做的事情去了,比如主线程会继续做他页面刷新的任务
  2. 执行在主线程的协程,它实质上会往你的主线程 post 一个 Runnable,这个 Runnable 就是你的协程代码
  3. 当这个协程被挂起的时候,就是主线程 post 的这个 Runnable 提前结束,然后继续延续自己的使命去了
  4. 线程的代码在到达 suspend 函数的时候被掐断,接下来协程会从这个 suspend 函数开始继续往下执行,不过是在指定的线程。这个线程是suspend 函数指定的,比如在withContext中,函数内部的 withContext 传入的 Dispatchers.IO 所指定的 IO 线程
  5. 在 suspend 函数执行完成之后,协程会自动帮我们把线程再切回来
  6. 协程在执行到有 suspend 标记的函数的时候,会被 suspend 也就是被挂起,而所谓的被挂起,就是切个线程;不过区别在于,挂起函数在执行完成之后,协程会重新切回它原先的线程。再简单来讲,在 Kotlin 中所谓的挂起,就是一个稍后会被自动切回来的线程调度操作
  7. 挂起之后是需要恢复,而恢复这个功能是协程的,如果你不在协程里面调用,恢复这个功能没法实现,所以也就回答了一个问题:为什么挂起函数必须在协程或者另一个挂起函数里被调用,要求 suspend 函数只能在协程里或者另一个 suspend 函数里被调用,还是为了要让协程能够在 suspend 函数切换线程之后再切回来
  8. 所以这个 suspend,其实并不是起到把任何把协程挂起,或者说切换线程的作用。真正挂起协程这件事,是 Kotlin 的协程框架帮我们做的,它其实只是一个提醒。函数的创建者对函数的使用者的提醒:我是一个耗时函数,我被我的创建者用挂起的方式放在后台运行,所以请在协程里调用我

关于Dispatchers

Default: 
当我们未指定 dispatcher 的时候会默认使用,当然,我们也可以明确设置使用它。
它一般用于 CPU 密集型的任务,特别是涉及到计算、算法的场景。它可以使用和 CPU 
核数一样多的线程。正因为是密集型的任务,同时运行多个线程并没有意义,
因为 CPU 将会很繁忙。
IO:
 它用于输入/输出的场景。通常,涉及到会阻塞线程,需要等待另一个系统响应的任务,
 比如:网络请求、数据库操作、文件读写等,都可以使用它。因为它不使用 CPU ,
 可以同一时间运行多个线程,默认是数量为 64 的线程池。Android App 中有很多
 网络请求的操作,所以你可能会经常用到它。
UnConfined: 
如果你不在乎启动了多少个线程,那么你可以使用它。它使用的线程是不可控制的,
除非你特别清楚你在做什么,否则不建议使用它。
Main: 
这是 UI 相关的协程库里面的一个 dispatcher,在 Android 编程中,
它使用的是 UI 线程。

协程构造器(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 方法会返回一个 Job,Job 继承了协程上下文(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 = currentriends.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 吗?简单地回答就是:不可以。
客观地来说,根据情况而定:
如果你使用 RxJava 只是用来从主线程切换到子线程。你也看到了,协程可以轻松地实现这一点。这种情况下,完全可以替代 RxJava。
如果你使用 RxJava 用来流式编程,合并流、转换流等。RxJava 依然更有优势。协程中有一个 Channels 的概念,可以替代 RxJava 实现一些简单的场景,但是通常情况下,你可能更倾向于使用 RxJava 的流式编程。
值得一提的是,这里有一个开源库,可以在协程中使用 RxJava,你可能会感兴趣。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值