Kotlin协程(5)Flow

0,引子

看下面的例子:

fun fooAsync(p: Params): CompletableFuture<Value> = 
    CompletableFuture.supplyAsync { bar(p) } 

可以使用Future来获取需要长时间运行的异步返回的值。

当调用fooAsync(p)时,它会承诺将来会提供一个值,并且后台会运行一个操作栏来计算该值。现在您必须小心不要丢失对这个Future的引用,因为这个Future实际上是一种资源,就像一个打开的文件。您必须等待它或者如果不再需要它的值时则取消它。

这被称为热数据源。与常规函数不同,常规函数仅在调用期间才处于活动状态,而热源甚至在调用相应函数之外也处于活动状态,它可能在调用该函数之前就已在后台处于活动状态,而在调用该函数之后仍可以处于活动状态,就像我们在这里看到的那样。

在kotlin中可以使用挂起函数避免热数据源的麻烦

suspend fun foo(p: Params): Value =
    withContext(Dispatchers.Default) { bar(p) }

在执行bar操作时,foo的调用方被挂起。无需担心会意外丢失对正在运行的后台操作的引用,并且使用挂起函数编写的代码看起来很熟悉-就像常规的同步的代码一样。这个foo函数定义很冷-在调用之前它没有做任何事情,在返回值之后也不会做任何事情。

如果我们想获取一个流式返回怎么办?

fun foo(p: Params): Sequence<Value> =
    sequence { while (hasMore) yield(nextValue) }

这样?如果您使用sequence作为返回类型来表示流式API,则等待传入值必须阻塞调用者的线程。这不利于UI应用程序。对于异步编程,我们要挂起的协程,对于协程间的通信kotlin提供了Channel。

fun fooProducer(p: Params): ReceiveChannel<Value> =
    GlobalScope.produce { while (hasMore) send(nextValue) }

但是如果这样做的化我们就遇到到Futer同样的问题,Channel代表着热值流。通道的另一端正在协力生成值,因此我们不能只删除对ReceiveChannel的引用,因为生产者将永远被挂起,以等待使用者,浪费内存资源,开放网络连接等。

结构化并发在某种程度上缓解了这一问题。观察到fooProducer启动了一个协程,该协程与其余代码同时工作。我们可以通过将fooProducer函数声明为CoroutineScope的扩展来使并发性明确化:

fun CoroutineScope.fooProducer(p: Params): ReceiveChannel<Value> =
    produce { while (hasMore) send(nextValue) }

通过结构化的并发,丢失的通道会阻止外部协程作用域的完成,从而有效地“挂起”正在进行的操作。但是,它不能完全解决问题,而只是改变了我们的错误的影响,我们仍然不能这样写。

总而言之,使用Channel并不像使用挂起函数使用单个值或使用同步值序列那样简单,并且由于并发性而涉及细微的问题和约定。

Channel非常适合对本质上很热的数据源进行建模,这些数据源在没有应用程序要求的情况下就存在:传入的网络连接,事件流等。

就像Future一样,Channel也是同步原语。当您需要以相同或不同的过程将数据从一个协程发送到另一个协程时,您应该使用一个通道,因为不同的协程是并发的,并且需要同步才能在存在并发的情况下处理任何数据。但是,同步总是以性能为代价。

但是,如果我们不需要并发或同步,而只需要非阻塞数据流,该怎么办?Flow是一个不错的选择。

fun foo(p: Params): Flow<Value> =
    flow { while (hasMore) emit(nextValue) }

就像sequence一样,flow代表着一个冷的数据流。 foo的调用者获得对flow实例的引用,但是flow{...}构建器中的代码未激活,尚无资源绑定到该实例。与sequence相似,可以使用各种通用运算符(如map,filter等)来转换flow。与sequence不同,flow是异步的,并允许在其生成器和运算符中的任何位置挂起函数。例如,以下代码定义十个整数的flow,每个整数的延迟时间为100毫秒:

val ints: Flow<Int> = flow { 
    for (i in 1..10) {
        delay(100)
        emit(i)
    }
}

 flow的终端operators收集该流程发出的所有值,仅在相应操作期间激活流程代码。它使流程变冷-在调用终端操作之前它是不活动的,在调用返回之前释放所有资源后,它是不活动的。最基本的终端操作称为收集。它是一个挂起函数,用于在收集流时挂起调用协程:

ints.collect { println(it) } 

与通道不同,flow本质上不涉及任何并发。它是非阻塞的,但是是顺序的。flow的目标是使异步数据流的悬浮功能成为异步操作,即方便,安全,易学且易于使用。

前面说了这么多都是为了引出今天的主角Flow。

1,Flow

flow主要涉及2个接口:

interface Flow<out T> {
    suspend fun collect(collector: FlowCollector<T>)
}

flow的唯一功能是单个collect函数,该函数使用单个emit方法接受FlowCollector接口的实例:

interface FlowCollector<in T> {
    suspend fun emit(value: T)
}

流构建器的签名还使用FlowCollector接口作为接收器,以便我们可以直接从相应的lambda的主体中发出:

fun <T> flow(block: suspend FlowCollector<T>.() -> Unit): Flow<T>

对于流的简单用法,在收集流时,如下所示:

val ints: Flow<Int> = flow { 
    for (i in 1..10) {
        delay(100)
        emit(i)
    }
}

ints.collect { println(it) }

这其中进行了什么操作呢?基于传递来collect{…}函数的lambda创建了FlowCollector的实例,然后将该实例传递给了follow{…}的body。

public suspend inline fun <T> Flow<T>.collect(crossinline action: suspend (value: T) -> Unit): Unit =
    collect(object : FlowCollector<T> {
        override suspend fun emit(value: T) = action(value)
    })

可以看到FlowCollector实例的有一个emit方法,方法体就是lamda表达式action.


因此, flow emitter和flow collector之间的交互是一个简单的函数调用-调用emit函数。如果我们在头脑中内联此函数调用,我们可以立即了解在运行此代码时发生的情况,它等同于:

for (i in 1..10) {
    delay(100)
    println(i) // <-- emit was called here => FlowCollector.emit(value)
}

2,操作符

前面说过Flow可以像Sequence一样运行各种操作符,如:

fun <T, R> Flow<T>.map(transform: suspend (value: T) -> R) = flow {
    collect { emit(transform(it)) }
}

由于Kotlin支持扩展函数,Flow定义操作也是非常简单的,kotlinx.coroutines库已经将map和许多其他通用运算符定义为Flow类型的扩展。

为了更好地了解流的顺序性质,请看一下下面的示例流,该流发出十个整数,它们之间的延迟为100毫秒:

suspend fun productor() = flow<Int> {
        for (i in 1..10) {
            emit(i)
            delay(100)
        }
    }

让我们确认收集它大约需要一秒钟:

   @Test
    fun consumer() = runBlocking {

        val time = measureTimeMillis {
            productor().collect {
                println(it)
            }
        }
        println(time)
    }

但是,如果收集器也很慢,并且在打印每个元素之前增加了自己的100 ms延迟,会发生什么情况?看看这个:

   @Test
    fun consumer() = runBlocking {

        val time = measureTimeMillis {
            productor().collect {
                delay(100)
                println(it)
            }
        }
        println(time)
    }

完成此过程大约需要2秒钟,因为此处的发射器和收集器都是顺序执行的一部分,并且在它们之间交替进行:

我们可以安排执行的结构,使整个操作更快地完成,而不更改发射者或收集者的代码吗?我们可以。我们需要分离发射器和收集器-在与收集器分开的协程中运行发射器,以便它们可以并发运行。但是,对于两个独立的协程,我们不能简单地通过函数调用来发出元素。我们需要在两个协程之间建立一些通信。这正是渠道的设计宗旨。您可以通过一个协程通过一个通道发送元素,并在另一个协程中接收它们,这样整个执行过程将如下所示:

我们定义一个Flow的扩展操作符buffer:

fun <T> Flow<T>.buffer(size: Int = 0): Flow<T> = flow {
    coroutineScope {
        val channel = produce(capacity = size) {
            collect { send(it) }
        }
        channel.consumeEach { emit(it) }
    }
}

我们使用coroutineScope来构造并发结构,并避免将生产者协程泄漏到范围之外.

   @Test
    fun consumer() = runBlocking {
        val time = measureTimeMillis {
            productor().buffer().collect {
                delay(100)
                println(it)
            }
        }
        println(time)
    }

运行与之前相同的发射器和收集器代码,但在它们之间使用上面的buffer()运算符可以实现所需的执行速度加速。

3,上下文

在许多情况下,代码的执行上下文很重要。在服务器端程序的上下文中可能会携带诊断信息;在UI应用程序中,小部件只能从特定的主线程进行触摸。当您的代码变大时,这可能会造成潜在的问题,尤其是在将数据生产者和数据使用者分离时。 Kotlin Flows旨在实现这种模块化,因此让我们看看它们在执行上下文方面的表现。

launch(Dispatchers.Main) { // launch in the main thread
    initDisplay() // prepare ui
    dataFlow().collect { // block of the collector begins
        updateDisplay(it) // update ui
    } 
}

UI应用程序可以在主线程中启动协程,以从一些dataFlow()函数返回的流中收集元素,并使用其值更新显示。

dataFlow必须执行一些消耗CPU的计算,该怎么办:

fun dataFlow(): Flow<Data> = flow { // create emitter
    withContext(Dispatchers.Default) {
        while (isActive) {
            emit(someDataComputation())
        }
    }
}

如果允许这种流发射器实现,则collect中的updateDisplay会尝试从错误的线程更新UI。为什么?看1,Flow最后一段

每个流程实现都必须保留收集器的上下文。实际上,这意味着flow{...}构建器函数不会将值直接传递给发出时收集器的块,而是包含检查此上下文保留不变性的逻辑。

实现dataFlow()发射器函数的正确方法是什么?首先,我们将删除withContext并从收集器的上下文中发出:

fun dataFlow(): Flow<Data> = flow { // create emitter
    while (isActive) {
        emit(someDataComputation())
    }
}

但是,someDataComputation可能会阻止收集器的线程,从而冻结UI。有两种解决方法。一种是将适当的上下文封装在someDataComputation本身中:

fun someDataComputation(): Data = 
    withContext(Dispatchers.Default) { 
        // implementation here
    }

这对于隔离功能很好用,但是如果flow{...}中的整个代码需要特定的上下文,则很不方便,也无法在每个值之间来回切换上下文。因此,还有一个对结果流使用flowOn运算符的解决方案:

fun dataFlow(): Flow<Data> = flow { // create emitter
    while (isActive) {
        emit(someDataComputation())
    }
}.flowOn(Dispatchers.Default) // ^ works on the flow before it

flowOn函数更改了所应用的流的上下文,同时确保为将在其后应用的收集器保留上下文。 flowOn运算符的实现使用指定的Dispatchers.Default上下文创建一个单独的协程,以收集someDataComputation流,同时在原始收集器的上下文中发出。

相同的上下文保留规则适用于流上的运算符。考虑以下流程:

dataFlow()
    .map { opA(it) } // in contextA
    .flowOn(contextA) 
    .map { opB(it) } // in collector's context

在这里,opB在收集器的上下文中被调用,但是opA的上下文受flowOn运算符影响。

总而言之,Kotlin Flows执行上下文的规则很简单。不在乎其执行上下文的非阻塞代码无需采取任何特殊的预防措施。收集器始终可以确保保留其执行上下文。对于需要某些特定执行上下文的代码,可以将flowOn运算符放在相应的上下文敏感代码之后,以在指定的上下文中收集其上的流。

4,Reactive Streams 

Reactive Extensions(简称ReactiveX或Rx)最初由Erik Meijer为.NET创建,并于2010年向公众公开。这是一种用于异步数据流的API的新方法,该方法通用化了观察者模式以及发射元素的回调(onNext) ,流完成(onCompleted)和错误(onError),并引入了流处理运算符(例如map和filter),使操作数据流与使用集合一样容易。

这里就得提提RxJava了。

   @Test
    fun rxJavaRs() {
     val subscription = Observable.fromCallable {
            {
                "${Thread.currentThread().name}:rxjava"
            }()
        }.subscribeOn(Schedulers.io())
            .observeOn(Schedulers.computation())
            .subscribe({
                println(it)
            }, {
                it.printStackTrace()
            }, {
                println("${Thread.currentThread().name}:Complete")
            })

        Thread.sleep(100)
    }

输出:

RxCachedThreadScheduler-1:rxjava
RxComputationThreadPool-1:Complete

这个用Fllow怎么写?

 @Test
    fun fllowRx() = runBlocking {
        flow {
            emit("${Thread.currentThread().name}:fllow")
        }.flowOn(Dispatchers.IO)
            .onEach {
                println(it)
            }
            .catch {
                it.printStackTrace()
            }.onCompletion {
                println("${Thread.currentThread().name}:Complete")
            }.flowOn(Dispatchers.Default)
            .collect()
    }

输出:

DefaultDispatcher-worker-3 @coroutine#3:fllow
DefaultDispatcher-worker-2 @coroutine#2:Complete

 tips:注意我上面RxJava用的fromCallable,可不可以用Just代替呢?答案是不可以,原因:拥抱RxJava(三):关于Observable的冷热,常见的封装方式以及误区

可以看到上面RxJava持有一个Subscription对象引用,如果您希望能够取消此订阅,​​则必须仔细管理该对象,否则可能会泄漏它。这与结构化并发正在解决的问题非常相似,因此设计Flow是合理的,这样您就不会意外泄漏订阅。

Kotlin Flow完全没有订阅的概念。挂起和轻量协程来解救。Flow的collect操作是最类似于订阅的概念,但是它只是一个挂起的函数调用,由于结构化的并发性而很难泄漏或以其他方式滥用。

基于挂起的collect操作设计也消除了对onError和onCompleted回调的单独设置的需求,如果需要可以通过catch 和onCompletion 操作符来实现,当然Fllow也提供了一个onEach操作符来替换onNext回调。

Fllow为subscriptionOn / observeOn设计了一个一致的机制,其中只有一个flowOn运算符。Rxjava中subscriptionOn / observeOn的用途是不一样的,这不是本文的重点就不说了。

5,异常

假设我们正在编写一个UI应用程序,该应用程序在UI中显示值的更新流,从而从流中收集它们。此应用程序的uiScope是CoroutineScope,其生存期绑定到显示数据的相应UI元素。有一个dataFlow()函数返回带有要显示的数据的流,因此可以像这样激活数据显示:

uiScope.launch { // launch a UI display coroutine
    dataFlow().collect { value -> updateUI(value) }
}

Flow确保updateUI始终在uiScope在此处定义的收集器的执行上下文中调用。即使dataFlow()在内部使用了其他上下文,该事实也不会以任何方式从中泄漏出来。

但是,如果dataFlow()中有错误怎么办?在这种情况下,collect调用将引发异常,从而导致协程异常完成,协程将传播到uiScope,通常,最终将在其上下文中调用未捕获的异常处理程序(CoroutineExceptionHandler)。如果异常确实是意外的,并且永远都不会在正确的代码中发生,那很好,但是例如,如果dataFlow()正在从网络中读取数据,并且当网络出现问题时,很可能会发生故障?需要处理。失败是通过异常报告的,可以像通常在Kotlin中处理异常一样处理-使用try / catch块:

uiScope.launch { 
    try {
        dataFlow().collect { value -> updateUI(value) }
    } catch (e: Throwable) {
        showErrorMessage(e)
    }
}

如果将这种异常处理逻辑封装到流上的运算符中,那么我们可以简化此代码,减少嵌套并使其更具可读性:

uiScope.launch {
    dataFlow()
        .handleErrors() // handle dataFlow errors
        .collect { value -> updateUI(value) }
}

但是,我们如何实现handleErrors函数呢?如下所示:

fun <T> Flow<T>.handleErrors(): Flow<T> = flow {
    try {
        collect { value -> emit(value) }
    } catch (e: Throwable) {
        showErrorMessage(e)
    }
}

此实现从调用它的上游流中收集值,并向下游发出它们,就像之前一样,将collect调用包装到try / catch块中。它只是抽象了我们最初编写的代码。能行吗?是的,对于这种特殊情况。那么,对于通用的情况呢?

考虑一下handleErrors返回的流的属性:

val flow = dataFlow().handleErrors()

它像其他任何流一样发出一些值,但是它还具有其他流所没有的其他属性-任何错误是下游流被它捕获。考虑以下带有Kotlin错误功能的代码作为测试:

flow.collect { error("Failed") }

如果以简单的流程运行它,则此代码将在第一个发出的值上引发anIllegalStateException。但是使用handleError返回的流时,此异常将被捕获并且不会出现,因此collect调用可以正常完成。为什么?看1,Flow最后一段

Kotlin流旨在允许对数据流进行模块化推理。流的唯一假定影响是它们的发射值和完成,因此流规范不允许使用类似handleError的流运算符。每个流程实现都必须确保异常透明-下游异常必须始终传播到收集器。

其实上面已经看到了kotlin已经提供catch操作符给我们用,不用我们自己定义一个,我们可以把handlerErrors()中用catch实现。

fun <T> Flow<T>.handleErrors(): Flow<T> = 
    catch { e -> showErrorMessage(e) }

但是,生成的代码与我们使用try / catch编写的原始代码具有不同的行为,因为它没有捕获由于异常透明而可能在collect {value-> updateUI(value)}调用中发生的错误。我们可以通过重写以下代码来继续处理updateUI中的错误:

uiScope.launch { 
    dataFlow()
        .onEach { value -> updateUI(value) }
        .handleErrors() 
        .collect()
}

将updateUI从collect移到onEach运算符中,我们已将其放置在handleErrors中的错误处理之前,因此现在也可以处理updateUI错误。最后,我们现在可以使用launchIn终端运算符合并launch和collect调用,进一步减少此代码中的嵌套并将其转换为简单的从左到右的运算符序列:

dataFlow()
    .onEach { value -> updateUI(value) }
    .handleErrors() 
    .launchIn(uiScope)

6,背压

说到流就不得不讨论一个背压(Back-pressure)的问题了,背压定义为数据使用者无法跟上输入数据的能力,无法将信号发送到数据生产者以减慢数据元素的速率。

传统的反应流设计涉及一个反向通道,以根据需要向生产者请求更多数据。即使对于简单的操作符,此请求协议的管理也会导致非常困难的实现。我们在Kotlin流程的设计中或在其操作员的实现中都没有看到这种复杂性,但是Kotlin流程确实支持背压。怎么会?

通过使用Kotlin挂起功能,可以在Kotlin流程中实现透明的背压管理。您可能已经注意到,Kotlin流程设计中的所有函数和功能类型都标有suspend修饰符-这些函数具有在不阻塞线程的情况下挂起调用程序执行的强大功能。因此,当流的收集器不堪重负时,它可以简单地挂起发射器,并在准备好接受更多元素时稍后将其恢复。

7,RxJava互操作

Kotlin Flows在概念上仍然是反应性流。即使它们是基于悬浮的并且没有直接实现相应的接口,它们的设计方式也使得与基于反应流的系统的集成变得简单。我们提供了开箱即用的flow.asPublisher()扩展函数,可将Flow转换为反应流Publisher接口,并提供Publisher.asFlow()扩展以进行反向转换。

  @Test
    fun fllowToRxjava(){
        fun <T> Publisher<T>.toFloweable() = Flowable.fromPublisher(this)
        flow {
            emit("${Thread.currentThread().name}:fllow")
        }.flowOn(Dispatchers.IO)
            .asPublisher()
            .toFloweable()
            .observeOn(Schedulers.computation())
            .subscribe({
                println(it)
            }, {
                it.printStackTrace()
            }, {
                println("${Thread.currentThread().name}:Complete")
            })

        Thread.sleep(100)
    }

依赖为:

   implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:versionCode")

8,番外

考虑一个问题:

interface Operation<T> {
    fun performAsync(callback: (T?, Throwable?) -> Unit)
}

这个东西用协程怎么改写?

suspend fun <T> Operation<T>.perform(): T =
    suspendCoroutine { continuation ->
        performAsync { value, exception ->
            when {
                exception != null -> // operation had failed
                    continuation.resumeWithException(exception)
                else -> // succeeded, there is a value
                    continuation.resume(value as T)
            }
        }
    }

这样对吧。

注意,这种表现是一种冷的数据源。它不会执行任何操作,直到它被调用为止,并且在返回之后不会执行任何操作,因为它会等待通过回调的操作完成。

那如果支持取消呢?

interface Operation<T> {
    fun performAsync(callback: (T?, Throwable?) -> Unit)
    fun cancel() // cancels ongoing operation
}

可以这样改写:

suspend fun <T> Operation<T>.perform(): T =
    suspendCancellableCoroutine { continuation ->
        performAsync { /* ... as before ... */ }
        continuation.invokeOnCancellation { cancel() }
    }

那如果我要这么一个需求:如果Operation提供异步值流并多次调用指定的回调该怎么办?它也必须以某种方式表明它的完成。对于这个简单的示例,我们假设它是通过调用具有null值的回调来实现的。怎么改写?

我们不能将此类Operation与suspendCoroutine之类的函数一起使用,以免在第二次尝试恢复延续时得到IllegalStateException,因为Kotlin的挂起和resume是single-shot.

Kotlin Flow进行救援!流被明确设计为代表多个值的冷异步流。我们可以使用callbackFlow函数将多次回调转换为流:

fun <T : Any> Operation<T>.perform(): Flow<T> =
    callbackFlow {
        performAsync { value, exception ->
            when {
                exception != null -> // operation had failed
                    close(exception)
                value == null -> // operation had succeeded
                    close()
                else -> // there is a value
                    offer(value as T)
            }
        }
        awaitClose { cancel() }
    }

注意许多重要的区别。首先,perform不再是暂停功能。它本身不会等待任何东西。它返回冷流。直到终端操作的调用者收集了此流后,才调用callbackFlow {...}块中的代码。
和以前一样,performAsync将安装一个回调,但是现在,我们正在使用一个热的SendChannel来代替Continuation,该热SendChannel已开放以传递值。因此,将为每个值调用offer函数,并调用close函数以表示失败或成功完成。在这里,awaitClose替换了invokeOnCancellation,并且还起到了重要的功能,即在传入值时将块暂停在callbackFlow中。

如何支持背压?

如果performAsync将值传递给回调的速度快于收集协程可以处理它们的速度,会发生什么?输入在处理异步数据流时总是出现的背压问题。有一个缓冲区可以保留一些值,但是当该缓冲区溢出时,提供的返回值将为false且值将丢失。有几种方法可以避免或控制损失。
一种是用sendBlocking(value)替换offer(value)。在这种情况下,调用回调的线程会在缓冲区溢出时被阻塞,直到缓冲区中有更多空间为止。这是在大多数传统的基于流回叫的API中向后压表示信号的一种典型方法,它可以确保不会丢失任何价值。
如果期望值的数量受到限制或期望值不能太快到达,那么我们可以使用缓冲区运算符来配置无限的缓冲区大小,方法是在callbackFlow {...}之后添加.buffer(Channel.UNLIMITED)调用。在这种情况下,offer始终返回true,因此不会丢失任何价值,也不会出现阻塞。但是,可能会耗尽缓冲值⁶的内存。

通常,值流表示部分操作结果或状态更新,因此只有最新的值才真正有意义。这意味着可以使用conlate运算符对结果流进行安全地合并,这可以保证offer始终返回true,并且即使可以丢弃(合并)中间值,收集器也可以看到最新的值。

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Kotlin StateFlowKotlin协程库中的一种流(Flow)实现,用于在异步场景下处理状态的变化。StateFlow可用于代替LiveData或RxJava的Observable,提供了一种简洁且类型安全的方式来观察和响应状态的变化。 StateFlow是一个具有状态的流(flow),它可以发射新的值并保持最新的状态。与普通的Flow相比,StateFlow更适用于表示单一的可变状态,并且可以方便地在多个观察者之间共享。 StateFlow在使用上类似于普通的Flow,你可以使用`stateIn`函数将其转换为一个只读的SharedFlow,并使用`collect`或`conflate`等操作符来观察和处理状态的变化。 下面是一个使用StateFlow的示例代码: ``` import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val state = MutableStateFlow("Initial state") val job = launch { state.collect { value -> println("Received state: $value") } } state.value = "Updated state" job.cancel() } ``` 在上面的示例中,我们创建了一个MutableStateFlow对象并初始化为"Initial state"。然后使用`collect`函数来观察state的变化,并在状态发生变化时打印出新的值。我们通过修改`state.value`来更新状态,并在控制台上看到"Received state: Updated state"的输出。 总之,Kotlin StateFlow提供了一种方便的方式来处理状态的变化,并与Kotlin协程无缝集成,使得在异步场景下处理状态变化更加简洁和可靠。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值