Kotlin协程完全解析

4,139 阅读14分钟

PS:B站也有我的视频分享,内容和本文有重叠也有不同,结合来看效果更好喔:Android 深入理解协程 BaguTree周六茶话

(内容略长,前三点是重点,四五六点建议结合源码观看)

一、Continuation Passing Style (CPS)

协程在多种语言中都有应用,那它们通用的指导概念是什么?那就是CPS

1、原理

(1)举个例子,平时使用的直接编码风格Direct Style

// 三个步骤,发送一个token
fun postItem(item: Item) {
    val token = generateToken()         // step1
    val post = createPost(token, item)  // step2
    processPost(post)                   // step3
}

CPS的 C 是Continuation,是续体的意思,上面代码中 step2、step3 是 step1 的续体,step3 是 step2 的续体

(2)将上述代码进行CPS转化后,变成传接续体风格Continuation Passing Style

fun postItem(item: Item) {
    generateToken { token ->              // setp1
        createPost(token, item) { post -> // step2
            processPost(post)             // step3
        }
    }
}

这个变化后可以发现,CPS转化其实是将下一步变成了回调。结论:CPS == Callbacks

(3)Kotlin 提供了一种Direct Style的写法,而且同时能实现CPS的效果。为什么能这么实现?因为 kotlin在编译的时候替我们实现了回调。看个例子,我们将上面的例子写成kotlin的代码:

suspend fun postItem(item: Item) {
    val token = generateToken()         // 在上面的例子中需要传送续体(callback),所以这个方法也是 suspend 方法
    val post = createPost(token, item)  // 也是 suspend 方法
    processPost(post)
}

将上述代码编译成Java后,会发现带suspend的方法 在编译后方法签名都会增加一个续体参数,下面对比一下

// 编译前:Kotlin
suspend fun createPost(token: Token, item: Item): Post { … }
// 编译后:Java/JVM(cont其实是一个callback,Post为结果)
Object createPost(Token token, Item item, Continuation<Post> cont) { … }

回到上面的举例代码,其中前两步每一步都会产生结果,而每一步的结果都需要传递给下一步,所以可以思考一下,续体的作用是什么?

总结:续体负责将当前步骤结果传递给下个步骤,同时移交下一步的调用权

调用权是怎么产生的?是因为我们将下一步的代码打包成了对象,继续传递下去,所以继续执行下一步的调用权会移交给后面执行的代码(这才是异步执行的核心思想)。

2、续体在kotlin中的声明

下面是Kotlin中续体接口,看下这个接口的注释,suspension point就是标注suspend的方法

image.png

这个接口内有一个对象和一个方法:

  • CoroutineContext:是一个链表结构,可以使用「+」操作符,其中包含协程执行时需要的一些参数:名称/ID、调度器 Dispatcher、控制器 Job、异常 Handler等(把 Job 称为「控制器」感觉好理解一些)
  • resumeWith:触发下一步的方法,参数 result 是当前步骤的结果(上面说到了调用权resumeWith就是调用入口)

「Continuation is a generic callback interface」: Continuation是一个续体通用的回调接口

二、State Machine

状态机是一个可以控制状态的对象

续体会缓存结果递交给下一步,状态机会缓存步骤编号,并在每一步触发的时候将状态改为下一步,以实现步骤切换

1、续体+状态机

kotlin中的suspend方法最终会被编译成一个状态机,下面举个例子,来看看suspend方法是如何转换的

(1)我们先给每个步骤编号:

suspend fun postItem(item: Item) {
    switch (label) {
        case 0:
            generateToken()
        case 1:
            createPost(token, item)
        case 2:
            processPost(post)
    }
}

(2)然后进行CPS转换,加上续体,实现结果传递、调用权转移;同时看到转换后有个「resume」方法,每一次触发下一步都会调用该方法,同时在「postItem」内实现状态机,保证每次调用会触发下一步骤

// 入口:postItem
fun postItem(item: Item, cont: Continuation) {
    val sm = object : CoroutineImpl(cont) { // cont是父协程的续体,在当前协程结束时会触发cont的resume
        fun resume(...) {
            postItem(null, this)    // 续体回调入口
        }
    }
    switch (sm.label) {
        case 0:
            sm.item = item      // 初始参数,执行过程中传递的一些参数
            sm.label = 1        // 状态控制
            requestToken(sm)    // step1,传入续体,执行完成后调用resume,并将结果传递下去
        case 1:
            val token = sm.result as Token  // 从续体中拿取上一步的结果
            val item = sm.item              // 从续体中拿取初始参数
            sm.label = 2                    // 状态控制
            createPost(token, item, sm)     // step2,传入续体&参数,执行完成后调用resume,并将结果传递下去
        case 2:
            val post = sm.result as Post    // 从续体中拿取上一步的结果
            processPost(post, sm)           // spte3
    }
}

仔细看上面的额注释,想象一下执行过程,首先进入「case 0」然后「label」变成1,在「requestToken」方法内调用一次「sm」对象的「resume」,再次进入「postItem」,创建新的「sm」,此时假设新的「sm」会继承「cont」的数据,那么会进入「case 1」......

再复习一下:

续体传递结果,移交下一步调用权

状态机实现每调用一次同一个方法,就执行一个步骤

2、这两个东西结合可以实现什么?

有了续体&状态机我们可以做什么?

╮( ̄▽  ̄)╭ 「在任意的时候发起下一步」

想象一下,如果我只会调用「resumeWith」...... 没关系,续体&状态机帮我们完成了一切(参数传递、步骤切换),我们只用在任意时刻任意线程中调用「resumeWith」来触发下一步

三、launch执行过程

1、整体流程

明白什么是续体状态机后,我们来看一下协程发起的过程,从CoroutineScope.launch看起:

image.png

源码就不一一截图了,整体流程如下图,从左上角开始:

image.png

上图浅红色的是重点,后续会重点展开讲createCoroutineUnintercepted的反编译的代码,先看一下上图的几个关键对象:

(1)SuspendLambda

SuspendLambda是一个续体的实现,下面是SuspendLambda继承关系,可以看到它是已经实现了resumeWith方法了

image.png

(2)Dispatcher

Dispatcher续体分发器,它有多种实现,最简单的就是Dispatchers.Main,我们也从这个源码开始看起。我们先来看下继承关系,可以先简单的认为,各类Dispatcher的爷爷就是ContinuationInterceptor,爹就是CoroutineDispatcher

image.png

上图中有两种Dispatcher,其中HandlerContextdispatch实现会回调到主线程:

image.png

(3)DispatchedContinuation

DispatchedContinuation也是续体,不过它是一个续体的委托类,内部持有一个续体对象。来看一下DispatchedContinuation的继承关系,SchedulerTaskrun方法中调用「resume相关方法 & 异常Handler」

image.png

因为续体resumeWith都交由DispatchedContinuationrun方法调用,所以会称之为续体的委托控制者

image.png

(看到这个by是不是有一种恍然大悟的感觉,可读性大大提升)

2、反编译createCoroutineUnintercepted方法

其他方法基本都有源码可看,就这个方法隐藏的特别深,最终找到kotlin开源项目中的代码:kotlin/IntrinsicsJvm.kt at master · JetBrains/kotlin (github.com)

image.png

来看下这个方法的说明:

  1. Creates unintercepted coroutine with receiver type [R] and result type [T]. 使用receiver和result的类型创建一个不可打断的协程(receiver可以忽略,关注result即可)

  2. This function creates a new, fresh instance of suspendable computation every time it is invoked. 这个方法会创建suspend相关逻辑(核心逻辑实际上是编译器创建的,这里面只执行一个new)

  3. To start executing the created coroutine, invoke `resume(Unit)` on the returned [Continuation] instance. 通过resume方法启动协程

  4. The [completion] continuation is invoked when coroutine completes with result or exception. completion是一个续体,在这里创建的协程结束或者异常时会被调用

先说一个结论,图中的「this」是一个继承BaseContinuationImpl的对象,为什么呢,接着看下面反编译分析

3、协程创建执行过程分析

(1)模拟launch过程发起协程

因为直接反编译标准协程代码并不能非常直接的看到调用过程,所以我们根据createCoroutineUnintercepted的解释发起协程

import kotlinx.coroutines.delay
import kotlin.coroutines.*
import kotlin.coroutines.intrinsics.createCoroutineUnintercepted
 
fun main() {
    launch3 {
        print("before")
        delay(1_000)
        print("\nmiddle")
        delay(1_000)
        print("\nafter")
    }
    Thread.sleep(3_000)
}
 
fun <T> launch3(block: suspend () -> T) {
    // 1、传入代码块block,使用block创建协程,
    // 2、同时自行创建一个续体,「resumeWith」最终会被调用
    val coroutine = block.createCoroutineUnintercepted(object : Continuation<T> {
        override val context: CoroutineContext
            get() = EmptyCoroutineContext
 
        override fun resumeWith(result: Result<T>) {
            println("\nresumeWith=$result")
        }
    })
    // 3、执行block协程
    coroutine.resume(Unit)
}

确认执行结果:

before
middle
after
resumeWith=Success(kotlin.Unit)

(2)反编译查看创建过程

以上操作主要为了能更清晰的看到反编译的代码,我们使用dx2jar进行反编译,看以下代码中注释的1-3步

import kotlin.Metadata;
import kotlin.Result;
import kotlin.ResultKt;
import kotlin.Unit;
import kotlin.coroutines.Continuation;
import kotlin.coroutines.CoroutineContext;
import kotlin.coroutines.EmptyCoroutineContext;
import kotlin.coroutines.intrinsics.IntrinsicsKt;
import kotlin.coroutines.jvm.internal.DebugMetadata;
import kotlin.coroutines.jvm.internal.SuspendLambda;
import kotlin.jvm.functions.Function1;
import kotlin.jvm.internal.Intrinsics;
import kotlinx.coroutines.DelayKt;
 
public final class KTest6Kt {
 
  public static final void main() {
    // 1、编译后,代码块变为继承「SuspendLambda」的对象,该对象还实现了Function接口,是一个「续体+状态机」的对象
    launch3(new KTest6Kt$main$1(null));
    Thread.sleep(3000L);
  }
 
  public static final <T> void launch3(Function1<? super Continuation<? super T>, ? extends Object> paramFunction1) {
    Intrinsics.checkNotNullParameter(paramFunction1, "block");
    // 2、「createCoroutineUnintercepted」会创建一个新的「KTest6Kt$main$1」对象(为什么?),传入我们自定义的续体
    Continuation continuation = IntrinsicsKt.createCoroutineUnintercepted(paramFunction1, new KTest6Kt$launch3$coroutine$1());
    Unit unit = Unit.INSTANCE;
    Result.Companion companion = Result.Companion;
    // 3、启动协程
    continuation.resumeWith(Result.constructor-impl(unit));
  }
   
  // 我们自定义的续体的内部类
  public static final class KTest6Kt$launch3$coroutine$1 implements Continuation<T> {
    public CoroutineContext getContext() {
      return (CoroutineContext)EmptyCoroutineContext.INSTANCE;
    }
     
    public void resumeWith(Object param1Object) {
      StringBuilder stringBuilder = new StringBuilder();
      stringBuilder.append("\nresumeWith=");
      stringBuilder.append(Result.toString-impl(param1Object));
      param1Object = stringBuilder.toString();
      System.out.println(param1Object);
    }
  }
   
  // 协程「续体+状态机」的内部类
  static final class KTest6Kt$main$1 extends SuspendLambda implements Function1<Continuation<? super Unit>, Object> {
    int label;
     
    KTest6Kt$main$1(Continuation param1Continuation) {
      super(1, param1Continuation);
    }
     
    public final Continuation<Unit> create(Continuation<?> param1Continuation) {
      Intrinsics.checkNotNullParameter(param1Continuation, "completion");
      return (Continuation<Unit>)new KTest6Kt$main$1(param1Continuation);
    }
     
    public final Object invoke(Object param1Object) {
      return ((KTest6Kt$main$1)create((Continuation)param1Object)).invokeSuspend(Unit.INSTANCE);
    }
     
    // 4、resumeWith触发
    public final Object invokeSuspend(Object param1Object) {
      Object object = IntrinsicsKt.getCOROUTINE_SUSPENDED();
      int i = this.label;
      if (i != 0) {
        if (i != 1) {
          if (i == 2) {
            ResultKt.throwOnFailure(param1Object);
          } else {
            throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
          }
        } else {
          ResultKt.throwOnFailure(param1Object);
          param1Object = this;
          System.out.print("\nmiddle");
          ((KTest6Kt$main$1)param1Object).label = 2;
        }
      } else {
        ResultKt.throwOnFailure(param1Object);
        param1Object = this;
        System.out.print("before");
        ((KTest6Kt$main$1)param1Object).label = 1;
        if (DelayKt.delay(1000L, (Continuation)param1Object) == object)
          return object;
        param1Object = this;
        System.out.print("\nmiddle");
        ((KTest6Kt$main$1)param1Object).label = 2;
      }
      System.out.print("\nafter");
      return Unit.INSTANCE;
    }
  }
}

ps:对于两个参数的重载方法,可以看到第一个参数并没有用: image.png

其中第3步的resumeWith方法实现在哪里?我们看下上面的SuspendLambda的继承关系图即可知道是BaseContinuationImpl # resumeWith方法:

internal abstract class BaseContinuationImpl(
    // 创建时传入,完成时调用。在上面的例子中,这是我们自定义的续体,在「KTest6Kt$main$1 # create」方法中传入
    public val completion: Continuation<Any?>?
) : Continuation<Any?>, CoroutineStackFrame, Serializable {
 
    // This implementation is final. This fact is used to unroll resumeWith recursion.(展开递归)
    public final override fun resumeWith(result: Result<Any?>) {
 
        // This loop unrolls recursion in current.resumeWith(param) to make saner and shorter stack traces on resume
        // unrolling:铺开,这里表示铺开递归,使用循环代替递归,减少栈深度
        //「this」即block转化为的「SuspendLambda」对象,即「KTest3Kt$main$1」对象
        var current = this
        //「result」一般默认是「Result.success(Unit)」,上面的例子中是「Unit」
        var param = result
 
        while (true) {
            // Invoke "resume" debug probe on every resumed continuation, so that a debugging library infrastructure
            // can precisely track what part of suspended callstack was already resumed
            probeCoroutineResumed(current)
            with(current) {
                // 获取「current」的「completion」
                // 在上面的例子中,「completion」是内部协程对象,即「KTest3Kt$launch$coroutine$1」对象
                val completion = completion!! // fail fast when trying to resume continuation without completion
                //「invokeSuspend」获取执行结果
                val outcome: Result<Any?> =
                    try {
                        val outcome = invokeSuspend(param)
                        if (outcome === COROUTINE_SUSPENDED) return
                        Result.success(outcome)
                    } catch (exception: Throwable) {
                        Result.failure(exception)
                    }
                releaseIntercepted() // this state machine instance is terminating
                if (completion is BaseContinuationImpl) {
                    // unrolling recursion via loop
                    // 如果用于完成的续体内还有 completion,则开始套娃,直至获取到最外层的「续体」
                    // (「BaseContinuationImpl」是「SuspendLambda」的父类,只有「CPS」的时候会生成)
                    // 「suspend」方法中有「suspend」方法时会触发这里的逻辑
                    current = completion
                    param = outcome
                } else {
                    // top-level completion reached -- invoke and return
                    // 在默认情况下,最外部协程的续体是「StandaloneCoroutine」
                    // 上面的例子中,最外部的是我们自己声明的匿名内部续体(object : Continuation<T>)
                    completion.resumeWith(outcome)
                    return
                }
            }
        }
    }
    ......
}

「resumeWith」→ 「invokeSuspend」→ 

(1)invokeSuspend返回COROUTINE_SUSPENDED,return结束当前resumeWith,等待下次resumeWith

(2)invokeSuspend返回其他或者异常,生成Result对象,如果completion还是BaseContinuationImpl对象,则套娃,否则调用completion续体的resumeWith

这里套娃的情况出现在suspend方法中还有suspend方法,completion实际上是父协程的续体,即续体里还有续体

(3)拓展:生成SuspendLambda的方法

image.png


以上重点已讲完,下面最重要的就是那两张UML图


四、delay执行过程

续体有深入了解后,我们再来看看下面例子中delay的执行过程

GlobalScope.launch(Dispatchers.Main) {
    print("before")
    delay(1_000)
    print("\nmiddle")
    delay(1_000)
    println("\nafter")
}

delay方法:

public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        // if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
        if (timeMillis < Long.MAX_VALUE) {
            cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
        }
    }
}

suspendCancellableCoroutine作用:获取一个续体后,传入scheduleResumeAfterDelay方法

看一下suspendCancellableCoroutine的内容,其中通过cancellable.getResult() → trySuspend()返回COROUTINE_SUSPENDED终止当前执行,等待下次resume

COROUTINE_SUSPENDED 这个标志代表 return 当前方法,执行权力交由下次调用续体 resumeWith 的对象

public suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (CancellableContinuation<T>) -> Unit
): T =
    // Obtains the current continuation instance inside suspend functions
    suspendCoroutineUninterceptedOrReturn { uCont ->
        // 创建「CancellableContinuationImpl」
        val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
        /*
         * For non-atomic cancellation we setup parent-child relationship immediately
         * in case when `block` blocks the current thread (e.g. Rx2 with trampoline scheduler), but
         * properly supports cancellation.
         */
        cancellable.initCancellability()
        block(cancellable)      // block内发送延迟消息,发送完后当前调用栈即可结束,即可「return」
        cancellable.getResult() // 首次调用,返回「COROUTINE_SUSPENDED」,「COROUTINE_SUSPENDED」就是「return」
    }

其中atomic操作开源代码:kotlinx.atomicfu/AtomicFU.kt…

回到delay方法中,其中context.delay从context中获取ContinuationInterceptor(该类是各种Dispatcher的基类),反回空就使用DefaultDelay

internal val CoroutineContext.delay: Delay get() = get(ContinuationInterceptor) as? Delay ?: DefaultDelay

本例中使用的是Dispatcher.Main,这里获取的就是HandlerContext对象,可以看到HandlerContext中的scheduleResumeAfterDelay方法

override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
    val block = Runnable {
        with(continuation) { resumeUndispatched(Unit) }
    }
    if (handler.postDelayed(block, timeMillis.coerceAtMost(MAX_DELAY))) {
        continuation.invokeOnCancellation { handler.removeCallbacks(block) }
    } else {
        cancelOnRejection(continuation.context, block) // 发送失败就 cancel
    }
}

使用handler.postDelayed发送延迟消息,消息体中触发CancellableContinuationImpl续体的resumeUndispatched方法

override fun CoroutineDispatcher.resumeUndispatched(value: T) {
    val dc = delegate as? DispatchedContinuation
    resumeImpl(value, if (dc?.dispatcher === this) MODE_UNDISPATCHED else resumeMode)
}

delegate是啥?可以看下CancellableContinuationImpl的构造方法

image.png

在前面suspendCancellableCoroutine方法中可以看到传入的是uCont.intercepted(),即当前协程的续体再转化成DispatchedContinuation对象

如果一定要看到uCont是什么,可以看到我们第一次反编译的代码delegate就是KTest6Kt$main$1对象,就是传入「delay」方法的续体,就是「SuspendLambda」

下一行中的dispatcher就是当前协程的调度器Dispatcher.Main,意思是如果resume和launch的dispatcher是同一个,则传入undispatched的状态,这里就是同一个

回来再看CancellableContinuationImpl # resumImpl,其中的操作是:如果当前是NoCompleted状态则调用dispatchResume方法

image.png

调用过程如下:

CancellableContinuationImpl # resumImpl// NotCompleted
CancellableContinuationImpl # dispatchResume// tryResume 返回false
DispatchedTask<T>.dispatch
↓ // mode == MODE_UNDISPATCHED
DispatchedTask<T>.resume
↓
DispatchedContinuation # resumeUndispatchedWith
↓
Continuation # resumeWith

如果本例中使用的是Dispatcher.IO,则context.delay获取的是DefaultDelay

image.png

看到EventLoopImplBase中的实现


public override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
    val timeNanos = delayToNanos(timeMillis)
    if (timeNanos < MAX_DELAY_NS) {
        val now = nanoTime()
        DelayedResumeTask(now + timeNanos, continuation).also { task ->
            continuation.disposeOnCancellation(task)
            schedule(now, task)
        }
    }
}

这里使用了DelayedResumeTaqsk进行了延迟执行resumeWith,可以看到续体还是一路被携带的,大家可以自行分析下调用过程。

五、协程种类及关系

image.png

  1. Job提供协程的基本操作:start、cancel、join,且声明一个子Job序列:children

a job is a cancellable thing with a life-cycle that culminates in its completion.

  1. JobSupport实现Job的基本方法,实现父子关系、状态控制(父Job取消,子Job全部取消;子Job异常,父子Job全部取消,除SupervisorCoroutine

A concrete implementation of [Job]. It is optionally a child to a parent job.

  1. AbstractCoroutine,在JobSupport基础上增加协程上下文、resumeWith方法、生命周期回调方法

Abstract base class for implementation of coroutines in coroutine builders.

4、各类协程实现

方法名实现作用关键操作、原理
CoroutineScope.launchStandaloneCoroutine发起非阻塞协程,返回控制器Job任务调度(线程池+Handler)
CoroutineScope.runBlockingBlockingCoroutine发起阻塞协程LockSupport.parkNanos 挂起当前线程
CoroutineScope.asyncDeferredCoroutine发起非阻塞协程,返回DeferredCancellableContinuationImpl.getResult方法返回 COROUTINE_SUSPENDED 挂起父协程
coroutineScopeScopeCoroutine继承外部context,返回新的控制器Job新建一个新的协程
supervisorScopeSupervisorCoroutine一个子协程异常不会影响到其他子协程新建一个协程,重写 childCancelled 方法
withContextScopeCoroutineDispatchedCoroutineUndispatchedCoroutine在当前协程基础上使用新的context发去协程对比当前协程context与新传入的context,判断使用那种协程,......
withTimeoutTimeoutCoroutine超时自动cancel协程各种Dispatcher都有自己的实现
CoroutineScopeContextScope返回一个只实现了context的scope
  1. Job状态控制

JobSupport中的注释有详细解释 // TODO

六、协程上下文

image.png

上图紫色部分是实现了操作符的相关类

CoroutineContext是可以相加的,加完变成这种结构:

image.png

DispatcherJob这些都实现了Element接口,都可以在上下文中进行相加操作,换句话说,只要实现Element接口就能加入CoroutineContext,同时需要声明CoroutineContext.Key作为存取的Key

image.png

CoroutineContext核心方法就是plusget,实现上下文的拼接,同时使用伴生对象实现去重

// 举个例子
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
↓
SupervisorJob().plus(Dispatchers.Main)
↓
Job.plus(ContinuationInterceptor)
↓
public operator fun plus(context: CoroutineContext): CoroutineContext =
    if (context === EmptyCoroutineContext) {
        // fast path -- avoid lambda creation
        // 如果要合并的是一个空上下文,直接返回当前的上下文
        this
    } else {
        // this -> Job, context -> ContinuationInterceptor
        context.fold(this) { acc, element ->
            // acc -> Job, context -> ContinuationInterceptor
            // 取出右侧的上下文的 key, acc.minusKey计算出左侧上下文除去这个key后剩下的上下文内容
            val removed = acc.minusKey(element.key)
            // removed -> Job
            if (removed === EmptyCoroutineContext) {
                // acc 与 element 相等
                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)
                    }
                }
            }
        }
    }

恕我直言,这篇文章写的真好:Kotlin协程上下文CoroutineContext是如何可相加的,是时候锻炼下逻辑了  (:з」∠) 我就不展开讲了。


以上,如有错漏敬请告知

image.png