kotlin中如何选择合适的响应式数据流

前言

在现代移动应用开发中,采用响应式编程范式是提高应用性能和可维护性的重要方式之一。而在 Kotlin 开发环境下,MVI(Model-View-Intent)架构已经成为了一种流行的选择。本文将深入探讨基于 Kotlin MVI 架构下的响应式数据流使用方式,以及它们各自的适用场景。

1.MVI架构简介:

MVI架构是一种基于数据流的响应式编程架构模式,它将应用程序分为四个核心组件:模型(Model)、视图(View)、意图(Intent)和状态(State)。

原理:

  • 模型(Model):负责处理数据的状态和逻辑。
  • 视图(View):负责展示数据和用户界面。
  • 意图(Intent):代表用户的操作,如按钮点击、输入等。
  • 状态(State):反映应用程序的当前状态。

流程:

  1. 用户通过视图(View)发起意图(Intent)。
  2. 意图(Intent)被传递给模型(Model)。
  3. 模型(Model)根据意图(Intent)进行状态(State)的更新。
  4. 状态(State)的变化被传递给视图(View),视图(View)进行相应的界面更新。

架构图:

目前,官方提供的可观察的数据组件有LiveData、StateFlow、SharedFlow、Flow、Channel。大家最常见的应该是LiveData,配合ViewModel可以很方便的实现数据流的流转。不过,LiveData也有很多常见的缺陷,并且使用场景也比较固定。

那么,在什么场景下用哪一种可观察数据组件最合适呢?我们来进行一一分析。

2. LiveData

原理:LiveData 是一种生命周期感知型的数据持有类,它可以在数据发生变化时通知观察者。它与界面的生命周期绑定,可以确保数据更新只会在活跃的生命周期内触发。

LiveData核心方法:

方法名

作用

observe(LifecycleOwner owner,Observer observer)

注册和宿主生命周期关联的观察者

observeForever(Observer observer)

注册观察者,不会反注册,需自行维护

setValue(T data)

发送数据,没有活跃的观察者时不分发。只能在主线程。

postValue(T data)

和setValue一样,不受线程环境限制,

onActive

当且仅当有一个活跃的观察者时会触发

inActive

不存在活跃的观察者时会触发

缺点:

  • LiveData 只能在主线程更新数据: 只能在主线程 setValue,即使 postValue 内部也是切换到主线程执行;
  • LiveData 数据重放问题: 注册新的订阅者,会重新收到 LiveData 存储的数据,这在有些情况下不符合预期(可以使用自定义的 LiveData 子类 SingleLiveData 或 UnPeekLiveData 解决,此处不展开);
  • LiveData 不防抖: 重复 setValue 相同的值,订阅者会收到多次 onChanged() 回调(可以使用 distinctUntilChanged() 解决,此处不展开);
  • LiveData 不支持背压: 在数据生产速度 > 数据消费速度时,LiveData 无法正常处理。比如在子线程大量 postValue 数据但主线程消费跟不上时,中间就会有一部分数据被忽略。

适用场景:

LiveData 作为一个 可感知生命周期的,可观察的,数据持有者,简单即是它的优势,也是它的局限,当然这些局限性不应该算 LiveData 的缺点,因为 LiveData 的设计初衷就是一个简单的数据容器。对于简单的数据流场景,使用 LiveData 完全没有问题,它适用于需要与 UI 生命周期绑定、且不需要复杂操作符链的简单数据流。

举例:通过观察livedata数据变化,更新UI显示

class NameViewModel : ViewModel() {

    val currentName: MutableLiveData<String> by lazy {
        MutableLiveData<String>()
    }
}

class NameActivity : AppCompatActivity() {

    private val model: NameViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val nameObserver = Observer<String> { newName ->
            nameTextView.text = newName
        }
        model.currentName.observe(this, nameObserver)
    }
}

3.Flow

为什么引入Flow,我们可以从Flow解决了什么问题的角度切入

  1. LiveData不支持线程切换,所有数据转换都将在主线程上完成,有时需要频繁更改线程,面对复杂数据流时处理起来比较麻烦
  2. 而RxJava又有些过于麻烦了,有许多让人傻傻分不清的操作符,入门门槛较高,同时需要自己处理生命周期,在生命周期结束时取消订阅,而Flow作用在协程内,可以与协程的生命周期绑定,当协程取消时,Flow也会被取消,避免了内存泄漏风险。

可以看出,Flow是介于LiveData与RxJava之间的一个解决方案,它有以下特点

  • Flow 支持线程切换、背压
  • Flow 入门的门槛很低,没有那么多傻傻分不清楚的操作符
  • 简单的数据转换与操作符,如 map 等等
  • 冷数据流,不消费则不生产数据,每次重新订阅收集都会将所有事件重新发送一次,这一点与LiveData不同:LiveData的发送端并不依赖于接收端。
  • 属于kotlin协程的一部分,可以很好的与协程基础设施结合

Kotlin Flow 包含三个实体:数据生产方 - (可选的)中介者 - 数据使用方。数据生产方负责向数据流发射(emit)数据,而数据使用方从数据流中消费数据。根据生产方产生数据的时机,可以将 Kotlin Flow 分为冷流和热流两种:

  • 普通 Flow(冷流): 冷流是不共享的,也没有缓存机制。冷流只有在订阅者 collect 数据时,才按需执行发射数据流的代码。冷流和订阅者是一对一的关系,多个订阅者间的数据流是相互独立的,一旦订阅者停止监听或者生产代码结束,数据流就自动关闭。
  • SharedFlow / StateFlow(热流): 热流是共享的,有缓存机制的。无论是否有订阅者 collect 数据,都可以生产数据并且缓存起来。热流和订阅者是一对多的关系,多个订阅者可以共享同一个数据流。当一个订阅者停止监听时,数据流不会自动关闭(除非使用 WhileSubscribed 策略)。

Flow核心操作

操作

作用

flow{}

创建一个新的数据流。flow{} 是 suspend 函数,需要在协程中执行;

emit()

将一个新的值发送到数据流中;

collect{}

触发数据流消费,可以获取数据流中所有的发出值。Flow 是冷流,数据流会延迟到终端操作 collect 才执行,并且每次在 Flow 上重复调用 collect,都会重复执行 flow{} 去触发发送数据动作。collect 是 suspend 函数,需要在协程中执行。

catch{}

捕获数据流中发生的异常

flowOn()

更改上流数据操作的协程上下文 CoroutineContext,对下流操作没有影响。如果有多个 flowOn 运算符,每个 flowOn 只会更改当前位置的上游数据流;

onStart

状态回调,在数据开始发送之前触发,在数据生产线程回调;

onCompletion

状态回调,在数据发送结束之后触发,在数据生产线程回调;

onEmpty

状态回调,在数据流为空时触发(在数据发送结束但事实上没有发送任何数据时),在数据生产线程回调;

此外,Flow还有类似Rxjava一样的强大的操作符:终端操作符collectLatest、map、filter、drop、dropWhile、take、takeWhile,回调操作符onStart,onEach,onCompletion等等。

适用场景:

如果数据流比较复杂,需要做线程切换,又或者要变换数据,就用Flow。在什么地方使用Flow比较合适,或者说比较容易上手呢?那就是Repository层。因为通常我们需要在Repository层获取网络数据或者获取本地、内存的数据,有时候不同数据源的数据是需要进行结合或者变换的,所以这里用到Flow的可能性是比较大的。Repository对数据进行处理后,ViewModel拿到的其实就是一个完整可用的数据结构了,ViewModel就可以简单地用LiveData完成与UI层的数据传递。

举例1:将网络请求封装成flow

// 网络请求服务
interface ApiService {
    /** * https://www.wanandroid.com/banner/json */
 @GET("banner/json")
 suspend fun getBanners(): Response<List<Banners>>
}


// Repository层封装成Flow
class BannerRepository{
    private val apiService: ApiService by lazy { RetrofitClient.create() }

    suspend fun <T> getBanners(): Flow<RequestResult<Response<T>>> {
        return flow {
            val response: Response<T> = apiService.getBanners()
            if (response.isSuccess()) {
                emit(RequestResult.Success(response))
            } else {
                emit(RequestResult.Error(response.getCode(), response.getMessage()))
            }
        }.flowOn(Dispatchers.IO)
        .catch { throwable: Throwable ->
            emit(RequestResult.Error(-1, throwable.message))
        }
    }
}

// ViewModel层获取数据
class TestViewModel : ViewModel() {
    val pageState: MutableLiveData<RequestResult> by lazy {
        MutableLiveData<String>()
    }
    val _PageState: LiveData<RequestResult> = pageState

    val bannerRepository: BannerRepository by lazy { BannerRepository() }
    fun requestBanners(){
        viewModelScope.launch{
            bannerRepository.getBanners()
            .collect {
                 pageState.setValue(it)
        }
    }
}


// View层监听数据变化
class TestActivity : AppCompatActivity(){
    ...
    
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
        lifecycleScope.launchWhenCreated {
            repeatOnLifecycle(Lifecycle.State.CREATED){
                viewModel._PageState.observe(this){ pageState:RequestResult -> 
                    when (it) { 
                        is RequestResult.Success -> { 
                            fRequestResult.append("Result: apiService.getBanners()").append("\n").append(gson.toJson(it.data))
                             } 
                        is RequestResult.Error -> { 
                            fRequestResult.append("ERROR: ").append("\n").append(it.errorCode) .append(":").append(it.errorMsg)
                             } 
                        } 
                    tvResult.text = fRequestResult
                }
            }
        }
    }
}

举例2:异步批量压缩图片,并回调结果

fun zipImages(paths: List< String>) : Flow<Result<File>> {
    return paths.map{ path->
        flow <Result<File>>{
            emit(Result.success(File("")))
        }.catch{ exception ->
            emit(Result.failure(exception))
        }
    }.merge().flowOn(Dispatchers.IO)
} 

//监听
lifecycleScope.launch{
    zipImages(mutableListOf("path1","path2")).collect{ result->
        if(result.isSuccess){

        }else {

        }
        ...
    }
}

4.SharedFlow

前面说过,冷流和订阅者只能是一对一的关系,当我们要实现一个流多个订阅者的场景时,就需要使用热流了。

StateFlow 是一个状态容器式可观察数据流,可以向其收集器发出当前状态更新和新状态更新。可以通过其 value 属性读取当前状态值,如需更新状态并将其发送到数据流,那么就需要使用MutableStateFlow。

特点:

  1. 是热数据流 ,即使没有接收者,也会发射数据
  2. SharedFlow 是 StateFlow 的可配置性极高的泛化数据流。
  3. 可以有多个接收器,一个数据可以被多个接收

构造函数:

public fun <T> MutableSharedFlow(
    replay: Int = 0,
    extraBufferCapacity: Int = 0,
    onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow<T> {
    require(replay >= 0) { "replay cannot be negative, but was $replay" }
    require(extraBufferCapacity >= 0) { "extraBufferCapacity cannot be negative, but was $extraBufferCapacity" }
    require(replay > 0 || extraBufferCapacity > 0 || onBufferOverflow == BufferOverflow.SUSPEND) {
        "replay or extraBufferCapacity must be positive with non-default onBufferOverflow strategy $onBufferOverflow"
    }
    val bufferCapacity0 = replay + extraBufferCapacity
    val bufferCapacity = if (bufferCapacity0 < 0) Int.MAX_VALUE else bufferCapacity0 // coerce to MAX_VALUE on overflow
    return SharedFlowImpl(replay, bufferCapacity, onBufferOverflow)
}

参数分析:

  • replay 代表重放的数据个数
    1. replay 为0 代表不重放,也就是没有粘性(默认)
    2. replay 为1 代表重放最新的一个数据,后来的接收器能接受1个最新数据。
    3. replay 为2 代表重放最新的两个数据,后来的接收器能接受2个最新数据。
  • extraBufferCapacity 额外的数据缓存池,Flow 存在发送过快,消费太慢的情况,这种情况下,就需要使用缓存池,把未消费的数据存下来。

缓冲池容量 = replay + extraBufferCapacity

如果总量小于 0 ,则容量为 Int.MAX_VALUE

  • onBufferOverflow   如果指定了有限的缓存容量,那么超过容量以后怎么办?
    1. BufferOverflow.SUSPEND : 超过就挂起(默认)
    2. BufferOverflow.DROP_OLDEST : 丢弃最老的数据
    3. BufferOverflow.DROP_LATEST : 丢弃最新的数据

适用场景:

  • 发生订阅时,需要将过去已经更新的 n 个值,同步给新的订阅者。
  • 配置缓存策略。
  • 有多个订阅者。

举例:用于事件的传递,类似EventBus的消息总线

  • 用SharedFlow实现类似EventBus的事件总线组件
object SharedFlowEvents :
    CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {

    /**
     * 非粘性事件 存放容器
     */
    val _events: ConcurrentHashMap<Any, MutableSharedFlow<Any>> by lazy {
        ConcurrentHashMap()
    }

    /**
     * 粘性事件 存放容器
     */
    val _stickyEvents: ConcurrentHashMap<Any, MutableSharedFlow<Any>> by lazy {
        ConcurrentHashMap()
    }

    /**
     * 发送 非粘性 事件
     */
    inline fun <reified T> post(event: T) {
        val clazz = T::class.java
        _events.getOrElse(clazz) {
            MutableSharedFlow(0, 1, BufferOverflow.DROP_OLDEST)
        }.tryEmit(event as Any)
    }

    /**
     * 发送 粘性 事件
     */
    inline fun <reified T> postSticky(event: T) {
        val clazz = T::class.java
        // 粘性事件需要先初始化 SharedFlow 进行存储
        _initStickyEvent(clazz)
        _stickyEvents.getOrElse(clazz) {
            MutableSharedFlow(1, 1, BufferOverflow.DROP_OLDEST)
        }.tryEmit(event as Any)
    }

    /**
     * 监听 非粘性事件
     */
    inline fun <reified T> onEvent(
        event: Class<T>,
        owner: LifecycleOwner? = null,
        dispatcher: CoroutineDispatcher = Dispatchers.Main,
        crossinline action: suspend (T) -> Unit,
    ) {
        _initEvent(event)
        launch(dispatcher) {
            _events[event]?.collect {
                if (it is T) {
                    action.invoke(it)
                }
            }
        }.apply {
            owner?.let { bindLifecycle(it.lifecycle) }
        }
    }

    /**
     * 监听 粘性事件
     */
    inline fun <reified T> onStickyEvent(
        event: Class<T>,
        owner: LifecycleOwner? = null,
        dispatcher: CoroutineDispatcher = Dispatchers.Main,
        crossinline action: suspend (T) -> Unit,
    ) {
        _initStickyEvent(event)
        launch(dispatcher) {
            _stickyEvents[event]?.collect {
                if (it is T) {
                    action.invoke(it)
                }
            }
        }.apply {
            owner?.let { bindLifecycle(it.lifecycle) }
        }
    }

    /**
     * 初始化 非粘性事件 缓存容器
     */
    fun <T> _initEvent(event: Class<T>) {
        if (!_events.containsKey(event)) {
            _events[event] = MutableSharedFlow(0, 1, BufferOverflow.DROP_OLDEST)
        }
    }

    /**
     * 初始化 粘性事件 缓存容器
     */
    fun <T> _initStickyEvent(event: Class<T>) {
        if (!_stickyEvents.containsKey(event)) {
            _stickyEvents[event] = MutableSharedFlow(1, 1, BufferOverflow.DROP_OLDEST)
        }
    }
}
  • 拓展方法:
// 监听非粘性事件
inline fun <reified T> LifecycleOwner.onEvent(
    event: Class<T>,
    dispatcher: CoroutineDispatcher = Dispatchers.Main,
    crossinline action: suspend (T) -> Unit,
) = SharedFlowEvents.onEvent(event, this, dispatcher, action)

// 监听粘性事件
inline fun <reified T> LifecycleOwner.onStickyEvent(
    event: Class<T>,
    dispatcher: CoroutineDispatcher = Dispatchers.Main,
    crossinline action: suspend (T) -> Unit,
) = SharedFlowEvents.onStickyEvent(event, this, dispatcher, action)

// 添加 post 的顶层函数,方便直接调用
inline fun <reified T> postEvent(event: T) = SharedFlowEvents.post(event)

// 添加 postSticky 的顶层函数,方便直接调用
inline fun <reified T> postStickyEvent(event: T) = SharedFlowEvents.postSticky(event)
  • ViewModel发出事件
// 批量删除
private suspend fun deleteBatch(removeList: List<T>)  = viewModelScope.launch{
    // 异步执行批量删除的操作
    viewModelScope.async {
        realDeleteConfirm(removeList)
    }.await()
    
    // 删除完成发出事件,通知页面弹出吐司
    SharedFlowEvents.post(DownloadPageEvent.ShowToast("删除完成"))
}
  • View层监听事件
override fun onCreate(savedInstanceState: Bundle?) {
    // 吐司提示
    onEvent(DownloadPageEvent.ShowToast::class.java){
        ToastUtil.showAutoToast(activity,it.msg)
    }
}

5.StateFlow

StateFlow 就是 SharedFlow的一种特殊类型,特点有三:

  1. 它的replay容量为 1;即可缓存最近的一次粘性事件,如果想避免粘性事件问题,使用SharedFlow,replay默认值0。
  2. 初始化时必须给它设置一个初始值
  3. 每次发送数据都会与上次缓存的数据作比较,只有不一样才会发送。它还可直接访问它自己的value参数获取当前结果值,在使用上与LiveData相似。

与LiveData的不同点

  1. StateFlow必须在构建的时候传入初始值,LiveData不需要;
  2. StateFlow默认是防抖的,即相同值不更新,LiveData默认不防抖;
  3. StateFlow默认没有和生命周期绑定

适用场景:

StateFlow有初始值,且默认实现了防抖,因此通常用它来代替LiveData,在ViewModewl中通知Activity获取数据刷新UI。

举例:在ViewModewl中通知Activity刷新UI

  • 在ViewModel中定义MutableStateFlow对象管理页面状态
abstract class AbsDownloadTabViewModel<T> : ViewModel() {
    // 页面状态
    protected var pageState = MutableStateFlow<PageState<List<T>>>(PageState.Loading)
    val _pageState : StateFlow<PageState<List<T>>> = pageState
    
    
    public fun loadData() {
        cancelLoadData()
        viewModelScope.launch {
            // 通知View层显示加载页
            pageState.emit(PageState.Loading)
            val result = withContext(Dispatchers.IO) {
                // 异步获取数据
                realLoadData()
            }.transform { datas ->
                if (datas.isNullOrEmpty()) {、
                    // 数据为空
                    PageState.Empty
                } else {
                    // 数据加载成功
                    PageState.Success(datas)
                }
            }
            // 通知View层刷新UI
            pageState.emit(result)
        }
    }
}
  • View层监听页面状态改变
class DownloadVideoFragment:BaseVisibilityFragment<DownloadFragmentCommonBinding>() {
    
    override fun onCreateView(inflater: LayoutInflater,container: ViewGroup?,savedInstanceState: Bundle?,): View? {
        lifecycleScope.launchWhenCreated {
        repeatOnLifecycle(Lifecycle.State.CREATED){
            viewModel._pageState.collect{
                   when(it){
                       is PageState.Success -> {
                           showContentView()
                       }
                       is PageState.Loading -> {
                       showLoadingView()
                       }
                       is PageState.Error -> {
                           showErrorView()
                       }
                       else -> {
                           // PageState.Empty
                           showEmptyView()
                           }
                       }
                }
            }
        }
    }
}

哪些情况下不能StateFlow不能平替LiveData?

例如:登录页面输入密码,通过StateFlow通知UI提示用户”密码正确“或者”密码错误“,此时如果用户多次输入错误密码,由于StateFlow默认防抖,导致UI层不会继续给用户反馈。

6.Channel

Channel出现在Flow之前,最初被设计用来进行协程间通信。但在Flow出现之后,Channel就逐渐退居幕后了。在Flow的源码中还是可以看到Channel的身影,其本身的职责越发单一,仅作为协程间通信的并发安全的缓冲队列而存在。

Channel的设计和Java中的BlockQueue相似。区别在于,Channel并不阻塞线程,而是提供了挂起函数send和 receive。

当需要的时候, 多个协程可以向同一个channel发送数据, 一个channel的数据也可以被多个协程接收.

当多个协程从同一个channel接收数据的时候, 每个元素仅被其中一个consumer消费一次. 处理元素会自动将其从channel里删除.

全局构造函数:

public fun <E> Channel(
    capacity: Int = RENDEZVOUS,
    onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
    onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E>

参数介绍:

  • capacity   channel的容量,kotlin为我们定义了几种常量:
    1. RENDEZVOUS 只有消费端调用时,才会发送数据,否则挂起发送操作。这也是channel的默认类型
    2. CONFLATED capacity是这个参数时,onBufferOverflow参数只能为BufferOverflow.SUSPEND。缓冲区满时,永远用最新元素替代,之前的元素将被废弃。可以理解为是onBufferOverflow等于DROP_OLDEST的快捷创建版。
    3. UNLIMITED 无限制容量,缓冲队列满后,会直接扩容,直到OOM。
    4. BUFFERED 默认创建64位容量的缓冲队列,当缓存队列满后,会挂起发送数据,直到队列有空余。我们也可以直接传递一个数值,来创建指定缓冲大小的channel。    
  • onBufferOverflow  指定当缓冲区满的时候的背压策略。有3种选择:
    1. SUSPEND 挂起
    2. DROP_OLDEST 丢弃最旧的元素
    3. DROP_LATEST 丢弃最新的元素
  • onUndeliveredElement   指定数据发送但是接收者没有收到的时候的回调。

适用场景:

在MVI架构中,相较于直接通过函数接收意图,利用协程Channle发送和接收意图来的更合理,当然,这种方式也存在一些缺陷。

优点:

  1. 异步处理: 可以在协程中异步地发送和接收意图,不会阻塞 UI 线程,提高了应用的响应性能。
  2. 多个生产者-单个消费者: 可以支持多个 View 层发送意图到同一个 Channel 中,由 ViewModel 单独消费,这样可以更好地管理和控制意图的处理顺序。
  3. 支持超时和缓冲: 可以设置 Channel 的缓冲区大小,或者使用 Channel 的相关函数来设置超时等特性,提高了意图传递的灵活性和可控性。

缺点:

  1. 复杂性增加: 使用 Channel 发送意图的方式相对于简单地调用函数来说,涉及到更多的协程概念和代码结构,对开发者的学习成本较高。
  2. 需要手动管理订阅和取消订阅: 需要在 ViewModel 中手动创建和管理 Channel,以及在合适的时机订阅和取消订阅,容易出现忘记取消订阅而导致内存泄漏的问题。

举例:

abstract class AbsDownloadTabViewModel<T> : ViewModel() {
    // 定义意图通道
    private val intentChannel = Channel<DownloadCommenIntent<T>>(Channel.CONFLATED)

   
    init {
        viewModelScope.launch {
            // 接收意图
            intentChannel.consumeAsFlow().collect{
                onAction(it)
            }
        }
    }

    // 外部调用,发送意图
    fun sendAction(action: DownloadCommenIntent<T>) = viewModelScope.launch {
        intentChannel.send(action)
    }

    // 及时关闭,避免内存泄漏
    override fun onCleared() {
        super.onCleared()
        intentChannel.close()
    }
    
    // 处理意图
    private fun onAction(action: DownloadCommenIntent<T>){
        when(action){
            is DownloadCommenIntent.LoadData -> {
                loadData()
            }
            is DownloadCommenIntent.CancelLoadData -> {
                cancelLoadData()
            }
            is DownloadCommenIntent.SelectAllOrNone -> {
                selectAllOrNone()
            }
            is DownloadCommenIntent.SelectNone -> {
                selectNone()
            }
            is DownloadCommenIntent.ChangeListPlayState -> {
                changeListPlayState(action.state,action.audioDetailBean)
            }
            is DownloadCommenIntent.DeleteBatch<T> -> {
                deleteJob = deleteBatch(action.removeList)
            }
            else ->{
                onOtherAction(action)
            }
        }
    }
}

总结

通过以上示例和场景说明,可以更清晰地了解每种数据流的特点及其在实际应用中的使用场景。总的来说,每种数据流都有它的适用性和局限性,开发者应该充分地考虑实际场景,来选择合适的数据流实现数据的观察和处理。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值