源解 Kotlin 协程

前置知识

CPS

Continuation Passing Style(续体传递风格): 约定一种编程规范,函数不直接返回结果值,而是在函数最后一个参数位置传入一个 callback 函数参数,并在函数执行完成时通过 callback 来处理结果。回调函数 callback 被称为续体(Continuation),它决定了程序接下来的行为,整个程序的逻辑通过一个个 Continuation 拼接在一起。

简单理解就是开发中我们常用的 callback 方式来接收返回的结果,只不过这里改名叫"续体"。

Kotlin 编译器处理协程体

我们用 kotlin 写协程代码在编译阶段会经过 kotlin 编译器处理,其会对协程相关逻辑进行拓展和调整。在接下来进行"通过实例源码解读"时,要知道如何查看 kotlin 协程代码编译。

kotlin 复制代码
fun init(context: Context) {
  MainScope().launch {
    //...
  }
}

如上面代码在经过 kotlin 编译器处理后会是什么样子呢?

  • 首先将 kotlin 代码转成 bytecode
  • 然后再将 bytecode 转成 java

得到的就是如下代码:

kotlin 复制代码
public final void init(@NotNull final Context context) {
  
  BuildersKt.launch$default(
    CoroutineScopeKt.MainScope(), 
    (CoroutineContext)null, 
    (CoroutineStart)null, 
    (Function2)(new Function2((Continuation)null) {
        //....
      }), 
    3, 
    (Object)null);
}

可以看到,我们写的 MainScope().launch {} 被转换成了 BuildersKt.launch$default(),其中第四个参数尤其关键,它就是我们的协程体(即 launch{} 部分的逻辑)。它是一个 Function2 的匿名对象,是不是看不懂?这里就好需要回到上一步(kotlin 转 bytecode)来看看。

标记 1、标记 2 共同完成了一件事,创建 FlutterManager <math xmlns="http://www.w3.org/1998/Math/MathML"> i n i t init </math>init1 对象,然后检查其类型是否为 Function2。用 java 伪代码表示

java 复制代码
Object object = new FlutterManager$init$1(context, flutterSoConfig, continuation);
object instanceof Function2

标记3 就是上面 bytecode 转 java 后的代码块了,其中第四个入参为上面的 object。

可以发现 bytecode 中出现了转成 java 后没有的 FlutterManager <math xmlns="http://www.w3.org/1998/Math/MathML"> i n i t init </math>init1 类,其实它就是转成 java 后的 Function2,其继承自 SuspendLambda。可以继续在 bytecode 中验证这一说法。

Continuation 继承关系

java 复制代码
- Continuation: 续体,恢复协程的执行
 - BaseContinuationImpl: 实现 resumeWith(Result) 方法,控制状态机的执行,定义了 invokeSuspend 抽象方法
  - ContinuationImpl: 增加 intercepted 拦截器,实现线程调度等
   - SuspendLambda: 封装协程体代码块
    - FlutterManager$init$1 协程体代码块生成的子类: 实现 invokeSuspend 方法,其内实现状态机流转逻辑

实例源解

kotlin 复制代码
object FlutterManager{

  fun init(context: Context) {
    val flutterSoUrl = context.assets.open("flutterso.json").readBytes().decodeToString()
    val flutterConfig =
    Gson().fromJsonProxy(flutterSoUrl, FlutterSOConfig::class.java) ?: return
    MainScope().launch {
      val flutterSoFile = downloadDynamicSO(context, DownloadConfig(flutterConfig.libflutter.url, context.getDir("flutterso", Context.MODE_PRIVATE).absolutePath)
          .apply {
            fileName = FLUTTER_ENGINE_SO_NAME
          })?.let { File(it) }
      val appSoFile = downloadDynamicSO(context, DownloadConfig(flutterConfig.libapp.url, context.getDir("flutterso", Context.MODE_PRIVATE).absolutePath)
          .apply {
            fileName = FLUTTER_APP_SO_NAME
          })?.let { File(it) }
      if (flutterSoFile != null && appSoFile != null) {
        loadAndInitFlutter(context, flutterSoFile.parentFile, appSoFile.absolutePath)
      }
    }
  }

  private suspend fun downloadDynamicSO(
    context: Context,
    downloadConfig: DownloadConfig
  ): String? {
    return suspendCoroutine {
      DownloadManager.instance.start(
        context,
        downloadConfig,
        object : IDownloadListener {
          override fun onSuccess(url: String?, savePath: Uri?) {
            super.onSuccess(url, savePath)
            it.resume(savePath?.path)
          }

          override fun onFailed(url: String?, throwable: Throwable) {
            super.onFailed(url, throwable)
            it.resumeWithException(throwable)
          }
        })
    }
  }
}

创建与启动

kotlin 复制代码
//在示例代码中只传入了 block 参数,其他均采用默认值。
// bloack 即为协程体
public fun CoroutineScope.launch(
  context: CoroutineContext = EmptyCoroutineContext,
  start: CoroutineStart = CoroutineStart.DEFAULT,
  block: suspend CoroutineScope.() -> Unit
): Job {
  //newContext = scope 上下文 + context 上下文 + Dispatchers
  val newContext = newCoroutineContext(context)
  // 默认参数,所以 coroutine = StandaloneCoroutine()
  val coroutine = if (start.isLazy)
    	LazyStandaloneCoroutine(newContext, block) 
  	else
    	StandaloneCoroutine(newContext, active = true)
  // 启动协程
  //  StandaloneCoroutine 继承自 AbstractCoroutine,这里调用的是父类 start()
  coroutine.start(start, coroutine, block)
  return coroutine
}
kotlin 复制代码
//注意看!
// AbstractCoroutine 继承自 Continuation
//  所以 AbstractCoroutine 及子类(StandaloneCoroutine)都是 Continuation 对象,即续体!
public abstract class AbstractCoroutine<in T>
	: JobSupport(active), Job, Continuation<T>, CoroutineScope {

	/**
	 * @params start: 		CoroutineStart.DEFAULT
	 * @params receiver:	StandaloneCoroutine
	 * @params block: 		suspend CoroutineScope.() -> Unit
	 */
	public fun <R> start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
		// 调用 CoroutineStart 的重载方法 invoke()
		start(block, receiver, this)
	}
}
kotlin 复制代码
public enum class CoroutineStart {
  DEFAULT,
  //...

  /**
   * @params block:				suspend CoroutineScope.() -> Unit
   * @params receiver:		StandaloneCoroutine
   * @params completion:	StandaloneCoroutine
   */
  public operator fun <R, T> invoke(block: suspend R.() -> T, receiver: R, completion: Continuation<T>): Unit =
    when (this) {
      //此时调用到这里!!!
      DEFAULT -> block.startCoroutineCancellable(receiver, completion)
      ATOMIC -> block.startCoroutine(receiver, completion)
      UNDISPATCHED -> block.startCoroutineUndispatched(receiver, completion)
      LAZY -> Unit // will start lazily
    }
}

到这里可以知道默认参数情况下,调用 launch() 会先合并 context,然后创建 StandaloneCoroutine,其继承自 AbstractCoroutine,并持有合并后的 context,即包装一层成为续体(Continuation)。然后调用启动,最后执行到 (suspend R.() -> T)#startCoroutineCancellable()。

下面看真正的启动流程。

kotlin 复制代码
internal fun <R, T> (suspend (R) -> T).startCoroutineCancellable(
    receiver: R, completion: Continuation<T>,
    onCancellation: ((cause: Throwable) -> Unit)? = null
) = runSafely(completion) {
        //开始真正的启动逻辑
        createCoroutineUnintercepted(receiver, completion).intercepted().resumeCancellableWith(Result.success(Unit), onCancellation)
    }

createCoroutineUnintercepted()

kotlin 复制代码
public actual fun <R, T> (suspend R.() -> T).createCoroutineUnintercepted(
    receiver: R,
    completion: Continuation<T>
): Continuation<Unit> {
    val probeCompletion = probeCoroutineCreated(completion)
    // this 指 (suspend R.() -> T)
    //  根据"前置知识 - Kotlin编译器处理线程体"、"前置知识 - Continuation继承关系"可知,
    //   this = SuspendLambda 
    //			 = ContinuationImpl 
    //			  = BaseContinuationImpl 
    //		  = Function2
    //    即调用 Function2#create()
    return if (this is BaseContinuationImpl)
        create(receiver, probeCompletion)
    else {
        createCoroutineFromSuspendFunction(probeCompletion) {
            (this as Function2<R, Continuation<T>, Any?>).invoke(receiver, it)
        }
    }
}
java 复制代码
//下面代码为 MainScope().launch{} 解码后(kotlin -> bytecode ->java)的部分代码
BuildersKt.launch$default(CoroutineScopeKt.MainScope(), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
  //....
  @NotNull
  public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
    //重新创建一个新的 SuspendLambda对象 (即新的 Continuation),其持有传入的续体(即第一层续体包装)
    Function2 var3 = new <anonymous constructor>(completion);
    return var3;
  }
}), 3, (Object)null);

intercepted()

kotlin 复制代码
//根据上面 createCoroutineUnintercepted() 可知,this =  ContinuationImpl
public actual fun <T> Continuation<T>.intercepted(): Continuation<T> =
    (this as? ContinuationImpl)?.intercepted() ?: this
kotlin 复制代码
internal abstract class ContinuationImpl : BaseContinuationImpl {
  //执行逻辑如下:
  // 1. intercepted != null,return 调度器包装续体。
  // 2. intercepted == null,从 context 中提取 ContinuationInterceptor,
  //     调用其 interceptContinuation(),生成调度器包装续体并 return。
  // 3. intercepted == null && context 中没有 ContinuationInterceptor,则 return 原续体。
  //
  // 按照示例代码逻辑 MainScope(),其内部会注入 Dispatchers.Main(即 MainCoroutineDispatcher)
  //  这里实际调用的是 MainCoroutineDispatcher#interceptContinuation()
  public fun intercepted(): Continuation<Any?> =
    intercepted
    	?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
      	.also { intercepted = it }
}
kotlin 复制代码
// MainCoroutineDispatcher 继承自 CoroutineDispatcher
//  interceptContinuation() 由父类实现
public abstract class CoroutineDispatcher:ContinuationInterceptor {
  
  //返回 DispatchedContinuation(即调度器包装续体),其持有调度器(这里指 MainCoroutineDispatcher)和第二层续体包装
  public final override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
  	DispatchedContinuation(this, continuation)
}

resumeCancellableWith()

kotlin 复制代码
public fun <T> Continuation<T>.resumeCancellableWith(
    result: Result<T>,
    onCancellation: ((cause: Throwable) -> Unit)? = null
): Unit = when (this) {
    //根据上面拦截器处理逻辑,执行到这里! 
    //  即调用 DispatchedContinuation#resumeCancellableWith()
    is DispatchedContinuation -> resumeCancellableWith(result, onCancellation)
    else -> resumeWith(result)
}
kotlin 复制代码
internal class DispatchedContinuation<in T>(
  @JvmField val dispatcher: CoroutineDispatcher,
  @JvmField val continuation: Continuation<T>
) : DispatchedTask<T>(MODE_UNINITIALIZED), CoroutineStackFrame, Continuation<T> by continuation {

  /**
   * @params result: 	 Result.success(Unit)
   * @parans onCancellation: null
   */
  inline fun resumeCancellableWith(
    result: Result<T>,
    noinline onCancellation: ((cause: Throwable) -> Unit)?
      ) {
    val state = result.toState(onCancellation)
    //判断是否需要线程切换
    // 根据示例代码 dispatcher = MainCoroutineDispatcher
    //   MainCoroutineDispatcher 的具体实现由 HandlerContext 来完成
    // 继承关系:
 	  //   - MainCoroutineDispatcher
  	//    - HandlerDispatcher
  	// *=> - HandlerContext 
    //
    // 根据 HandlerContext#isDispatchNeeded() 逻辑 + 示例代码运行环境,此处为 false
    if (dispatcher.isDispatchNeeded(context)) {
      _state = state
      resumeMode = MODE_CANCELLABLE
      //调用调度器进行线程切换
      //  如果需要切换的话,具体实现是由 HandlerContext#dispatch() 实现,
      //   内部就是做了 Handler(Looper.getMainLooper()).post()。
      // 传入的 this = DispatchedContinuation,其继承自 DispatchedTask,
      //  DispatchedTask 顶级父类为 Runnable,其内部重写 run(),最终调用到 continuation.resume()
      dispatcher.dispatch(context, this)
    } else {
      executeUnconfined(state, MODE_CANCELLABLE) {
        if (!resumeCancelled(state)) {
          //最终调用到 continuation.resume()
          resumeUndispatchedWith(result)
        }
      }
    }
  }

  inline fun resumeUndispatchedWith(result: Result<T>) {
    withContinuationContext(continuation, countOrElement) {
        continuation.resumeWith(result)
    }
  }
}

启动逻辑代码很简短,只有一行,但却分了三步逻辑:

  1. 调用 createCoroutineUnintercepted() 创建第二层续体包装(SuspendLambda),其持有接受者和第一层续体包装(StandaloneCoroutine);
  2. 调用 intercepted(),进行拦截器(或者叫调度器)处理(例如,线程切换),此时经过处理后会生成第三层续体包装(DispatchedContinuation);
  3. 调用 resumeCancellableWith(),内部会根据调度器判断是否需要线程切换,无论是否切换,最终都会调用 continuation#resumeWith() 执行到协程体内部逻辑。

启动完结

我们接续往下看,上面分析到最后都会调用 continuation#resumeWith()。根据 DispatchedContinuation 的构造函数可知,resumeWith() 由第二层续体包装(SuspendLambda) 来实现,即 Kotlin 编译器处理生成的 Function2 实例。那么我们就看下其内部实现。

kotlin 复制代码
//根据"前置知识 - Continuation继承关系"可知,
//  resumeWith() 实际由 BaseContinuationImpl 实现
internal abstract class BaseContinuationImpl(
    public val completion: Continuation<Any?>?
) : Continuation<Any?>, CoroutineStackFrame, Serializable {
    
    public final override fun resumeWith(result: Result<Any?>) {
        var current = this
        var param = result
        while (true) {
            with(current) {
                val completion = completion!!
                val outcome: Result<Any?> =
                    try {
                        //执行协程体 invokeSuspend()
                        val outcome = invokeSuspend(param)
                        //挂起状态,直接 return
                        if (outcome === COROUTINE_SUSPENDED) return
                        //非挂起,直接返回结果
                        Result.success(outcome)
                    } catch (exception: Throwable) {
                        Result.failure(exception)
                    }
                //如果需要,执行线程切换
                releaseIntercepted() 
                if (completion is BaseContinuationImpl) {
                    current = completion
                    param = outcome
                } else {
                    completion.resumeWith(outcome)
                    return
                }
            }
        }
    }    
}

上面的代码是一套公共逻辑,我们先只关注启动后调用 resumeWith() 会怎么执行即可。首先会调用 invokeSuspend(),此方法也是在 Kotlin编译器生成的协程代码的 Function2 中实现。

java 复制代码
BuildersKt.launch$default(CoroutineScopeKt.MainScope(), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
  Object L$0;
  int label;

  @Nullable
  public final Object invokeSuspend(@NotNull Object $result) {
    label40: {
      // var10 = 挂起状态
      Object var10 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
      switch (this.label) {
        // 启动调用到此处!
        case 0:
          //判断传入的 result,如果为 Result.Failure,这直接抛异常。
          //  按照上面启动逻辑,此处 resut = Result.success。
          ResultKt.throwOnFailure($result);
          //准备一些参数
          //...
          //将 label 标记为 1
          this.label = 1;
          //调用 downloadDynamicSO(), 传入函数定义的参数 + 续体
          var10000 = var7.downloadDynamicSO(var8, var3, this);
          //判断执行结果是否为挂起状态,是则直接 return,不阻塞当前线程执行。
          // 根据示例代码,downloadDynamicSO() 为 suspend 修饰函数,所以此处应该为挂起状态。
          if (var10000 == var10) {
            return var10;
          }
          break;
          
       //...
      }
      return Unit.INSTANCE;
    }
    //...
  }), 3, (Object)null);

到这里,协程已经完成启动,并执行到第一个挂起函数,状态变为挂起状态。结合 BaseContinuationImpl#resumeWith() 逻辑,执行到挂起函数,并返回挂起状态,然后直接 return,启动阶段完结。

函数挂起与恢复

挂起

根据上面流程,逻辑执行到了挂起函数 downloadDynamicSO(),那么我们看下其怎么实现协程挂起与恢复的。

suspend 修饰 downloadDynamicSO(),其经过 Kotlin编译器生成如下代码:

java 复制代码
//处理函数规定的入参外,最后额外追加了一个续体入参。
private final Object downloadDynamicSO(Context context, DownloadConfig downloadConfig, Continuation $completion) {
  //根据启动时的经验,此处为创建 SafeContinuation 对象,其持有 DispatchedContinuation 对象,DispatchedContinuation 又持有 SuspendLambda 对象
  SafeContinuation var5 = new SafeContinuation(IntrinsicsKt.intercepted($completion));
  Continuation it = (Continuation)var5;
  int var7 = false;
  //调用下载,此处是一个异步操作
  DownloadManager.Companion.getInstance().start(context, downloadConfig, (IDownloadListener)(new FlutterManager$downloadDynamicSO$2$1(it)));
  //获取执行结果,由于是异步操作,所以此处返回结果为挂起
  Object var10000 = var5.getOrThrow();
  return var10000;
}

代码比较简单,主要逻辑为:

  1. 创建一个 SafeContinuation 续体包装,持有 DispatchedContinuation 对象,最终持有的为协程体续体对象(即 SuspendLambda)。
  2. 执行异步代码逻辑。
  3. 返回结果,此时结果为挂起。

Q: 代码中并没有体现何时为挂起状态

A: 状态的管理由新创建的续体(SafeContinuation)处理。我们来看下它的内部实现,便可知上面代码在调用异步逻辑后,接着调用 SafeContinuation#getOrThrow() 返回的就是 COROUTINE_SUSPENDED。

kotlin 复制代码
internal actual class SafeContinuation<in T>
internal actual constructor(
  private val delegate: Continuation<T>,
  initialResult: Any?
) : Continuation<T>, CoroutineStackFrame {
  //未携带状态的构造函数,默认状态为 UNDECIDED
	internal actual constructor(delegate: Continuation<T>) : this(delegate, UNDECIDED)

  // 构造时未赋值状态时,默认初始状态为 UNDECIDED
  @Volatile
  private var result: Any? = initialResult
  
  internal actual fun getOrThrow(): Any? {
    var result = this.result // atomic read
    //如果默认状态,则更新状态为 COROUTINE_SUSPENDED
    if (result === UNDECIDED) {
        if (RESULT.compareAndSet(this, UNDECIDED, COROUTINE_SUSPENDED)) return COROUTINE_SUSPENDED
        result = this.result // reread volatile var
    }
    //状态更新
    return when {
        result === RESUMED -> COROUTINE_SUSPENDED // already called continuation, indicate COROUTINE_SUSPENDED upstream
        result is Result.Failure -> throw result.exception
        else -> result // either COROUTINE_SUSPENDED or data
    }
  }
}

恢复

Q: 上面异步逻辑执行完成后(即下载完成),代码如何继续执行的呢?

A: 核心点就在下载完成后调用 continuation.resume()。我们继续来看下 downloadDynamicSO 经 Kotlin 编译器处理后生成的类。

java 复制代码
private final Object downloadDynamicSO(Context context, DownloadConfig downloadConfig, Continuation $completion) {
  //...
  //调用下载,最后注入一个下载监听对象,该对象持有 SafeContinuation 续体对象
  DownloadManager.Companion.getInstance().start(
    context, 
    downloadConfig, 
    (IDownloadListener)(new FlutterManager$downloadDynamicSO$2$1(it)));
  Object var10000 = var5.getOrThrow();
  return var10000;
}

public final class FlutterManager$downloadDynamicSO$2$1 implements IDownloadListener {
  FlutterManager$downloadDynamicSO$2$1(Continuation $captured_local_variable$0) {
    this.$it = $captured_local_variable$0;
  }
  final Continuation $it;

  public void onSuccess(@Nullable String url, @Nullable Uri savePath) {
    super.onSuccess(url, savePath);
    Continuation var3 = this.$it;
    String var4 = savePath != null ? savePath.getPath() : null;
    //下载完成,调用 SafeContinuation#resumeWith()
    var3.resumeWith(Result.constructor-impl(var4));
  }
//...
}
kotlin 复制代码
internal actual class SafeContinuation<in T>
  internal actual constructor(
    private val delegate: Continuation<T>,
    initialResult: Any?
      ) : Continuation<T>, CoroutineStackFrame {
    //未携带状态的构造函数,默认状态为 UNDECIDED
    internal actual constructor(delegate: Continuation<T>) : this(delegate, UNDECIDED)

    // 构造时未赋值状态时,默认初始状态为 UNDECIDED
    @Volatile
    private var result: Any? = initialResult

    public actual override fun resumeWith(result: Result<T>) {
      while (true) {
        //结合挂起小节中的逻辑,此时 result = COROUTINE_SUSPENDED
        val cur = this.result 
        when {
          cur === UNDECIDED -> if (RESULT.compareAndSet(this, UNDECIDED, result.value)) return
          cur === COROUTINE_SUSPENDED -> if (RESULT.compareAndSet(this, COROUTINE_SUSPENDED, RESUMED)) {
            //执行到此处!!
            // 结合挂起小节中的逻辑,此处调用的是 DispatchedContinuation#resumeWith()
            delegate.resumeWith(result)
            return
          }
          else -> throw IllegalStateException("Already resumed")
        }
      }
    }
  }

逻辑执行到此处,最后调用的是 DispatchedContinuation#resumeWith(),该方法与"创建与启动-resumeCancellableWith"小节中的 DispatchedContinuation#resumeCancellableWith() 实现逻辑基本一致,不再重复解读,最终执行到协程体的 invokeSuspend() 逻辑。

java 复制代码
BuildersKt.launch$default(CoroutineScopeKt.MainScope(), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
  Object L$0;
  int label;

  @Nullable
  public final Object invokeSuspend(@NotNull Object $result) {
    label40: {
      // var10 = 挂起状态
      Object var10 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
      switch (this.label) {
        case 0:
          //将 label 标记为 1
          this.label = 1;
          //...
          break;
        //代码再次执行到 invokeSuspend(),此时 label = 1
        case 1:
          //获取到第一个挂起函数返回的结果,并赋值给 var10000
          ResultKt.throwOnFailure($result);
          var10000 = $result;
          break;
        //...
      }
      var16 = (String)var10000;
      //执行后续示例代码中 .let{} 部分的逻辑
      //... 
      this.label = 2;
      //调用下一个挂起函数
      var10000 = var7.downloadDynamicSO(var8, var4, this);
      if (var10000 == var10) {
         return var10;
      }
      return Unit.INSTANCE;
    }
    //...
  }), 3, (Object)null);

回到 invokeSuspend() 逻辑中,由于第一次挂起时将 label 标记为 1,所以此时执行 case 1 的逻辑,将结果赋值给 var1000,break 跳出 switch,继续执行后续逻辑,最后将 label 标记为 2,调用下一个挂起函数。后面的逻辑又回到了函数的挂起与恢复,和上面解析的一致,这里就不重复了。

总结

博文来自青杉

参考内容

Kotlin协程之再次读懂协程工作原理 - 掘金
深入理解Kotlin协程

相关推荐
有点感觉30 分钟前
Android级联选择器,下拉菜单
kotlin
zhangphil8 小时前
Android Coil3缩略图、默认占位图placeholder、error加载错误显示,Kotlin(1)
android·kotlin
xvch14 小时前
Kotlin 2.1.0 入门教程(二十三)泛型、泛型约束、协变、逆变、不变
android·kotlin
xvch2 天前
Kotlin 2.1.0 入门教程(二十四)泛型、泛型约束、绝对非空类型、下划线运算符
android·kotlin
zhangphil3 天前
Android Coil ImageLoader MemoryCache设置Key与复用内存缓存,Kotlin
android·kotlin
mmsx3 天前
kotlin Java 使用ArrayList.add() ,set()前面所有值被 覆盖 的问题
android·开发语言·kotlin
lavins3 天前
android studio kotlin项目build时候提示错误 Unknown Kotlin JVM target: 21
jvm·kotlin·android studio
面向未来_3 天前
JAVA Kotlin Androd 使用String.format()格式化日期
java·开发语言·kotlin
alexhilton3 天前
选择Retrofit还是Ktor:给Android开发者的指南
android·kotlin·android jetpack
GordonH19913 天前
Kotlin 优雅的接口实现
android·java·kotlin