IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 移动开发 -> 讲真,Kotlin 协程的挂起没那么神秘(原理篇) -> 正文阅读

[移动开发]讲真,Kotlin 协程的挂起没那么神秘(原理篇)

前言

协程系列文章:

上篇从拟物的角度阐述了协程挂起/恢复的场景,相信大家对此应该有了一个感性的的认识。上上篇分析了如何开启一个原始的协程,相信大家也知道协程内部执行原理了。本篇将重点分析协程挂起与恢复的原理,探究协程凭什么能挂起?它又为何能够在原地恢复?
通过本篇文章,你将了解到:

1、suspend 函数该怎么写?
2、withContext 凭什么能挂起协程?
3、withContext 靠什么恢复协程?
4、不用withContext 如何挂起协程?
5、协程执行、挂起、恢复的全流程。

1、suspend 函数该怎么写?

suspend 写法初探

古有两小儿辩日,今有俩码农论协程。

小明说:“挂起函数当然很容易写,不就是加个suspend吗?”

suspend fun getStuInfo() {
    println("after sleep")
}

小红说:“你这样写不对,编译器会提示:”
image.png
意思是挂起函数毫无意义,可以删除suspend 关键字。

小明说:“那我这写的到底是不是挂起函数呢?”
小红:“简单,遇事不决反编译。”

   public static final Object getStuInfo(@NotNull Continuation $completion) {
      String var1 = "after sleep";
      boolean var2 = false;
      System.out.println(var1);
      return Unit.INSTANCE;
   }

虽然带了Continuation 参数,但这个参数没有用武之地。
并且调用getStuInfo()的地方反编译查看:

    public final Object invokeSuspend(@NotNull Object $result) {
        //挂起标记
        Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
        String var2;
        boolean var3;
        switch(this.label) {
            case 0:
                ResultKt.throwOnFailure($result);
                var2 = "before suspend";
                var3 = false;
                System.out.println(var2);
                this.label = 1;
                //此处判断结果为false,因为getStuInfo 永远不会挂起
                if (CoroutineSuspendKt.getStuInfo(this) == var4) {
                    return var4;
                }
                break;
            case 1:
                ResultKt.throwOnFailure($result);
                break;
            default:
                throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
        }

        var2 = "after suspend";
        var3 = false;
        System.out.println(var2);
        return Unit.INSTANCE;
    }

可以看出,在调用的地方会判断getStuInfo()是否会挂起,但结果是永远不会挂起。
综合定义与调用处可知:

getStuInfo() 不是一个挂起函数。

小明计上心头说:“挂起嘛,顾名思义就是阻塞。”

suspend fun getStuInfo() {
    Thread.sleep(2000)
    println("after sleep")
}

小红:“然而这是线程的阻塞而非协程的挂起。”
小明:“额,不阻塞原来的线程,那我再开个线程做耗时任务。”

suspend fun getStuInfo1() {
    thread {
        Thread.sleep(2000)
        println("after sleep")
    }
    println("after thread")
}

小红:“这次虽然不阻塞原来的线程了,但是线程还是往下执行了(after thread 先于 after sleep 打印),并不能挂起协程。”
小明:“到底要我怎样?既不能阻塞线程又不能让线程继续执行后续的代码,这触及了我的知识盲区,我需要研究研究。”

delay 挂起协程

小明恶补了协程相关的知识点,信心满满找到小红展示成果。

suspend fun getStuInfo() {
    delay(5000)
    Log.d("fish", "after delay thread:${Thread.currentThread()}")
}

这样写就能够挂起协程了,并且挂起的时长为5s,等待这时间一过,"after delay thead"将会打印。
而且有三点改进:

  • suspend 编译器不会再提示是冗余的了。
  • 反编译结果展示getStuInfo()的Continuation参数也有用了。
  • 反编译结果展示调用getStuInfo()有机会被挂起了。

小红看完称赞道:“不错哦,有进步,看来是做了功课的。那我再问你个问题:你怎么证明调用getStuInfo()函数的线程没有被阻塞的呢?”
小明胸有成竹的说:“这个我早有准备,且看我完整代码。”

        //点击UI
        binding.btnDelay.setOnClickListener {
            GlobalScope.launch(Dispatchers.Main) {
                //在主线程执行协程
                Log.d("fish", "before suspend thread:${Thread.currentThread()}")
                //执行挂起函数
                getStuInfo()
            }
            binding.btnDelay.postDelayed({
                //延迟2s在主线程执行打印
                Log.d("fish", "post thread:${Thread.currentThread()}")
            }, 2000)
        }

       suspend fun getStuInfo() {
          delay(5000)
          Log.d("fish", "after delay thread:${Thread.currentThread()}")
      }

最后打印结果如下:
image.png

getStuInfo()运行在主线程,该函数里将协程挂起5s,而在2s后在主线程里打印。

1、第三条语句5s后打印说明delay(5000)有效果,主线程在执行delay()后没有继续往下执行了。
2、第二条语句2s后打印说明主线程并没有被阻塞。
3、综合以上两点,getStuInfo()既能够阻止当前线程执行后面的代码,又能够不阻塞当前线程,说明达到挂起协程的目的了。

小红:“理解很到位,我又有个问题了:挂起函数是在主线程执行的,那能否让它在子线程执行呢?在大部分的场景下,我们都需要在子线程执行耗时操作,子线程执行完毕后,主线程刷新UI。”
小明:“容我三思…”

2、withContext 凭什么能挂起协程?

withContext 使用

不用小明思考了,我们直接开撸源码。协程使用过程中除了launch/asyc/runBlocking/delay 之外,想必还有一个函数比较熟悉:withContext。
刚接触时大家都使用它来切换线程用以执行新的协程(子协程),而原来的协程(父协程)则被挂起。当子协程执行完毕后将会恢复父协程的运行。

fun main(array: Array<String>) {
    GlobalScope.launch() {
        println("before suspend")
        //挂起函数
        var studentInfo = getStuInfo2()
        //挂起函数执行返回
        println("after suspend student name:${studentInfo?.name}")
    }
    //防止进程退出
    Thread.sleep(1000000)
}

suspend fun getStuInfo2():StudentInfo {
    return withContext(Dispatchers.IO) {
        println("start get studentInfo")
        //模拟耗时操作
        Thread.sleep(3000)
        println("get studentInfo successful")
        //返回学生信息
        StudentInfo()
    }
}

如上,在Default 线程开启了协程,进而在里面调用挂起函数getStuInfo2。该函数里切换到IO 线程执行(Defualt 和IO 不一定是不同的线程),当执行完毕后将返回学生信息。
查看打印结果:
image.png
从结果上来看,明明是异步调用,代码里却是用同步的方式表达出来,这就是协程的魅力所在。

withContext 原理

suspendCoroutineUninterceptedOrReturn 的理解

父协程为啥能挂起呢?这得从withContext 函数源码说起。

#Builders.common.kt
suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T {
    return suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
        val oldContext = uCont.context
        val newContext = oldContext + context
        //...
        //构造分发的协程
        val coroutine = DispatchedCoroutine(newContext, uCont)
        //开启协程
        block.startCoroutineCancellable(coroutine, coroutine)
        //获取协程结果
        coroutine.getResult()
    }
}

里边调用了suspendCoroutineUninterceptedOrReturn 函数,查看其实现,发现找了半天都没找着…实际上它并不是源码,而是在编译期生成的代码。
不管来源如何,先看它的参数,发现是Continuation类型的,这个参数是从哪来的呢?
仔细看,原来是withContext 被suspend 修饰的,而suspend 修饰的函数会默认带一个Continuation类型的形参,这样就能关联起来了:

suspendCoroutineUninterceptedOrReturn 传入的uCount 实参即为父协程的协程体。

将父协程的协程体存储到DispatchedCoroutine里,最后通过DispatchedCoroutine 分发。

getResult 的理解

直接看代码:

#DispatchedCoroutine类里
fun getResult(): Any? {
    //先判断是否需要挂起
    if (trySuspend()) return COROUTINE_SUSPENDED
    //如果无需挂起,说明协程已经执行完毕
    //那么需要将返回值返回
    val state = this.state.unboxState()
    if (state is CompletedExceptionally) throw state.cause
    //强转返回值到对应的类型
    return state as T
}

private fun trySuspend(): Boolean {
    //_decision 原子变量,三种值可选
    //private const val UNDECIDED = 0 默认值,未确定是1还是2
    //private const val SUSPENDED = 1 挂起
    //private const val RESUMED = 2   恢复
    _decision.loop { decision ->
        when (decision) {
            //若当前值为默认值,则修改为挂起,并且返回ture,表示需要挂起
            UNDECIDED -> if (this._decision.compareAndSet(UNDECIDED, SUSPENDED)) return true
            //当前已经是恢复状态,无需挂起,返回false
            RESUMED -> return false
            else -> error("Already suspended")
        }
    }
}

也就是说当调用了coroutine.getResult() 后,该函数执行的返回值即为suspendCoroutineUninterceptedOrReturn 的返回值,进而是withContext 的返回值。

此时withContext 的返回值为:COROUTINE_SUSPENDED,它是个枚举值,表示协程执行到该函数需要挂起协程,也即是调用了withContext()函数的协程需要被挂起。

小结挂起逻辑:

  1. withContext()函数记录当前调用它的协程,并开启一个新的协程。
  2. 开启的新协程在指定的线程执行(提交给线程池或是提交给主线程执行任务)。
  3. 判断新协程当前的状态,若是挂起则返回挂起状态,若是恢复状态则返回具体的返回值。

其中第2点只负责提交任务,耗时可以忽略。
第3点则是挂起与否的关键所在。

协程状态机

withContext()函数已经返回了,它的使命已经结束,关键是看谁在使用它的返回值做文章。

    GlobalScope.launch() {
        println("before suspend")
        //挂起函数
        var studentInfo = getStuInfo2() //①
        //挂起函数执行返回
        println("after suspend student name:${studentInfo?.name}")//②
    }

我们通俗的理解是:getStuInfo2()里调用了withContext(),而withContext() 返回了,那么getStuInfo2()也应当返回啊?而实际结果却是②的打印3s后才显示,说明实际情况是②的语句是3s后才执行。
luanch(){…}花括号里的内容我们称为协程体,而该协程体比较特殊,看起来是同步的写法,实际内部并不是同步执行,这部分在上上篇文章有分析,此处简单过一下。
老规矩,还是反编译看看花括号里的是啥内容。

        BuildersKt.launch$default((CoroutineScope)GlobalScope.INSTANCE, (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
            //状态机状态的值
            int label;
            @Nullable
            public final Object invokeSuspend(@NotNull Object $result) {
                //挂起状态
                Object var5 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
                Object var10000;
                switch(this.label) {
                    case 0:
                        //默认为label == 0,第一次进来时
                        String var2 = "before suspend";
                        System.out.println(var2);
                        //状态流转为下一个状态
                        this.label = 1;
                        //执行挂起函数
                        var10000 = CoroutineSuspendKt.getStuInfo2(this);
                        if (var10000 == var5) {
                            //若是挂起,直接返回挂起值
                            return var5;
                        }
                        break;
                    case 1:
                        //第二次进来时,走这,没有return,只是退出循环
                        ...
                        break;
                    default:
                        throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
                }
                //第二次进入走这,执行打印语句
                StudentInfo studentInfo = (StudentInfo)var10000;
                String var7 = "after suspend student name:" + (studentInfo != null ? studentInfo.getName() : null);
                boolean var4 = false;
                System.out.println(var7);
                return Unit.INSTANCE;
            }
            @NotNull
            public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
                ...
                return var3;
            }
            public final Object invoke(Object var1, Object var2) {
                ...
            }
        }), 3, (Object)null);

invokeSuspend()函数里维护了一个状态机,通过label 来控制流程走向,此处有两个状态,分别是0和1,0为默认。
第一次进入时默认为0,因此会调用 getStuInfo2(),而之前的分析表明该函数会返回挂起状态,因此此处检测到挂起状态后直接return 了,invokeSuspend() 执行结束。
invokeSuspend()返回值谁关注?

#BaseContinuationImpl 类成员方法
override fun resumeWith(result: Result<Any?>) {
    var current = this
    var param = result
    while (true) {
        ...
        with(current) {
            val completion = completion!! // fail fast when trying to resume continuation without completion
            val outcome: Result<Any?> =
                try {
                    //执行协程体
                    val outcome = invokeSuspend(param)
                    //若是挂起,则直接return
                    if (outcome === kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED) return
                    kotlin.Result.success(outcome)
                } catch (exception: Throwable) {
                    kotlin.Result.failure(exception)
                }
            //恢复逻辑
            //...
        }
    }
}

GlobalScope.launch() 函数本身会执行resumeWith()函数,该函数里执行invokeSuspend(),invokeSuspend()里会执行协程体,也即是GlobalScope.launch()花括号里的内容。
至此就比较明了了:

GlobalScope.launch() 最终会执行闭包(协程体),遇到挂起函数getStuInfo2()时将不会再执行挂起函数后的代码直到被恢复。

3、withContext 靠什么恢复协程?

协程体反编译

GlobalScope.launch() 启动的协程在调用getStuInfo2()后就挂起了,它啥时候会恢复执行呢?也就是说协程状态机啥时候会走到label=1的分支?
从上节分析可知,withContext(){} 花括号里的内容(协程体)将会被调度执行,既然是协程体当然还是要反编译查看。

    public static final Object getStuInfo2(@NotNull Continuation $completion) {
        return BuildersKt.withContext((CoroutineContext)Dispatchers.getIO(), (Function2)(new Function2((Continuation)null) {
            //状态机的值
            int label;

            @Nullable
            public final Object invokeSuspend(@NotNull Object var1) {
                //挂起值
                Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
                switch(this.label) {
                    case 0:
                        //默认走这,正常协程体里的内容
                        String var2 = "start get studentInfo";
                        System.out.println(var2);
                        Thread.sleep(3000L);
                        var2 = "get studentInfo successful";
                        System.out.println(var2);
                        //返回对象
                        return new StudentInfo();
                        //...
                }
            }
            ...
        }), $completion);
    }

此时的状态机只有一个状态,说明withContext() 协程体里没有调用挂起的函数。
继续查看是谁关注了invokeSuspend()的返回值,也就是谁调用了它。

协程体调用

协程的恢复离不开 resumeWith()函数

#BaseContinuationImpl 类成员方法
override fun resumeWith(result: Result<Any?>) {
    var current = this
    var param = result
    while (true) {
        with(current) {
            //completion 可能是父协程的协程体(或是包装后的),也即是当前协程体执行完成后
            //需要通知之前的协程体
            val completion = completion!! // fail fast when trying to resume continuation without completion
            val outcome: Result<Any?> =
                try {
                    //调用协程体
                    val outcome = invokeSuspend(param)
                    //如果是挂起则直接返回
                    if (outcome === kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED) return
                    kotlin.Result.success(outcome)
                } catch (exception: Throwable) {
                    kotlin.Result.failure(exception)
                }
            if (completion is BaseContinuationImpl) {
                // 仅仅记录 ①
                current = completion
                param = outcome
            } else {
                //执行恢复逻辑 ②
                completion.resumeWith(outcome)
                return
            }
        }
    }
}

对于Demo 里的withContext()函数的协程体来说,因为它没有调用任何挂起的函数,因此此处invokeSuspend(param) 返回的结果将是对象,“outcome === kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED” 判断不满足,继续往下执行。
completion 的类型至关重要,而我们的Demo里completion 表示的即是父协程的协程体,也就是GlobalScope.launch()的协程体,因此会执行:
completion.resumeWith(outcome),outCome 为 StudentInfo 对象。

到这就比较有趣了,我们之前分析过GlobalScope.launch()的协程体执行是因为调用了resueWith(),而此处也是调用了resumeWith(),最终都会调用到invokeSuspend(),而该函数就是真正执行了协程体。
此次调用已经属于第二次调用invokeSuspend(),之前第一次调用后label=0变为label=1,因此第二次会走到label=1的分支,该分支没有直接return,而是继续执行最终打印语句。
而执行的这打印语句就是getStuInfo2()后的语句,说明父协程恢复执行了。

最后再小结一下withContext()函数恢复父协程的原理:

  1. 调用withContext()时传入父协程的协程体。
  2. 当withContext()的协程体执行完毕后会判断completion。
  3. completion 即为1的协程体。
  4. completion.resumeWith() 最后执行invokeSuspend(),通过状态机流转执行之前挂起逻辑之后的代码。
  5. 整个父协程体就执行完毕了。

协程恢复关键的俩字:回调
协程表面上写法很简洁,云淡风轻,实际内部将回调利用起来,这就是协程原理的冰山之下的内容。

4、不用withContext 如何挂起协程?

上个小结只是关心协程挂起与恢复的核心原理,有意避开了launch/withContext里有关协程调度器的问题(这部分下篇分析),可能有的小伙伴觉得没有完全弄明白,没关系,和启动原始协程一样,这次我们也通过原始的方法挂起协程,这样就摒除调度器逻辑的影响,专注于挂起的本身。

协程挂起的核心要点

回过头看看delay的实现:

suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        if (timeMillis < Long.MAX_VALUE) {
            //提交给loop进行超时任务的调度
            cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
        }
    }
}

suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (CancellableContinuation<T>) -> Unit
): T =
    suspendCoroutineUninterceptedOrReturn { uCont ->
        val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
        cancellable.initCancellability()
        //开始调度
        block(cancellable)
        //返回结果
        cancellable.getResult()
    }

你发现了和withContext()函数的共同点了吗?
没错,就是:suspendCoroutineUninterceptedOrReturn 函数。
它的作用就是将父协程的协程体传递给其它协程/调度器。
想当然地我们也可以模仿withContext、delay利用它来做文章。

原始协程的挂起

复用之前的原始协程的创建:

fun <T> launchFish(block: suspend () -> T) {
    //创建协程,返回值为SafeContinuation(实现了Continuation 接口)
    //入参为Continuation 类型,参数名为completion,顾名思义就是
    //协程结束后(正常返回&抛出异常)将会调用它。
    var coroutine = block.createCoroutine(object : Continuation<T> {
        override val context: CoroutineContext
            get() = EmptyCoroutineContext

        //协程结束后调用该函数
        override fun resumeWith(result: Result<T>) {
            println("result:$result")
        }
    })
    //开启协程
    coroutine.resume(Unit)
}

再编写协程挂起函数:

suspend fun getStuInfo3(): StudentInfo {
    return suspendCoroutine<StudentInfo> {
        thread {
            //开启线程执行耗时任务
            Thread.sleep(3000)
            var studentInfo = StudentInfo()
            println("resume coroutine")
            //恢复协程,it指代 Continuation
            it.resumeWith(Result.success(studentInfo))
        }
        println("suspendCoroutine end")
    }
}

getStuInfo3()即为一个有效的挂起函数,它通过开启子线程执行耗时任务,执行完毕后恢复协程。

最后创建和挂起结合使用:

fun main(array: Array<String>) {
    launchFish {
        println("before suspend")
        var studentInfo = getStuInfo3()
        //挂起函数执行返回
        println("after suspend student name:${studentInfo?.name}")
    }
    //防止进程退出
    Thread.sleep(1000000)
}

运行效果:
image.png

从结果上看,与使用withContext()函数效果一致。

原始协程挂起原理

重点看suspendCoroutine()函数:

#Continuation.kt
psuspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T {
    contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
    return suspendCoroutineUninterceptedOrReturn { c: Continuation<T> ->
        //传入的c 为父协程的协程体
        //c.intercepted() 为检测拦截器,demo里没有拦截器用自身,也就是c
        val safe = SafeContinuation(c.intercepted())
        //执行函数,也就是子协程体
        block(safe)
        //检测返回值
        safe.getOrThrow()
    }

与withContext()函数相似,最终都调用了 suspendCoroutineUninterceptedOrReturn() 函数。

#SafeContinuationJvm.kt
internal actual fun getOrThrow(): Any? {
    //原子变量
    var result = this.result
    //如果是默认值,则将它修改为挂起状态,并返回挂起状态
    if (result === CoroutineSingletons.UNDECIDED) {
        if (SafeContinuation.RESULT.compareAndSet(this,
                CoroutineSingletons.UNDECIDED, COROUTINE_SUSPENDED)) return COROUTINE_SUSPENDED
        result = this.result // reread volatile var
    }
    return when {
        result === CoroutineSingletons.RESUMED -> COROUTINE_SUSPENDED // already called continuation, indicate COROUTINE_SUSPENDED upstream
        result is Result.Failure -> throw result.exception
        //挂起或者正常数据返回走这
        else -> result // either COROUTINE_SUSPENDED or data
    }
}

此处构造SafeContinuation(),默认的状态为CoroutineSingletons.UNDECIDED,因此getOrThrow()会返回 COROUTINE_SUSPENDED。

对比withContext、delay、suspendCoroutine 的返回值:

coroutine.getResult() //withContext
cancellable.getResult()//delay
safe.getOrThrow()//suspendCoroutine

都是判断当前协程的状态,用来给外部协程确定是否需要挂起自身。

5、协程执行、挂起、恢复的全流程

行文至此,相信大家对协程的挂起与恢复原理有了一定的认识,将这些点串联起来,用图表示:
image.png
实线为调用,虚线为关联。

图上对应的代码:

fun startLaunch() {
    GlobalScope.launch {
        println("parent coroutine running")

        getStuInfoV1()

        println("after suspend")
    }
}
suspend fun getStuInfoV1() {
    withContext(Dispatchers.IO) {
        println("son coroutine running")
    }
}

至于反编译结果,此处就不展示了,使用Android Studio 可以很方便展示。
代码和图对着看,相信大家一定会对协程开启、挂起、恢复有个全局的认识。

下篇我们将会深入分析协程提供的一些易用API,launch/async/runBlocking 等的使用及其原理。

本文基于Kotlin 1.5.3,文中完整Demo请点击

您若喜欢,请点赞、关注,您的鼓励是我前进的动力

持续更新中,和我一起步步为营系统、深入学习Android/Kotlin

1、Android各种Context的前世今生
2、Android DecorView 必知必会
3、Window/WindowManager 不可不知之事
4、View Measure/Layout/Draw 真明白了
5、Android事件分发全套服务
6、Android invalidate/postInvalidate/requestLayout 彻底厘清
7、Android Window 如何确定大小/onMeasure()多次执行原因
8、Android事件驱动Handler-Message-Looper解析
9、Android 键盘一招搞定
10、Android 各种坐标彻底明了
11、Android Activity/Window/View 的background
12、Android Activity创建到View的显示过
13、Android IPC 系列
14、Android 存储系列
15、Java 并发系列不再疑惑
16、Java 线程池系列
17、Android Jetpack 前置基础系列
18、Android Jetpack 易学易懂系列
19、Kotlin 轻松入门系列

  移动开发 最新文章
Vue3装载axios和element-ui
android adb cmd
【xcode】Xcode常用快捷键与技巧
Android开发中的线程池使用
Java 和 Android 的 Base64
Android 测试文字编码格式
微信小程序支付
安卓权限记录
知乎之自动养号
【Android Jetpack】DataStore
上一篇文章      下一篇文章      查看所有文章
加:2022-06-25 18:16:18  更:2022-06-25 18:16:37 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/25 2:37:25-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码