Kotlin协程 ——从 runBlocking 与 coroutineScope 说起

关于协程我们不多阐述,详细内容请查看官方文档,本文只谈谈 runBlockingcoroutineScope

runBlocking

我们先来看看 runBlocking 文档是如何描述该函数的:

Runs a new coroutine and blocks the current thread interruptibly until its completion. This function should not be used from a coroutine. It is designed to bridge regular blocking code to libraries that are written in suspending style, to be used in main functions and in tests.
运行一个新的协程&块(阻塞?) 当前线程可中断 直至完成,该函数不应从一个协程中使用,该函数被设计用于桥接普通阻塞代码到以挂起风格(suspending style)编写的库,以用于主函数与测试。

这段话怎么理解呢?这要从 suspend 修饰符说起,协程使用中可以使用该修饰符修饰一个函数,表示该函数为挂起函数,从而运行在协程中。 挂起函数,它不会造成线程阻塞,但是会 挂起 协程,并且只能在协程中使用。挂起函数不可以在main函数中被调用,那么我们怎么调试呢?对了,就是使用runBlocking 函数!

我们可以使用 runBlocking 函数,构建一个主协程,从而调试我们的协程代码。你可能会问协程有什么优势么?以至于我需要去搞懂他?引用官方举的一个小例子吧:

import kotlinx.coroutines.*

fun main() = runBlocking {
    repeat(100_000) { // 启动大量的协程
        launch {
            delay(1000L)
            print(".")
        }
    }
}

创建10W个协程并在一秒后同时打印一个点 ,不用我多说,你也知道如果使用线程实现的话会发生什么吧?

如何使用:

fun main() = runBlocking<Unit> {
    // this: CoroutineScope
    launch {
        // 在 runBlocking 作用域中启动一个新协程
        delay(1000L)
        println("World! ${Thread.currentThread().name}")
    }
    println("Hello, ${Thread.currentThread().name}")
}

总结:runBlocking 方法,可以在普通的阻塞线程中开启一个新的协程以用于运行挂起函数,并且可以在协程中通过调用 launch 方法,开启一个子协程,用于运行后台阻塞任务。

如果我们在普通的线程中运行该方法:

fun main2_1() {
    runBlocking {
       launch {
            // 在后台启动一个新的协程并继续
            delay(3000L)
            println("World!")
        }
    }
    println("Hello,") 
}

runBlocking 是会阻塞主线程的,直到 runBlocking 内部全部子任务执行完毕,才会继续执行下一步的操作!

在协程中执行耗时任务

好的,我们已经知道了如何通过 runBlocking 函数来创建一个协程了,那么我们应该如何利用协程来处理耗时任务呢?
运行我们第一个实例代码,通过打印结果我们可以看出俩次打印是在不同的协程上运行的,你可能会好奇为什么调用 launch 函数可以创建一个新的协程?我们来看一下 launch 方法的源码:

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext, 
    start: CoroutineStart = CoroutineStart.DEFAULT, 
    block: suspend CoroutineScope.() -> Unit
): Job (source)

从方法签名可以看出,launch 方法是 CoroutineScope 的一个扩展函数,该方法在不阻塞当前线程的情况下启动新的协程,并将协程的引用作为 Job 返回。取消生成的 Job 时,协程将被取消。

默认情况下,launch中的代码会立即执行。注意方法签名中的第二个参数 start ,我们可以通过修改该参数,来变更不同的子协程启动方式,详情请查看文档 CoroutineStart

什么是 CoroutineScope

了解完了 launch 方法后,我们来看看到底什么是 CoroutineScope 。

public interface CoroutineScope {
    /**
     * Context of this scope.
     */
    public val coroutineContext: CoroutineContext
}

该接口从字面理解是 协程的作用范围,为什么要有作用范围?

Coroutine 是轻量级的线程,并不意味着就不消耗系统资源。 当异步操作比较耗时的时候,或者当异步操作出现错误的时候,需要把这个 Coroutine 取消掉来释放系统资源。在 Android 环境中,通常每个界面(Activity、Fragment 等)启动的 Coroutine 只在该界面有意义,如果用户在等待 Coroutine 执行的时候退出了这个界面,则再继续执行这个 Coroutine 可能是没必要的。另外 Coroutine 也需要在适当的 context 中执行,否则会出现错误,比如在非 UI 线程去访问 View。 所以 Coroutine 在设计的时候,要求在一个范围(Scope)内执行,这样当这个 Scope 取消的时候,里面所有的子 Coroutine 也自动取消。所以要使用 Coroutine 必须要先创建一个对应的 CoroutineScope。

所以 CoroutineScope 只是定义了一个新 Coroutine 的执行 Scope。每个 coroutine builder 都是 CoroutineScope 的扩展函数,并且自动的继承了当前 Scope 的 coroutineContext 和取消操作。

每个 coroutine builder 和 scope 函数(withContext、coroutineScope 等)都使用自己的 Scope 和 自己管理的 Job 来运行提供给这些函数的代码块。并且也会等待该代码块中所有子 Coroutine 执行,当所有子 Coroutine 执行完毕并且返回的时候, 该代码块才执行完毕,这种行为被称之为 “structured concurrency”(结构化并发)。

coroutineScope 函数又是怎么一回事呢?

官方文档中说了一段不是很容易理解的话:

除了由不同的构建器提供协程作用域之外,还可以使用 coroutineScope 构建器声明自己的作用域。它会创建一个协程作用域并且在所有已启动子协程执行完毕之前不会结束。runBlocking 与 coroutineScope 的主要区别在于后者在等待所有子协程执行完毕时不会阻塞当前线程。

我们已经知道了 runBlocking 方法会创建一个新的协程,coroutineScope 函数看起来效果与 runBlocking 效果很像。但其实他们两者存在本质性的差异。

前面我们说了 runBlocking 是桥接阻塞代码与挂起代码之前的桥梁,其函数本身是阻塞的,但是可以在其内部运行 suspend 修饰的挂起函数。在内部所有子协程运行完毕之前,他是阻塞线程的。

而 coroutineScope 函数不同:

public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R =
    suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = ScopeCoroutine(uCont.context, uCont)
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }

该函数被 suspend 修饰,是一个挂起函数,前面我们说了挂起函数是不会阻塞线程的,它只会挂起协程,而不阻塞线程。

如何在我们的项目里使用协程?

如果你使用 MVVM ,那么只需要引入
androidx.lifecycle:lifecycle-viewmodel-ktx 这个包,就可以直接在ViewModel 中使用 viewModelScope 这个扩展字段,从而开启你的协程之旅。

如果我们没有使用 MVVM 呢,我想在Activity中使用应该如何操作呢?

参考代码如下:

class ScopedActivity : Activity(), CoroutineScope {
    lateinit var job: Job
    // CoroutineScope 的实现
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        job = Job()
    }
 
    override fun onDestroy() {
        super.onDestroy()
        // 当 Activity 销毁的时候取消该 Scope 管理的 job。
        // 这样在该 Scope 内创建的子 Coroutine 都会被自动的取消。
        job.cancel()
    }
 
    /*
     * 注意 coroutine builder 的 scope, 如果 activity 被销毁了或者该函数内创建的 Coroutine
     * 抛出异常了,则所有子 Coroutines 都会被自动取消。不需要手工去取消。
     */
    fun loadDataFromUI() = launch { // <- 自动继承当前 activity 的 scope context,所以在 UI 线程执行
        val ioData = async(Dispatchers.IO) { // <- launch scope 的扩展函数,指定了 IO dispatcher,所以在 IO 线程运行
            // 在这里执行阻塞的 I/O 耗时操作
        }
        // 和上面的并非 I/O 同时执行的其他操作
        val data = ioData.await() // 等待阻塞 I/O 操作的返回结果
        draw(data) // 在 UI 线程显示执行的结果
    }
}
  1. 使 Activity 实现 CoroutineScope接口;
  2. 重写 coroutineContext 的 get() 方法;
  3. 在onDestroy 方法中调用 job.cancel();
  4. 在适当的位置调用 launch 方法即可;

如何在协程中切换不同的线程

我们知道,在 Android 中是不可以在主线程发起网络请求的,我们的协程是寄宿在当前线程的,所以即使在协程中,我们任然不可以发起网络请求,否则一样会报 NetworkOnMainThreadException 这个异常的。

这时候我们就需要用到在协程中切换线程,上面代码中已经演示了如何操作了:

		val ioData = async(Dispatchers.IO) { // <- launch scope 的扩展函数,指定了 IO dispatcher,所以在 IO 线程运行
            // 在这里执行阻塞的 I/O 耗时操作
        }
        // 和上面的并非 I/O 同时执行的其他操作
        val data = ioData.await() // 等待阻塞 I/O 操作的返回结果

async 函数中添加 Dispatchers.IO 参数,该函数不同于 launch 函数的是,launch 函数的返回值是 Job,而 async 的返回值是用户自己设置的 Deferred<T>,在获取异步结果时需要通过调用 await 函数来获取。

如果我们有多个耗时操作呢?多个 async 函数调用会一同进行吗?答案是否定的,他们将会依次执行,也就是说 async 配合 await 时会阻塞当前协程的。

那如果我们的多个操作需要并行呢?很简单,在其外层增加一层 launch 即可,这样不同的耗时操作就执行在不同的协程之中,互相之间不会阻塞,从而实现并行,代码如下所示:

   fun delayLoad(v: View) = launch {
      launch {
          Logger.d("开始网络请求1 ${Thread.currentThread().name}")
          val response1 = async(Dispatchers.IO) {
              Logger.d("${Thread.currentThread().name}")
              ServiceCreator.create(PlaceService::class.java).login().execute() }
          tv_detail.text = response1.await().body().toString()
      }
       Logger.d("开始网络请求2 ${Thread.currentThread().name}")
       val response2 = async(Dispatchers.IO) {
           Logger.d("${Thread.currentThread().name}")
           ServiceCreator.create(PlaceService::class.java).login().execute() }
       tv_detail2.text = response2.await().body().toString()
   }

Dispatchers.IO 又是什么

先说结论,它是抽象类 CoroutineDispatcher 的一个实现,它是 CoroutineContext 接口的一个实现。那么什么是 CoroutineContext,字面意思:协程上下文。

Persistent context for the coroutine. It is an indexed set of [Element] instances.
An indexed set is a mix between a set and a map.
Every element in this set has a unique [Key]. Keys are compared by reference.
*协程的持久上下文。它是一组索引的[元素]实例。
*索引集是集和映射的混合。
*这个集合中的每个元素都有一个唯一的[键]。通过参考比较键。

故而当我们传入 Dispatchers.IO 时,这个新的协程被创建在 Dispatchers.IO 对应的上下文中,而非当前的主线程,所以才不会导致 NetworkOnMainThreadException

CoroutineDispatcher 定义了 Coroutine 执行的线程。CoroutineDispatcher 可以限定 Coroutine 在某一个线程执行、也可以分配到一个线程池来执行、也可以不限制其执行的线程。

在 Dispatchers 中 有以下四个常用的实现:

  1. Default
  2. Main
  3. Unconfined(一般而言我们不使用 Unconfined)
  4. IO

子协程会集成父协程的 context,所以如果不需要切换协程所在线程,我们只需在父类设置 Dispatcher

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章