kotlin协程-- 基础概念 ①|创建和使用

引言

首先先说一些相关概念

1.并发与并行

在操作系统中我们曾经学到过并发并行

并发:   是同一个时刻只有一条指令在执行,其他指令没有再执行,但是由于CPU的时间片特别短,导致多个指令来回切换的时间间隔特别短,就好像是同一时间多条指令在执行单核CPU与多核CPU都可以进行并发

并行:  在同一个时刻,多条指令在执行,这个不用想,只能在多核CPU中进行

2.同步与异步

同步操作很常见,我们一般运行一个程序,只能等该程序执行完毕后才能执行其他的程序。

而异步操作,是如果我们程序遇到一个循环10次的函数,我们的程序可能不会直接循环10次,而是跳过这个程序执行其他的程序。

在okhttp3中不就有同步的和异步两种请求方式

同步如下

        OkHttpClient okHttpClient = new OkHttpClient();//1.定义一个client
        Request request = new Request.Builder().url("http://www.baidu.com").build();//2.定义一个request
        Call call = okHttpClient.newCall(request);//3.使用client去请求
        try {
            String result = call.execute().body().string();//4.获得返回结果
            System.out.println(result);
        } catch (IOException e) {
            e.printStackTrace();
        }

异步如下

        OkHttpClient okHttpClient = new OkHttpClient();//1.定义一个client
        Request request = new Request.Builder().url("http://www.baidu.com").build();//2.定义一个request
        Call call = okHttpClient.newCall(request);//3.使用client去请求
        call.enqueue(new Callback() {//4.回调方法
            @Override
            public void onFailure(Call call, IOException e) {
 
            }
 
            @Override
            public void onResponse(Call call, Response response) throws IOException {
                String result = response.body().string();//5.获得网络数据
                System.out.println(result);
            }
        });

分析:

1、同步获得call之后,就通过call来获得Response然后再将Response转化为String类型。

2、异步的时候,获得call,就调用call的enqueue方法,然后在OnReponse中进行处理对了这enqueu中在handler的post方法中其实也用到过,我们post之后会调用它的sendMessageDelay然后调用sendMessageAtTime然后调用enqueueMessage,所以handler的post方法就是一个异步方法。

3.阻塞

3.1Looper的阻塞
3.1.1 loop的源码
public static void loop() {
    final Looper me = myLooper();
    if (me == null) {
        throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
    }
    if (me.mInLoop) {
        Slog.w(TAG, "Loop again would have the queued messages be executed"
                + " before this one completed.");
    }

    me.mInLoop = true;

    // Make sure the identity of this thread is that of the local process,
    // and keep track of what that identity token actually is.
    Binder.clearCallingIdentity();
    final long ident = Binder.clearCallingIdentity();

    // Allow overriding a threshold with a system prop. e.g.
    // adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
    final int thresholdOverride =
            SystemProperties.getInt("log.looper."
                    + Process.myUid() + "."
                    + Thread.currentThread().getName()
                    + ".slow", 0);

    me.mSlowDeliveryDetected = false;

    for (;;) {
        if (!loopOnce(me, ident, thresholdOverride)) {
            return;
        }
    }
}

我们可以看到在最后它有一个for死循环,只有当     !loopOnce(me, ident, thresholdOverride)

我们再看看loopOnce的源码

3.1.2loopOnce源码
private static boolean loopOnce(final Looper me,
        final long ident, final int thresholdOverride) {
    Message msg = me.mQueue.next(); // might block
    if (msg == null) {
        // No message indicates that the message queue is quitting.
        return false;
    }

    // This must be in a local variable, in case a UI event sets the logger
    final Printer logging = me.mLogging;
    if (logging != null) {
        logging.println(">>>>> Dispatching to " + msg.target + " "
                + msg.callback + ": " + msg.what);
    }
    // Make sure the observer won't change while processing a transaction.
    final Observer observer = sObserver;

    final long traceTag = me.mTraceTag;
    long slowDispatchThresholdMs = me.mSlowDispatchThresholdMs;
    long slowDeliveryThresholdMs = me.mSlowDeliveryThresholdMs;
    if (thresholdOverride > 0) {
        slowDispatchThresholdMs = thresholdOverride;
        slowDeliveryThresholdMs = thresholdOverride;
    }
    final boolean logSlowDelivery = (slowDeliveryThresholdMs > 0) && (msg.when > 0);
    final boolean logSlowDispatch = (slowDispatchThresholdMs > 0);

    final boolean needStartTime = logSlowDelivery || logSlowDispatch;
    final boolean needEndTime = logSlowDispatch;

    if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
        Trace.traceBegin(traceTag, msg.target.getTraceName(msg));
    }

    final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
    final long dispatchEnd;
    Object token = null;
    if (observer != null) {
        token = observer.messageDispatchStarting();
    }
    long origWorkSource = ThreadLocalWorkSource.setUid(msg.workSourceUid);
    try {
        msg.target.dispatchMessage(msg);
        if (observer != null) {
            observer.messageDispatched(token, msg);
        }
        dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
    } catch (Exception exception) {
        if (observer != null) {
            observer.dispatchingThrewException(token, msg, exception);
        }
        throw exception;
    } finally {
        ThreadLocalWorkSource.restore(origWorkSource);
        if (traceTag != 0) {
            Trace.traceEnd(traceTag);
        }
    }
    if (logSlowDelivery) {
        if (me.mSlowDeliveryDetected) {
            if ((dispatchStart - msg.when) <= 10) {
                Slog.w(TAG, "Drained");
                me.mSlowDeliveryDetected = false;
            }
        } else {
            if (showSlowLog(slowDeliveryThresholdMs, msg.when, dispatchStart, "delivery",
                    msg)) {
                // Once we write a slow delivery log, suppress until the queue drains.
                me.mSlowDeliveryDetected = true;
            }
        }
    }
    if (logSlowDispatch) {
        showSlowLog(slowDispatchThresholdMs, dispatchStart, dispatchEnd, "dispatch", msg);
    }

    if (logging != null) {
        logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
    }

    // Make sure that during the course of dispatching the
    // identity of the thread wasn't corrupted.
    final long newIdent = Binder.clearCallingIdentity();
    if (ident != newIdent) {
        Log.wtf(TAG, "Thread identity changed from 0x"
                + Long.toHexString(ident) + " to 0x"
                + Long.toHexString(newIdent) + " while dispatching to "
                + msg.target.getClass().getName() + " "
                + msg.callback + " what=" + msg.what);
    }

    msg.recycleUnchecked();

    return true;
}

我们不用看这么多代码,我们只需要知道什么时候返回true,什么时候返回false

我们就会发现当

当成功从消息队列中获取到一条消息时,会执行消息的分发和处理,并在最后通过 return true; 表示成功处理了一条消息,准备继续下一次循环。
当从消息队列中获取到的消息为 null,即消息队列正在退出时,会通过 return false; 表示没有更多的消息需要处理,退出循环。
这时候我们大概就明白,当消息队列不断有message传过来的时候,looper.loop会一直进行下去

当没有message传过来的时候looper.loop的for循环会退出

3.1.3注意
我当时把阻塞和死循环化成等号了,所以一直理解的就是在有消息传过来的时候,就会阻塞Looper,没有消息传过来的时候就不会阻塞Looper。但是阻塞的理解通俗的来说就是这个线程啥都不干,光等着,该线程处于休眠状态了或则长时间执行不完,卡在那里了。

所以根据这个理解的话,当没有Message传过来的时候,Looper处于阻塞状态。当有Message传来的时候,Looper不处于阻塞状态

现在我们来说说:Looper处于死循环是否会导致ANR?

3.2Looper处于死循环是否会导致ANR
3.2.1ANR是什么
在 Android 中,ANR(Application Not Responding)是指应用程序未响应用户交互事件(如触摸屏幕、按键等)的情况。当应用程序长时间未响应事件时,系统会显示一个 ANR 对话框,通知用户该应用程序已停止响应,然后给用户选择 “Force Close”(强制关闭)或 “Wait”(等待)的选项。

简单的说当一个任务占用太多资源就容易造成ANR

3.2.2Looper的死循环是否会导致ANR
面试中常考点是:Looper的死循环,会不会导致ANR?

答案是否定的,looper循环不会导致ANR ,只可能会阻塞主线程(当没有消息传过来) 它有一个消息队列 当消息队列里有消息的时候就会循环去取 当没有消息的时候就会调用epoll.await然后阻塞主线程 当重新有消息的时候会唤醒然后执行 他的阻塞和ANR不是一个概念的 ANR是指应用无响应 假如说我一个点击事件它长时间无响应就会导致ANR 但是阻塞的话是因为没有消息 而不是无响应。

我最开始想不通的一点是假如我looper处理一个特别大的任务,然后为什么不能反驳Looper的死循环是否导致ANR?
后来想了一下处理一个特别大的事件这是它的dispatchMessage处理的和我的死循环没有关系啊,我的死循环只负责把找消息又不负责处理消息。

这是一个学姐给我讲的,我原本以为这样就代表阻塞主线程之后也不会导致ANR

记得我们刚才说的两种导致阻塞的情况

1.没有消息传来等待消息传来

2.处理耗时量大的任务

我们现在是第一种情况,在没有收到消息时,它实际上是在等待新的消息到达。(相当于一个休眠状态)它并不会占用过多的CPU资源或者阻塞其他操作。在这种情况下,主线程依然可以处理用户界面事件和其他操作。所以,当Looper在收不到消息时阻塞主线程,它不会造成ANR。但是,如果Handler在处理消息时执行了耗时操作,这有可能导致ANR。

但这道题其实主要想问的是Looper的epoll与ANR的概念。我想的有点多,有点钻牛角尖了

3.2.3总结
在 Looper 的死循环中,它会不断地从消息队列中取出消息,并将消息分发给对应的 Handler 处理。当消息队列为空时,Looper 会一直循环等待新的消息到达。

在等待消息期间,Looper 的死循环会阻塞主线程,因为它会一直占用主线程的执行时间片。这意味着主线程无法继续执行其他任务或响应用户的输入事件或系统事件。

只有当主线程长时间占用了 CPU 或其他系统资源,并且长时间无法响应用户输入事件或完成关键操作时,才会触发 ANR 错误。

耗时操作本身并不会导致主线程卡死,导致主线程卡死的真正原因是耗时操作之后的操作, 没有在规定的时间内被分发。

4.挂起

挂起就是保存当前状态,等待恢复执行,在 Android 中的体现,挂起就是不影响主线程的工作,更贴切的说法可以理解为切换到了一个指定的线程。

4.1阻塞和挂起的区别

它们两个主要的不同在于释放CPU

挂起是协作式的,即在挂起时会主动将线程让出,使得线程可以执行其他任务。

同时,在协程挂起时,协程的堆栈和上下文会被保存下来,等待挂起结束后恢复执行。

这种方式可以避免线程的切换开销,提高程序的性能和响应速度。

阻塞是强制式的,即阻塞时会将线程一直占用,直到阻塞结束后才会继续执行。

在阻塞时,线程无法执行其他任务,因此会浪费 CPU 资源。

同时,阻塞也会增加线程切换的开销,降低程序的性能和响应速度。

5.多任务

多任务就是操作系统能够同时处理多个任务

多任务中又分出了2个:

一个是抢占式多任务

一个是协作式多任务

抢占式多任务就是操作系统自己来指定每个任务的CPU的占有时间,超过这个时间后。当前的这个任务就无法占有CPU了,交给下一个任务。
但是协作式多任务是除非你那个任务自己放弃对CPU的占有,否则别的任务无法用那个CPU。

协程

协程的作用

线程是非常重量级的,他需要依靠操作系统的调度才能实现不同线程的切换。但是协程却可以仅在编程语言的层面就能实现不同协程之间的切换

协程允许我们在单线程模式下模拟多线程编程的效果

协程的基本用法

导入包:

 implementation'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.1'
    implementation'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
2.1GlobalScope.launch
fun main(){
GlobalScope.launch {
    println("hello")
    Log.d("TAG","HELLO WORLD")
}
}

然后在MainActivity中调用main()  , 发现没有打印。

因为:Global.launch每次创建的都是一个顶层协程,这种协程当应用程序结束的时候会跟着一起结束。刚才日志无法打印就是因为代码块的代码还没来的及执行,应用程序就结束了。

我们只需要让程序延迟一段时间结束就可以了

fun main(){
GlobalScope.launch {
    println("hello")
    Log.d("TAG","HELLO WORLD")
}
    Thread.sleep(1000)
}

我们让这个main所在的线程阻塞1000ms,让主线程处于休眠状态,这时候我们会发现打印出来了。

存在一个问题,那么就是如果代码在1000ms内没办法结束,那么就会被强制中断。

fun main(){
GlobalScope.launch {
    println("hello")
    delay(1500)
    Log.d("TAG","HELLO WORLD")
}
    Thread.sleep(1000)
}

main所在的线程休眠1000ms然后执行GlobalScope.launch里面的代码,我们设置了在println()之后延迟1500ms才会执行Log.d()里面的东西

这时候我们会发现只会打印出println()里面的东西,但是打不出**Log.d()**里面的东西

2.2runBlocking

GlobalScope.launch的时候,因为它是一个顶层协程,会随程序的结束而结束

那么有没有一种协程,等它协程中的代码都执行完后,才会结束程序呢?

runBlocking

fun main1(){
    runBlocking {
        println("runBlocking")
    }
}

 输出  :runBlocking

runBlocking可以保证协程作用域内所有的代码和子协程中没有全部执行完之前,当前线程一直被阻塞。

一般runBlocking在正式开发中性能可能出现问题,所以我们一般只在测试环境下用。

2.3创建多个协程

在刚才的runBlocking里面加上launch就行了

fun main2(){
    runBlocking{
        launch {
            println("launch_0")
        }
        launch {
            println("launch_1")
        }
    }
}

输出:

launch_0

launch_1

例子:

fun main2(){
    runBlocking{
        launch {
            println("launch_0")
            delay(1000)
            println("launch_0 finished")
        }
        launch {
            println("launch_1")
            delay(1000)
            println("launch_1 finished")
        }
    }
}

输出:

launch_0
launch_1
launch_0 finished
launch_1 finished

tips:

launch与GlobalScope.launch不同,前者必须在协程的作用域中才能调用,其次它会在当前协程的作用域下创建子协程。

(子协程是一种当外层作用域的协程结束了,该作用域下所有的子协程也会一同结束)

2.4suspend关键字

launch必须在协程的作用域下才能被使用,如果我们把所有的launch都写在runBlocking里面,那么如果launch特别多的话,那么runBlocking所占的行数就更多了

那么可不可以最前面写了runBlocking后面调用方法?

ok,没问题。

fun main3(){
    runBlocking {
        main4()
        main5()
    }
}
fun main4(){
    println("没有协程作用域_0")
}
fun main5(){
    println("没有协程作用域_1")
}

但是呢,你会发现没办法调用类似delay()这种挂起函数,因为没有协程的作用域(通俗讲就是{}的范围就是作用域) 。

我们可以通过suspend关键字解决这个问题:

fun main3(){
    runBlocking {
        main4()
        main5()
    }
}
suspend fun main4(){
    delay(1000)
    println("没有协程作用域_0")
}
fun main5(){
    println("没有协程作用域_1")
}

这样就成功实现了**delay()**这类挂起函数

这样虽然可以实现delay这类挂起函数,但是我们不能调用launch函数,它只能在协程的作用域中才能调用。

2.5coroutineScope

coroutineScope函数是一个挂起函数,可以在任何其他挂起函数中调用。

它的特点是会继承外部的协程的作用域并创建一个子协程。

fun main6(){
    runBlocking {
        main7()
        main8()
    }
}
suspend fun main7() = coroutineScope{
   launch {
       println("coroutineScope_0")
       delay(1000)
   }
}
suspend fun main8() = coroutineScope{
    launch {
        println("coroutineScope_1")
        delay(1000)
    }
}

它可以保证其作用域内的所有代码和子协程在全部执行完之前,外部的协程会一直被挂起 。

coroutineScoperunBlocking不太一样

前者只会挂起当前协程,不影响其他协程,也不影响任何线程。

但是后者会挂起外部线程,如果在主线程中使用 runBlocking 函数来启动一个长时间运行的协程,就会导致主线程被阻塞,从而导致界面卡死的问题。

2.5.1注意

我当时就是这块没有理解,然后又去看了一眼Looper的死循环是否会导致ANR,我当时候的理解是Looper当没有收到消息,epoll会发出一个await指令,导致主线程被阻塞。但是阻塞不会导致ANR

然后我又看这里:runBlocking会挂起外部线程,如果在主线程中使用 runBlocking 函数来启动一个长时间运行的协程,就会导致主线程被阻塞,从而导致界面卡死的问题。

然后我就昏了,不是阻塞不会导致ANR嘛,这里为什么又导致了ANR,事实证明我当时的理解是错的,具体解析在上面写了。

这里因为处理耗时操作而导致主线程阻塞,时间太长的话就会导致ANR。
 

协程作用域

在讲构造器作用域之前,我们先来了解了解什么叫协程作用域

GlobalScope.launch中,它创建的是一个顶层协程,它里面的代码会随着应用程序的结束而结束。

runBlocking中我们说:runBlocking可以保证协程作用域里面的所有代码和子协程中没有全部执行完之前当前线程一直被阻塞。

然而launch,它必须依赖协程作用域才能执行

coroutineScoperunBlocking的作用效果差不多,它可以在协程作用域或者挂起函数中调用。

coroutineScope:只会挂起外部协程不会影响其他。

runBlocking:会导致直接把线程阻塞。

======================================================================   

什么叫协程作用域?
========================================================================================================

协程作用域(Coroutine Scope)是指协程的生命周期和作用域,用于管理协程的执行范围和生命周期,以确保协程的安全和正确性。

在协程中,通常使用 CoroutineScope 接口来定义协程作用域。CoroutineScope 接口提供了一组协程构建器(Coroutine Builder)协程上下文(Coroutine Context),用于创建和管理协程的生命周期和作用域。协程作用域可以是全局的,也可以是局部的,它们的生命周期可以是短暂的,也可以是长久的。

在协程作用域中,可以创建一个或多个协程,并将它们组织成一个层次结构。每个协程都有自己的作用域和生命周期,它们可以访问和共享作用域内的资源和状态。协程作用域还可以定义协程的取消策略和异常处理方式,以确保协程的安全和正确性。

========================================================================================================

协程必须在协程作用域中才能启动,

协程作用域中定义了一些父子协程的规则,

Kotlin 协程通过协程作用域来管控域中的所有协程

协程作用域间可并列或包含,组成一个树状结构,这就是 Kotlin 协程中的结构化并发。

作用域细分有下述三种:

顶级作用域:没有父协程的协程所在的作用域(GlobalScope.launch)

协同作用域:协程中启动新协程(即子协程),此时子协程所在的作用域默认为协同作用域,子协程抛出的未捕获异常都将传递给父协程处理,父协程同时也会被取消;(coroutineScope)

主从作用域:与协同作用域父子关系一致,区别在于子协程    出现未捕获异常时不会向上传递给父协程

父子协程间的规则

1、父协程如果取消或结束了,那么它下面的所有子协程均被取消或结束。

2、父协程需等待子协程执行完毕后才会最终进入完成状态,而不管父协程本身的代码块是否已执行完。

3、子协程会继承父协程上下文中的元素,如果自身有相同 Key 的成员,则覆盖对应 Key,覆盖效果仅在自身范围内有效。

作用域构造器

场景:我们要关闭一个应用,但是该应用中启动了一个协程来执行网络请求,而这个请求在应用关闭之前还没有完成,那么这个请求将会继续执行,这可能会浪费系统资源,例如网络带宽和 CPU 时间,而且还可能会导致请求结果无法正确处理或者造成其它一些问题。

我们在使用顶层协程进行网络加载操作的时候,可能会遇到这样的问题。

所以我们需要在退出应用之前,先把协程关了。

协程要怎么取消呢?不管是GlobalScope.launch函数还是launch函数,它们都会返回一个Job对象,只需要调用Job对象的**cancle()**方法就可以取消协程了。

val job = GlobalScope.launch { 
    //  处理具体逻辑
}
job.cancel()

如果我们每次创建的都是顶层协程,那么当Activity关闭时,就需要逐个调用所有已创建协程的cancel()方法,这种情况代码就很难维护了。因此,GlobalScope.launch这种协程作用域构建器,在实际项目中也是不太常用的。

实际项目常用的写法
val job = Job()
val scope = CoroutineScope(job)
scope.launch { 
    //  处理具体逻辑
}
job.cancel()

先创建一个Job对象,传入CoroutineScope()函数,CoroutineScope()函数会返回一个CoroutineScope对象,有了这个对象,就可以调用这个CoroutineScope对象的launch函数来创建一个协程了。

所以调用CoroutineScope的launch函数所创建的协程,都会被关联在Job对象的作用域下面,这样只需调用一次job.cancel()方法,就可以将同一作用域内的所有协程全部取消,这就大大降低了协程管理的成本。

而不用像GlobalScope那样,用多个GlobalScope.launch创建job对象,得每个job都**cancel()**一下才能把所有的协程关闭。但是像CoroutineScope,因为我们说过无论是GlobalScope.launch函数还是launch函数,它们都会返回一个Job对象,所以我们的首先的目的就是创建一个Job对象。然后我们将job传进CoroutineScope()获得一个CoroutineScope对象,然后将这个对象.launch。

最后只用将job.cancel()就可以全部取消了。

为什么GlobalScope不可以一次性取消所有的协程,而CoroutineScope可以 ?

简单来说就是GlobalScope一次性只能创建一个launch,但是CoroutineScope一次性可以创建多个launch 。

async

想要创建一个协程并获取它的执行结果,就要用到async函数。

一般情况下我们就是用CoroutineScope.launch但是获得的是Job对象。

示例:

   var job = Job()
        var c = String()
        var coroutineScope = CoroutineScope(job)
        coroutineScope.launch {
            c = "nihao"
        }
        println(job)

输出:

JobImpl{Active}@660a746

fun main00(){
    runBlocking {
        val result = async {
             5+5
        }.await()
        println(result)
    }
}

输出: 10

=====================================================================

async 总结 :

1、async函数必须在协程作用域当中才能调用,它会创建一个新的子协程,并且返回一个Deferred对象。

2、在调用完async函数之后代码块中的代码就会立即执行。

当调用await()方法之后,如果代码块的代码还没有执行完,那么await()方法会将当前协程阻塞住,等待获得async函数的执行结果。

即如果async函数里面的代码块还没有执行完的话,因为有await()方法,所以async所在的协程作用域不会在async执行完前结束,而是先将runBlocking这个协程阻塞,等async 函数执行完成之后再结束。

withContext简化async

fun main() {
    runBlocking {
        val result = withContext(Dispatchers.Default) {
            5 + 5
        }
        println(result)
    }
}

调用withContext()函数之后,会立即执行代码块中的代码,同时将外部协程挂起。当代码块中的代码全部执行完之后,会将最后一行的执行结果作为withContext()函数的返回值返回,我们可以发现withContext的用法和使用async差不多。

我们先把async实现这个的代码写出来:

fun main() {
    runBlocking {
        val result = async {
            5 + 5
        }.await()
        println(result)
    }
}

比较:

withContext与async最大的不同是: Dispatchers.Default

withContext()函数会强制要求指定一个线程参数

协程虽然是很轻量级别的线程,且多个协程可以运行在一个线程里面,但是这并代表着我们就不开线程了。

比如网络请求,我们都知道它是一个耗时很大的操作,在java中我们处理网络请求通常都是另开一个线程,让它在新的线程里面执行,因为我们知道,在主线程里面执行网络请求,容易因为它的耗时大导致ANR。

Dispatchers里面有四种:

分别有Default,Main,IO,Unconfined

至于为什么CPU密集型的计算任务在Dispatcher.Default中执行,因为CPU密集型的计算任务不能在并发特别高的情况下进行。

在我们刚才所学的协程作用域构建器中,除了coroutineScope函数之外,其他所有的函数都是可以指定这样一个线程参数的,只不过withContext()函数是强制要求指定的,而其他函数则是可选的。

结束。

下一篇: kotlin协程-- 基础概念 ② |协程取消和异常处理-CSDN博客

  • 9
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值