掌握协程的边界与环境:CoroutineScope 与 CoroutineContext

CoroutineScope 与 CoroutineContext 的概念

CoroutineContext (协程上下文)

CoroutineContext 是协程上下文,包含了协程运行时所需的所有信息。

比如:

  • 管理协程流程(生命周期)的 Job
  • 管理线程的 ContinuationInterceptor,它的实现类 CoroutineDispatcher 决定了协程所运行的线程或线程池。

CoroutineScope (协程作用域)

CoroutineScope 是协程作用域,它通过 coroutineContext 属性持有了当前协程代码块的上下文信息。

比如,我们可以获取 JobContinuationInterceptor 对象:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext) // scope 并没有持有已有协程的上下文
    val outerJob = scope.launch {
        val innerJob = coroutineContext[Job]
        val interceptor = coroutineContext[ContinuationInterceptor]
        println("job: $innerJob, interceptor: $interceptor")
    }

    outerJob.join()
}

CoroutineScope 的另一个作用就是提供了 launchasync 协程构建器,我们可以通过它来启动一个协程。

这样,新创建的协程能够自动继承 CoroutineScopecoroutineContext。比如利用 Job,可以建立起父子关系,从而实现结构化并发。

GlobalScope

GlobalScope 是一个单例的 CoroutineScope 对象,所以我们在任何地方通过它来启动协程。

它的第二个特点是,它的 coroutineContext 属性是 EmptyCoroutineContext,也就是说它没有内置的 Job

kotlin 复制代码
@DelicateCoroutinesApi
public object GlobalScope : CoroutineScope {
    /**
     * Returns [EmptyCoroutineContext].
     */
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

即使是我们手动创建的 CoroutineScope,其内部也是有 Job 的。

kotlin 复制代码
// 手动创建 CoroutineScope
CoroutineScope(EmptyCoroutineContext)

// CoroutineScope.kt
@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job()) // 自动创建Job对象

所以我们在 GlobalScope.coroutineContext 中是获取不到 Job 的:

kotlin 复制代码
@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking<Unit> {
    val job: Job? = GlobalScope.coroutineContext[Job]
    if (job == null) {
        println("job is null")
    }
    try {
        val jobNotNull: Job = GlobalScope.coroutineContext.job
    } catch (e: IllegalStateException) {
        println("job is null, exception is: $e")
    }
}

运行结果:

less 复制代码
job is null
job is null, exception is: java.lang.IllegalStateException: Current context doesn't contain Job in it: EmptyCoroutineContext

那么,这有什么用吗?

其实,GlobalScope 所启动的协程没有父 Job

这就意味着:

  • 当前协程不和其他 Job 的生命周期绑定,比如不会随着某个界面的关闭而自动取消。
  • 它是顶级协程,生命周期默认为整个应用的生命周期。
  • 它发生异常,并不会影响到其他协程和 GlobalScope。反之,GlobalScope 本身也无法级联取消所有任务,因为它所启动的协程是完全独立的。

总结:GlobalScope 就是用来启动那些不与组件生命周期绑定,而是与整个应用生命周期保持一致的全局任务,比如一个日志上报任务。

关键在使用时,可能会有资源泄露的风险,需要正确管理好协程的生命周期。

Context 的三个实用工具

在挂起函数中获取 CoroutineContext

如果我们要在一个挂起函数中获取 CoroutineContext,我们不得不给将其作为 CoroutineScope 的扩展函数。

kotlin 复制代码
suspend fun CoroutineScope.printContinuationInterceptor() {
    delay(1000)
    val interceptor = coroutineContext[ContinuationInterceptor]
    println("interceptor: $interceptor")
}

但我们知道挂起函数的外部一定有协程存在,所以是存在 CoroutineContext 的。为此,Kotlin 协程库提供了一个顶层的 coroutineContext 属性,这个属性的 get() 函数是一个挂起函数,它能在任何挂起函数中访问到当前正在执行的协程的 CoroutineContext

kotlin 复制代码
import kotlin.coroutines.coroutineContext

suspend fun printContinuationInterceptor() {
    delay(1000)
    val interceptor = coroutineContext[ContinuationInterceptor]
    println("interceptor: $interceptor")
}

另外,还有一个 currentCoroutineContext() 函数也能获取到 CoroutineContext,它内部实现也是 coroutineContext 属性。

为什么需要这个函数?

为了解决命名冲突,比如下面这段代码。

kotlin 复制代码
private fun mySuspendFun() {
    flow<String> {
        // 顶层属性
        coroutineContext 
    }

    GlobalScope.launch {
        flow<String> {
            // this 的成员属性优先级高于顶层属性
            // 所以是外层 launch 的 CoroutineScope 的成员属性 coroutineContext
            coroutineContext
        }
    }
}

在这种情况下,如果需要明确属性的源头,就需要使用 currentCoroutineContext() 函数,它会调用到那个顶层的属性。

CoroutineName 协程命名

CoroutineName 是一个协程上下文信息,我们可以使用它来给协程设置一个名称。

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val name = CoroutineName("coroutine-1")
    val job = scope.launch(name) {
        val coroutineName = coroutineContext[CoroutineName]
        println("current coroutine name: $coroutineName")
    }

    job.join()
}

运行结果:

less 复制代码
current coroutine name: CoroutineName(coroutine-1)

它主要用于测试和调试,你可以使用它来区分哪些日志是哪个协程打印的。

自定义 CoroutineContext

如果我们要给协程附加一些功能,我们可以考虑自定义 CoroutineContext

如果是简单的标记,可以优先考虑使用 CoroutineName

自定义 CoroutineContext 需要实现 CoroutineContext.Element,并且提供 Key。为此,Kotlin 协程库提供了 AbstractCoroutineContextElement 来简化这个过程。我们只需这样,即可创建一个用于协程内部记录日志的 Context

kotlin 复制代码
// 继承 AbstractCoroutineContextElement,并把 Key 传给父构造函数
class CoroutineLogger(val tag: String) : AbstractCoroutineContextElement(CoroutineLogger) {

    // 声明专属的 Key
    companion object Key : CoroutineContext.Key<CoroutineLogger>

    // 添加专属功能
    fun log(message: String) {
        println("[$tag] $message")
    }
}

使用示例:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(Dispatchers.Default)
    val job = scope.launch(CoroutineLogger("Test")) {
        val logger = coroutineContext[CoroutineLogger]
        logger?.log("Start")
        delay(5000)
        logger?.log("End")
    }
    job.join()
}

运行结果:

less 复制代码
[Test] Start
[Test] End

coroutineScope() 与 withContext()

coroutineScope 串行的异常封装器

coroutineScope 是一个挂起函数,它会挂起当前协程,直到执行完内部的所有代码(包括会等待内部启动的所有子协程执行完毕),最后一行代码的执行结果会作为函数的返回值。

coroutineScope 会创建一个新的 CoroutineScope,在这个作用域中执行 block 代码块。并且这个作用域严格继承了父上下文(coroutineContext),并会在内部创建一个新的 Job,作为父 Job 的子 Job

coroutineScope 从效果上来看,和 launch().join() 类似。

那么,它的应用场景是什么?

它的应用场景由它的特性决定,有两个核心场景:

  1. 在挂起函数中提供 CoroutineScope。(最常用)

    kotlin 复制代码
    suspend fun CoroutineScope.mySuspendFunction() {
        delay(1000)
        launch {
            println("launch")
        }
    }

    如果你要在挂起函数中启动一个新的协程,你只好将其定义为 CoroutineScope 的扩展函数。不过,你也可以使用 coroutineScope 来提供作用域。

    它能提供作用域,是因为挂起函数的外部一定存在着协程,所以一定具有 CoroutineScope

    kotlin 复制代码
    suspend fun doConcurrentWork() {
        val startTime = System.currentTimeMillis()
        coroutineScope {
            val task1 = async { // 任务1
                delay(5000)
            }
            val task2 = async { // 任务2
                delay(3000)
            }
        } // // 挂起,直到上面两个 async 都完成
        val endTime = System.currentTimeMillis()
        println("Total execution time: ${endTime - startTime}") // 5000 左右
    }
  2. 业务逻辑封装并进行异常处理。(最重要)

    我们都知道,我们无法在协程外部使用 try-catch 捕获协程内部的异常。

    但使用 coroutineScope 函数可以,当它内部的任何子协程失败了,它会将这个异常重新抛出来,这时我们可以使用 try-catch 来捕获。

    kotlin 复制代码
    fun main() = runBlocking<Unit> {
        try {
            coroutineScope {
                val data1 = async {
                    "user-1"
                }
                val data2 = async {
                    throw IllegalStateException("error")
                }
    
                awaitAll(data1, data2)
            }
        } catch (e: Exception) {
            println("exception is: $e")
        }
    }

    运行结果:

    less 复制代码
    exception is: java.lang.IllegalStateException: error

    原因也很简单,因为它是一个串行的挂起函数,外部协程会被挂起,直到它执行完毕。如果它的内部出现了异常,外部协程是能够知晓的。

    coroutineScope 可以将并发的崩溃变为可被捕获、处理的异常,常用于处理并发错误。

withContext 串行的上下文切换器

我们再来看 withContext,其实它和 coroutineScope 几乎一样。

它也是一个串行的挂起函数,也会返回代码块的结果,内部也是启动了一个新的协程。

它和 coroutineScope 的唯一的不同是,withContext 允许我们传递上下文。你也可以这么想,coroutineScope 就是一个不改变任何上下文的 withContext

kotlin 复制代码
withContext(EmptyCoroutineContext) { // 沿用旧的 CoroutineContext

}

withContext(coroutineContext) { // 使用旧的 CoroutineContext

}

withContext 的使用场景就很清楚了,我们需要切换上下文的时候会使用它,并且希望代码是串行执行的,之后还能再切回原来的线程继续往下执行。

虽然 withContextcoroutineScope 类似,但 coroutineScope 更多用于封装业务异常。

kotlin 复制代码
suspend fun getUserProfile() {
    // 当前在 Dispatchers.Main
    val profile = withContext(Dispatchers.IO) {
        // 自动切换到 IO 线程
        Thread.sleep(3000) // 耗时操作
        "the user profile"
    }

    // 自动切回 Dispatchers.Main
    println("the user profile is $profile")
}

CoroutineContext 的加、取操作

加法:合并与替换

两个 CoroutineContext 相加调用的是 plus()

kotlin 复制代码
public operator fun plus(context: CoroutineContext): CoroutineContext =
    if (context === EmptyCoroutineContext) this else // fast path -- avoid lambda creation
        context.fold(this) { acc, element ->
            val removed = acc.minusKey(element.key)
            if (removed === EmptyCoroutineContext) element else {
                // make sure interceptor is always last in the context (and thus is fast to get when present)
                val interceptor = removed[ContinuationInterceptor]
                if (interceptor == null) CombinedContext(removed, element) else {
                    val left = removed.minusKey(ContinuationInterceptor)
                    if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
                        CombinedContext(CombinedContext(left, element), interceptor)
                }
            }
        }

其中关键在于 CombinedContext,它是 CoroutineContext 的实现类:

kotlin 复制代码
// CoroutineContextImpl.kt
@SinceKotlin("1.3")
internal class CombinedContext(
    private val left: CoroutineContext,
    private val element: Element // Element 也是 `CoroutineContext` 的实现类
) : CoroutineContext, Serializable 

它会将操作符两边的上下文使用 CombinedContext 对象包裹(合并),如果两个上下文具有相同的 Key,加号右侧的会替换左侧的。

比如 Dispatchers.IO + Job() + CoroutineName("my-name") 一共会进行三次合并,得到三个 CombinedContext 对象,不会进行替换。

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val handler = CoroutineExceptionHandler { _, throwable ->
        println("Caught $throwable")
    }
    val job =
        launch(Dispatchers.IO + Job() + CoroutineName("my-name") + handler) { 
            println(coroutineContext)
        }
    job.join()
}

运行结果:

less 复制代码
[CoroutineName(my-name), com.example.coroutinetest.TestKtKt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6667fe3b, StandaloneCoroutine{Active}@26b2b4fd, Dispatchers.IO]

如果在末尾再加上一个 CoroutineName("your_name"),会进行一次替换,运行结果是:[com.example.coroutinetest.TestKtKt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6667fe3b, CoroutineName(your_name), StandaloneCoroutine{Active}@26b2b4fd, Dispatchers.IO]

[] 取值

[] 取值其实调用的是 CoroutineContext.get() 函数,它会从上下文(CombinedContext 树)中找到我们需要的信息。

kotlin 复制代码
@SinceKotlin("1.3")
public interface CoroutineContext {
    /**
     * Returns the element with the given [key] from this context or `null`.
     */
    public operator fun <E : Element> get(key: Key<E>): E?
        
    // ...
}

我们填入的参数其实是每一个接口的伴生对象 Key,每个伴生对象都实现了 CoroutineContext.Key<T> 接口,并将泛型指定为了当前接口。

ContinuationInterceptor 为例:

kotlin 复制代码
@SinceKotlin("1.3")
public interface ContinuationInterceptor : CoroutineContext.Element {
    /**
     * The key that defines *the* context interceptor.
     */
    public companion object Key : CoroutineContext.Key<ContinuationInterceptor>
    
    // ...
}

比如我们要获取上下文中的 CoroutineDispatcher,我们可以这样做:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(Dispatchers.Default)
    val job = scope.launch {
        val dispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher // 强转
        println("CoroutineDispatcher is $dispatcher")
    }
    job.join()
}
相关推荐
木易 士心6 小时前
Android 开发核心知识体系与面试指南精简版
android·面试·职场和发展
一棵树73517 小时前
Android OpenGL ES初窥
android·大数据·elasticsearch
初级代码游戏7 小时前
MAUI劝退:安卓实体机测试
android
奔跑中的蜗牛6668 小时前
直播APP跨平台架构实践(二):KMP UI 与 Rust 下载引擎协作实践
android
沐怡旸8 小时前
【底层机制】【Android】AIDL原理与实现机制详解
android·面试
小仙女喂得猪8 小时前
2025 跨平台方案KMP,Flutter,RN之间的一些对比
android·前端·kotlin
2501_915106328 小时前
iOS 混淆与 IPA 加固全流程,多工具组合实现无源码混淆、源码防护与可审计流水线(iOS 混淆|IPA 加固|无源码加固|App 防反编译)
android·ios·小程序·https·uni-app·iphone·webview
游戏开发爱好者88 小时前
用多工具组合把 iOS 混淆做成可复用的工程能力(iOS混淆 IPA加固 无源码混淆 Ipa Guard)
android·ios·小程序·https·uni-app·iphone·webview
尤老师FPGA9 小时前
LVDS系列32:Xilinx 7系 ADC LVDS接口参考设计(三)
android·java·ui