如果查看协程构建器的定义,你将看到它们的第一个参数类型是 CoroutineContext。
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
...
}
函数的接收者和最后一个参数的接收者都是 CoroutineScope 类型,这个 CoroutineScope 似乎是一个重要的概念,所以来看看它的定义:
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
它似乎只是 CoroutineContext 的包装器,由此你可能会想到 Continuation 是如何定义的:
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
Continuation 也包含着 CoroutineContext。既然 Kotlin 最重要的协程元素也用着它,那么它一定是一个非常重要的概念,它是什么呢?
CoroutineContext 接口
CoroutineContext 是一个表示元素或元素集合的接口。它在概念上类似于 map 或者 set :它是有索引的 Element 实例集,如 Job、CoroutineName、CoroutineDispatcher 等。不同寻常的是,每个 Element 也是一个 CoroutineContext。因此,集合中的每个元素自己本身就是一个集合。
这个概念很直观,想象一个杯子,它是单个元素,但是也是包含多个元素的集合。当你添加另一个杯子时,你就拥有了一个包含了两个元素的集合。
为了方便地规范和修改上下文,每个 CoroutineContext 的元素本身就是一个 CoroutineContext,如下面的例子所示(添加上下文和设置协程构建器上下文将在后面解释)。仅仅指定或添加上下文要比显示的创建集合要容易的多:
launch(CoroutineName("Name1")) { ... }
launch(CoroutineName("Name2") + Job()) { ... }
这个集合中的每一个元素都有唯一的 Key 来标识它。这些键通过引用进行比较。
例如 CoroutineName 或 Job 实现了 CoroutineContext.Element 接口,而该接口又实现了 CoroutineContext 接口。
fun main() {
val name: CoroutineName = CoroutineName("A name")
val element: CoroutineContext.Element = name
val context: CoroutineContext = element
val job: Job = Job()
val jobElement: CoroutineContext.Element = job
val jobContext: CoroutineContext = jobElement
}
这与 SuperviseJob、CoroutineExceptionHandler 和 Dispatcher 中的分发器是一样的。这些是最重要的协程上下文。它们将在下一章中解释。
在 CoroutineContext 中找到元素
由于 CoroutineContext 类似于集合,我们可以使用 get 找到具有相同键的元素。另一种选择是使用方括号,因为在 Kotlin 中,get 方法是一个操作符,可以使用方括号来调用。就像在 Map 中,当元素在上下文时,它将被返回,否则返回null。
fun main() {
val ctx: CoroutineContext = CoroutineName("A name")
val coroutineName: CoroutineName? = ctx[CoroutineName]
// or ctx.get(CoroutineName)
println(coroutineName?.name) // A name
val job: Job? = ctx[Job] // or ctx.get(Job)
println(job) // null
}
要查找 CoroutineName,我们只需传入 CoroutineName。它不是类或类型,而是一个伴生对象。这是 Kotlin 中的一个特性:一个类的名字可以被用做它的伴生对象的引用,所以 ctx[CoroutineName] 是 ctx[CoroutineName.Key] 的便捷写法。
data class CoroutineName(
val name: String
) : AbstractCoroutineContextElement(CoroutineName) {
override fun toString(): String = "CoroutineName($name)"
companion object Key : CoroutineContext.Key<CoroutineName>
}
这是 Kotlinx.coroutines 的常见做法,使用伴生对象作为作为同名元素的键。这样更加容易记住。一个键可能指向一个类(CoroutineName),或一个接口(Job),该接口有许多具有相同键的类实现(如 Job 和 SupervisorJob):
interface Job : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<Job>
// ...
}
添加上下文
CoroutineContext 真正有用的地方在于它能够将两者合并到一起。
当添加两个具有不同键的元素时,最终的上下文将会响应这两个键。
fun main() {
val ctx1: CoroutineContext = CoroutineName("Name1")
println(ctx1[CoroutineName]?.name) // Name1
println(ctx1[Job]?.isActive) // null
val ctx2: CoroutineContext = Job()
println(ctx2[CoroutineName]?.name) // null
println(ctx2[Job]?.isActive) // true, 因为 “Active” 是job创建后的初始状态
val ctx3 = ctx1 + ctx2
println(ctx3[CoroutineName]?.name) // Name1
println(ctx3[Job]?.isActive) // true
当添加具有相同的键的另一个元素时,就像在 map 中一样,新元素将替换前一个元素。
fun main() {
val ctx1: CoroutineContext = CoroutineName("Name1")
println(ctx1[CoroutineName]?.name) // Name1
val ctx2: CoroutineContext = CoroutineName("Name2")
println(ctx2[CoroutineName]?.name) // Name2
val ctx3 = ctx1 + ctx2
println(ctx3[CoroutineName]?.name) // Name2
}
空的协程上下文
因为 CoroutineContext 就像一个集合,所以我们也有一个空的上下文。这样的上下文本身不返回任何元素,如果我们把它添加到另一个上下文去,最终的行为和被添加的上下文完全一样:
fun main() {
val empty: CoroutineContext = EmptyCoroutineContext
println(empty[CoroutineName]) // null
println(empty[Job]) // null
val ctxName = empty + CoroutineName("Name1") + empty
println(ctxName[CoroutineName]) // CoroutineName(Name1)
}
删去元素
还可以使用 minusKey 函数通过传入元素的键,从上下文中删除指定元素。
CoroutineContext 的减号操作符没有被重载,我认为这是因为它的含义还不够清晰,正如 《Effective Kotlin》中第12条:操作符的行为应该与其名称一致所阐述的那样。
fun main() {
val ctx = CoroutineName("Name1") + Job()
println(ctx[CoroutineName]?.name) // Name1
println(ctx[Job]?.isActive) // true
val ctx2 = ctx.minusKey(CoroutineName)
println(ctx2[CoroutineName]?.name) // null
println(ctx2[Job]?.isActive) // true
val ctx3 = (ctx + CoroutineName("Name2"))
.minusKey(CoroutineName)
println(ctx3[CoroutineName]?.name) // null
println(ctx3[Job]?.isActive) // true
}
折叠上下文
如果我们需要对上下文中的每个元素执行某些操作,可以使用 fold 方法,该方法与集合的 fold 功能类似,它具备:
- 累加器初始值
- 根据累加器的当前状态和当前被调用的元素,生成累加器下一个状态的操作
fun main() {
val ctx = CoroutineName("Name1") + Job()
ctx.fold("") { acc, element -> "$acc$element " }
.also(::println)
// CoroutineName(Name1) JobImpl{Active}@dbab622e
val empty = emptyList<CoroutineContext>()
ctx.fold(empty) { acc, element -> acc + element }
.joinToString()
.also(::println)
// CoroutineName(Name1), JobImpl{Active}@dbab622e
}
协程上下文和构建器的关系
所以,CoroutineContext 只是保存和传递数据的一种方式。默认情况下,父协程将上下文传递给子协程,这是父协程与子协程关系产生的一种效果。我们会这样描述:子协程继承了父协程的上下文。
fun CoroutineScope.log(msg: String) {
val name = coroutineContext[CoroutineName]?.name
println("[$name] $msg")
}
fun main() = runBlocking(CoroutineName("main")) {
log("Started") // [main] Started
val v1 = async {
delay(500)
log("Running async") // [main] Running async
42
}
launch {
delay(1000)
log("Running launch") // [main] Running launch
}
log("The answer is ${v1.await()}")
// [main] The answer is 42
}
每个子协程都可以在参数中定义一个特定的上下文,这个上下文会覆盖来自父协程的上下文:
fun main() = runBlocking(CoroutineName("main")) {
log("Started") // [main] Started
val v1 = async(CoroutineName("c1")) {
delay(500)
log("Running async") // [c1] Running async
42
}
launch(CoroutineName("c2")) {
delay(1000)
log("Running launch") // [c2] Running launch
}
log("The answer is ${v1.await()}")
// [main] The answer is 42
}
一个计算协程上下文的简化公式是:
defaultContext + parentContext +
子协程的上下文总是覆盖父协程上下文中具有相同键的元素。默认值仅用于未指定时。目前,当没有设置 ContinuationInterceprot 时,默认值为 Dispatcher.Default ,并且只有当应用程序在调试模式时才设置 CoroutineId。
有一个特殊的上下文叫做 Job,它是可变的,用于父协程与子协程的通信。接下来的章节将会专门讨论这种通信的影响。
在挂起函数中访问上下文
CoroutineScope 有一个可用于访问上下文的 coroutineContext 属性,但是在一个普通的挂起函数中,是如何拥有上下文呢?你可能还记得,在底层的协程中一章说到,上下文被 continuation 引用, continuation 被传递给每个挂起函数。因此,可以在挂起函数中访问父协程的上下文。为此,我们可以直接使用 coroutineContext 属性,该属性可以用于每个挂起的作用域。
suspend fun printName() {
println(coroutineContext[CoroutineName]?.name)
}
suspend fun main() = withContext(CoroutineName("Outer")) {
printName() // Outer
launch(CoroutineName("Inner")) {
printName() // Inner
}
delay(10)
printName() // Outer
}
创建我们专属的上下文
这不是一个常见的需求,但是我们可以很容易地创建自己专属的协程上下文。为此,最简单的方法是创建一个实现了 CoroutinContext.Element 接口的类。这样的类需要 CoroutineContext.Key<*> 类型的属性作为键。此键将用作标识上下文的键。通常的做法是使用该类的伴生对象作为键。下面是一个非常简单的实现协程上下文的方式:
class MyCustomContext : CoroutineContext.Element {
override val key: CoroutineContext.Key<*> = Key
companion object Key :
CoroutineContext.Key<MyCustomContext>
}
这样的上下文非常像 CoroutineName:它将父协程传递到子协程,但任何子协程都可以用相同键的不同上下文覆盖它。要在实践中了解这一点,下面可以看到一个用于打印连续数字的上下文示例:
class CounterContext(
private val name: String
) : CoroutineContext.Element {
override val key: CoroutineContext.Key<*> = Key
private var nextNumber = 0
fun printNext() {
println("$name: $nextNumber")
nextNumber++
}
companion object Key:CoroutineContext.Key<CounterContext>
}
suspend fun printNext() {
coroutineContext[CounterContext]?.printNext()
}
suspend fun main(): Unit = withContext(CounterContext("Outer")) {
printNext() // Outer: 0
launch {
printNext() // Outer: 1
launch {
printNext() // Outer: 2
}
launch(CounterContext("Inner")) {
printNext() // Inner: 0
printNext() // Inner: 1
launch {
printNext() // Inner: 2
}
}
}
printNext() // Outer: 3
}
我有看到自定义上下文被用作一种依赖注入的方式 —— 在生产环境中比测试环境中更容易注入不同的值,然而,我不认为这将成为标准的做法:
data class User(val id: String, val name: String)
abstract class UuidProviderContext : CoroutineContext.Element {
abstract fun nextUuid(): String
override val key: CoroutineContext.Key<*> = Key
companion object Key :CoroutineContext.Key<UuidProviderContext>
}
class RealUuidProviderContext : UuidProviderContext() {
override fun nextUuid(): String = UUID.randomUUID().toString()
}
class FakeUuidProviderContext(
private val fakeUuid: String
) : UuidProviderContext() {
override fun nextUuid(): String = fakeUuid
}
suspend fun nextUuid(): String =
checkNotNull(coroutineContext[UuidProviderContext]) {
"UuidProviderContext not present"
}
.nextUuid()
// 下面是测试函数
suspend fun makeUser(name: String) = User(
id = nextUuid(),
name = name
)
suspend fun main(): Unit {
// 生产环境中的用例
withContext(RealUuidProviderContext()) {
println(makeUser("Michał"))
// e.g. User(id=d260482a-..., name=Michał)
}
// 测试用例
withContext(FakeUuidProviderContext("FAKE_UUID")) {
val user = makeUser("Michał")
println(user) // User(id=FAKE_UUID, name=Michał)
assertEquals(User("FAKE_UUID", "Michał"), user)
}
}
总结
CoroutineContext 在概念上类似于集合或映射。它是有索引的 Element 实例集,其中每个 Element 也是一个 CoroutinContext,它里面的每个元素都有唯一的 Key 用来标识它。这样, CoroutineContext 就是一种通用的将对象分组并传递给协程的方法。这些对象由协程保存,并可以决定这些协程应该如何运行(它们的状态是什么,在哪个线程,等等)。在下一章中,我们将讨论 Kotlin 协程库中最重要的协程上下文。









