Paging3-分页数据加载库(入门)


前言

  • 官方分页工具, 确实香. 但数据源不开放, 无法随意增删改操作; 只能借助 Room; 但列表数据不一定都要用 Room吧;
  • 如果偏查询的分页数据用 Paging3 ; 其他一概用 老Adapter; 这倒也算个方案. [苦笑]

官方 Pagings 优势:

  • 分页数据的内存中缓存。该功能可确保您的应用在处理分页数据时高效利用系统资源。
  • 内置的请求重复信息删除功能,可确保您的应用高效利用网络带宽和系统资源。
  • 可配置的 RecyclerView 适配器,会在用户滚动到已加载数据的末尾时自动请求数据。
  • 对 Kotlin 协程和 Flow 以及 LiveData 和 RxJava 的一流支持。
  • 内置对错误处理功能的支持,包括刷新和重试功能。

推荐文章


导包:

dependencies {
  val paging_version = "3.0.0"

  //唯一必导包
  implementation("androidx.paging:paging-runtime:$paging_version")

  // 测试用
  testImplementation("androidx.paging:paging-common:$paging_version")

  // optional - RxJava2 support
  implementation("androidx.paging:paging-rxjava2:$paging_version")

  // optional - RxJava3 support
  implementation("androidx.paging:paging-rxjava3:$paging_version")

  // 适配 Guava 库  - 高效java扩展库
  implementation("androidx.paging:paging-guava:$paging_version")

  // 适配 Jetpack Compose  - 代码构建View; 干掉 layout
  implementation("androidx.paging:paging-compose:1.0.0-alpha09")
}

提示:以下是本篇文章正文内容,下面案例可供参考

一、简单使用

不管怎样, 我们先实现, 把代码敲起来! [笑脸]

1.数据源 PagingSource

  • 它有两个泛型参数, 1. 页码key, 没有特殊需求的话一般就是 Int 类型; 2.集合实体类型
  • 重写两个方法: 1.load() 加载数据的方法; 2.getRefreshKey 初始加载的页码; 暂且返回 1 或 null
class DynamicDataSource: PagingSource<Int, DynamicTwo>() {

    //模拟最大页码
    private var maxPage = 2

    //模拟数据
    private fun fetchItems(startPosition: Int, pageSize: Int): MutableList<DynamicTwo> {
        Log.d("ppppppppppppppppppppp", "startPosition=${startPosition};;;pageSize=${pageSize}")
        val list: MutableList<DynamicTwo> = ArrayList()
        for (i in startPosition until startPosition + pageSize) {
            val concert = DynamicTwo()
            concert.title = "我是标题${i}"
            concert.newsInfo = "我是内容${i}"
            concert.nickName = "小王${i}"
            list.add(concert)
        }
        return list
    }

    override fun getRefreshKey(state: PagingState<Int, DynamicTwo>): Int? = null

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, DynamicTwo> {
        val nextPageNumber = params.key ?: 1
        val size = params.loadSize
        Log.d("ppppppppppppppppppppp", "nextPageNumber=${nextPageNumber};;;size=${size}")
        val response = fetchItems((nextPageNumber-1) * size, size)

        return LoadResult.Page(
            data = response,
            prevKey = null, // Only paging forward.  只向后加载就给 null
            //nextKey 下一页页码;  尾页给 null;  否则当前页码加1
            nextKey = if(nextPageNumber >= maxPage) null else (nextPageNumber + 1)
        )
    }
}

可以看出挂起喊出 suspend fun load() 的作用就是加载数据.
这里 fetchItems() 我们直接模拟数据

2.ViewModel

class DynamicPagingModel(application: Application) : AndroidViewModel(application) {
    val flow = Pager(
        //配置
        PagingConfig(pageSize = 10, prefetchDistance = 2,initialLoadSize = 10)
    ) {
        //我们自定义的数据源
        DynamicDataSource()
    }.flow
        .cachedIn(viewModelScope)
}

这代码是不是非常简单!

3.开始使用

  • 先初始化 Adapter 及 RecycleView
  • mViewModel?.flow?.collectLatest 绑定监听, 然后通过 submitData() 刷新数据;
mAdapter = SimplePagingAdapter(R.layout.item_dynamic_img_two, null)

mDataBind.rvRecycle.let {
    it.layoutManager = LinearLayoutManager(mActivity)
    it.adapter = mAdapter
}

//Activity 用 lifecycleScope
//Fragments 用 viewLifecycleOwner.lifecycleScope
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
    mViewModel?.flow?.collectLatest {
        mAdapter.submitData(it)
    }
}

4.Adapter

Adapter 必须继承 androidx.paging.PagingDataAdapter
DiffCallback() 或 handler NewViewHolder 不了解的可以看我的 ListAdapter 封装系列

open class SimplePagingAdapter(
    private val layout: Int,
    protected val handler: BaseHandler? = null
) :
    PagingDataAdapter<DynamicTwo, RecyclerView.ViewHolder>(DiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return NewViewHolder(
            DataBindingUtil.inflate(
                LayoutInflater.from(parent.context), layout, parent, false
            ), handler
        )
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if(holder is NewViewHolder){
            holder.bind(getItem(position))
        }
    }
}

简单的分页加载完成了. 是不是很简单;

二、参数解读

1.LoadResult

它是一个密封类; 它表示加载操作的结果;

1.1 LoadResult.Error
它表示加载失败; 需提供 Throwable 对象.

用途:

  • 异常时返回, 如 HTTP, IO, 数据解析异常;
  • 服务器错误码、错误msg 响应,
  • 没有更多数据 (官方提倡用footer的方式显示)

1.2 LoadResult.Page
它表示加载成功;

参数:

参数名意义及用法
data数据实体集合
prevKey前一页页码 key, 如果列表是向后加载, 则可以给 null
nextKey后一页页码 key, 一般给当前页码加1

2.PagingConfig

在ViewModel中出现. 表示 分页配置

参数:

参数名意义及用法
pageSize见名知意, 每页容量
prefetchDistancePaging能无限滚动就是因为它. 当RecycleView 滑动到底部时, 会自动加载下一页.
如果能提前预加载, 可以省去等待加载的步骤. prefetchDistance 就是触发加载的距离底部距离.
默认 = pageSize; 设置 0 时将不会加载更多
enablePlaceholders允许使用占位符. 想了解的点这里
initialLoadSize初始加载数量, 默认 = pageSize * 3
maxSize似乎意义没有那么简单. 总之 它不能 < pageSize + prefetchDistance * 2
jumpThreshold某阈值! 好吧我摊牌了, 我不知道. [奸笑]

3.监听加载状态

我们的页面有下拉刷新UI, 需要监听数据是否加载完毕.
我们需要监听 Adapter 的数据加载状态

lifecycleScope.launch {
    mAdapter.loadStateFlow.collectLatest { loadStates ->
        when(loadStates.refresh){
            is LoadState.Loading -> {
                Log.d("pppppppppppppp", "加载中")
            }
            is LoadState.Error -> {
                Log.d("pppppppppppppp", "加载失败")
            }
            is LoadState.NotLoading -> {
                Log.d("pppppppppppppp", "完事了")
            }
            else -> {
                Log.d("pppppppppppppp", "这是啥啊")
            }
        }
    }

    //或者:
    mAdapter.addLoadStateListener { ... }
}

LoadState: 表示加载状态的密封类;

类名
LoadState.NotLoading表示 加载完毕, 并且界面也已相应更新
LoadState.Error表示加载失败, 里面存有异常信息
LoadState.Loading表示列表正在加载…

三、状态适配器 LoadStateAdapter

用于直接在显示的分页数据列表中呈现加载状态。
例如: 尾部显示 正在加载, 加载失败, 没有更多等;

1.自定义Adapter

需 继承 LoadStateAdapter

class MyLoadStateAdapter(
    /**
     * 当下一页加载失败时, 继续尝试加载下一页; 
     */
    private val retry: () -> Unit
) : LoadStateAdapter<LoadStateViewHolder>() {

    override fun onCreateViewHolder(
        parent: ViewGroup,
        loadState: LoadState
    ) = LoadStateViewHolder(parent, retry)

    override fun onBindViewHolder(
        holder: LoadStateViewHolder,
        loadState: LoadState
    ) = holder.bind(loadState)
}

retry: 加载失败时, footer 显示错误信息. 它有一个重试按钮, 点击时执行.

2.自定义ViewHolder

我们给他赋予功能:

  • 加载中 显示 Loading;
  • 加载失败 显示 错误信息. 包括 http, IO 异常, 后台给的错误 msg 等; 并可以点击重试
  • 没有更多

代码如下, 可以按需调整

class LoadStateViewHolder (
    parent: ViewGroup,
    retry: () -> Unit
) : RecyclerView.ViewHolder(
    LayoutInflater.from(parent.context)
        .inflate(R.layout.view_loading_more, parent, false)
) {
    private val binding = ViewLoadingMoreBinding.bind(itemView)

    init {
        //当点击重试按钮时, 调用 PagingDataAdapter 的 retry() 重新尝试加载
        binding.btnLoadingRetry.setOnClickListener {
            retry()
        }
    }

    fun bind(loadState: LoadState) {
        // 当加载失败时.
        if(loadState is LoadState.Error){
            // 将没有更多封装成 NoMoreException;  此时显示没有更多 View
            if(loadState.error is NoMoreException){
                hideNoMoreUi(false) //显示 没有更多 View
                hideErrUi(true)     //隐藏 失败 View
            }else{
                hideNoMoreUi(true)
                hideErrUi(false, loadState.error.message)   //显示失败 View时, 填充错误 msg
            }
        }else{
            hideNoMoreUi(true)
            hideErrUi(true)
        }

        //加载中..
        binding.pbLoadingBar.visibility = if(loadState is LoadState.Loading){
            View.VISIBLE
        }else{
            View.GONE
        }
    }

    /**
     * 隐藏没有更多View;
     */
    private fun hideNoMoreUi(hide: Boolean){
        if(hide){
            binding.tvLoadingHint.visibility = View.GONE
        }else{
            binding.tvLoadingHint.visibility = View.VISIBLE
        }
    }

    /**
     * 隐藏 加载失败View;
     */
    private fun hideErrUi(hide: Boolean, msg: String? = null){
        if(hide){
            binding.tvLoadingError.visibility = View.GONE
            binding.btnLoadingRetry.visibility = View.GONE
        }else{
            binding.tvLoadingError.text = msg
            binding.tvLoadingError.visibility = View.VISIBLE
            binding.btnLoadingRetry.visibility = View.VISIBLE
        }
    }
}

顺便补一下 NoMoreException:
它的作用是, 超出最后一页时, 用 LoadResult.Error 响应. ViewHolder对失败情况进行判断, 显示没有更多UI

class NoMoreException: RuntimeException()

3.状态布局

<layout>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:paddingHorizontal="16dp"
    android:layout_width="match_parent"
    android:layout_height="54dp">
    <View
        android:layout_width="match_parent"
        android:layout_height="1px"
        android:background="#e5e5e5"
        app:layout_constraintTop_toTopOf="parent"/>
    <TextView
        android:id="@+id/tv_loading_hint"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="14sp"
        android:textColor="#798080"
        android:text="已经到底了"
        android:visibility="gone"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>
    <ProgressBar
        android:id="@+id/pb_loading_bar"
        android:layout_width="32dp"
        android:layout_height="32dp"
        android:visibility="gone"
        android:indeterminateTint="#7671F8"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>
    <TextView
        android:id="@+id/tv_loading_error"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:textColor="@color/shape_red"
        android:text="错误信息"
        android:layout_marginEnd="8dp"
        android:maxLines="2"
        android:ellipsize="end"
        android:visibility="gone"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@id/btn_loading_retry"
        app:layout_constraintStart_toStartOf="parent"/>
    <Button
        android:id="@+id/btn_loading_retry"
        android:layout_width="60dp"
        android:layout_height="38dp"
        android:textColor="@color/white"
        android:text="重试"
        android:visibility="gone"
        android:background="@drawable/shape_blue_7671f8_r8"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

4.最终 PagingSource

需要根据情况 返回不同的 LoadResult

class DynamicDataSource: PagingSource<Int, DynamicTwo>() {

    private var maxPage = 1

    override fun getRefreshKey(state: PagingState<Int, DynamicTwo>): Int? = null

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, DynamicTwo> {
        try {
            val nextPageNumber = params.key ?: 1

            //超过页码时,  返回没有更多状态 NoMoreException
            if(nextPageNumber > maxPage){
                return LoadResult.Error(NoMoreException())
            }

            //这是 Retrofit 网络请求
            val map = mapOf("page" to nextPageNumber, "pageSize" to params.loadSize)
            val param = ApiManager.INSTANCE.getJsonBody(map)
            val response = ApiManager.INSTANCE.mApi.getDynamicList(param)

            //后台 响应错误码时;  用 RuntimeException 返回错误信息
            if(response.code != 200){
                return LoadResult.Error(RuntimeException(response.msg))
            }

            //解析响应数据
            val jo = response.data
            val list = jo?.getAsJsonArray("newsList")?.toString()?.toBeanList<DynamicTwo>() ?: mutableListOf()
            maxPage = jo?.get("totalPage").toString().toInt()

            //返回正常数据
            return LoadResult.Page(
                data = list,
                prevKey = null, // Only paging forward. 只向后加载就给null
                // nextKey 下一页页码;  尾页给 null;  否则当前页码加1
                nextKey = nextPageNumber + 1
            )
        } catch (e: IOException) {
            // IOException for network failures.
            return LoadResult.Error(e)
        } catch (e: HttpException) {
            // HttpException for any non-2xx HTTP status codes.
            return LoadResult.Error(e)
        } catch (e: Exception) {
            // IOException for network failures.
            return LoadResult.Error(e)
        }
    }
}

代码中 请求参数只给了 page 和 pageSize; 其他参数怎么给?

  • DynamicDataSource 的构造方法传入;
  • 动态参数怎么办? 写回调, 从ViewModel 中组装请求数据
  • 麻烦怎么办? 创建 BaseDataSource. 将相似代码封装. 请求参数通过高阶函数从ViewModel组装;

5.前台使用:

  • 首先正常初始化 Adapter, RecycleView, 并调用 mViewModel?.flow?.collectLatest
  • 其次 RecycleView 的 adaper 不要给 主数据Adapter; 而是给 withLoadStateFooter() 返回的 ConcatAdapter
val stateAdapter = mAdapter.withLoadStateFooter(MyLoadStateAdapter(mAdapter::retry))
mDataBind.rvRecycle.let {
    it.layoutManager = LinearLayoutManager(mActivity)
    // ****  这里不要给 mAdapter(主数据 Adapter);  而是给 stateAdapter ***
    it.adapter = stateAdapter
}

withLoadStateFooter 的参数 就是我们自定义的 MyLoadStateAdapter; retry -> mAdapter.retry()

四、用LoadStateAdapter 改建头尾

博主改建失败了. 没兴趣的这部分可以跳过了!!

1.看一下 LoadStateAdapter 的源码;

  • 可以发现, 这是个单条目 Adapter.
  • 并且 只有当 LoadState.Loading, LoadState.Error 时才会出现; 当然也可以重写它
  • 当 列表状态变化时, 会设置 loadState 参数; 动态增删改 Item;
abstract class LoadStateAdapter<VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {

    var loadState: LoadState = LoadState.NotLoading(endOfPaginationReached = false)
        set(loadState) {
            if (field != loadState) {
                val oldItem = displayLoadStateAsItem(field)
                val newItem = displayLoadStateAsItem(loadState)

                if (oldItem && !newItem) {
                    notifyItemRemoved(0)
                } else if (newItem && !oldItem) {
                    notifyItemInserted(0)
                } else if (oldItem && newItem) {
                    notifyItemChanged(0)
                }
                field = loadState
            }
        }

    final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        return onCreateViewHolder(parent, loadState)
    }
    final override fun onBindViewHolder(holder: VH, position: Int) {
        onBindViewHolder(holder, loadState)
    }
    final override fun getItemViewType(position: Int): Int = getStateViewType(loadState)

  //条目数量, final 不可重写; 
    final override fun getItemCount(): Int = if (displayLoadStateAsItem(loadState)) 1 else 0
    
    abstract fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): VH
    abstract fun onBindViewHolder(holder: VH, loadState: LoadState)
    open fun getStateViewType(loadState: LoadState): Int = 0
  
  //只有当 Loading, Error 时, 才显示这个状态View
    open fun displayLoadStateAsItem(loadState: LoadState): Boolean {
        return loadState is LoadState.Loading || loadState is LoadState.Error
    }
}

2. 分析

如果我们把它改造成 Header footer 需要注意:

  • Header跟列表加载状态没有关系, 所以重写 displayLoadStateAsItem() 不管啥状态, 都返回true
  • loadState 不能重写, 所以 notifyItemChanged(0) 必被调用;
  • 不如暴力一点, 直接重写 notifyItemChanged() 让它什么都不做? 好吧 它也是 final, 不能重写
  • 既然组织不了, 那就调吧 [破涕为笑]; 咋办 尽量少执行无用代码呗, 那就 onBindViewHolder() 啥也不干;
  • 所以头尾由前端控制, Adapter 只需要把这个 固定View显示就 ok 了
  • 如果能阻止 notifyItemChanged(0) 那就最好了. 你们有没有办法呢. [666]

改造后 Adapter 如下

class EndViewAdapter(val v: View) : LoadStateAdapter<EndHolder>() {
    override fun onCreateViewHolder(
        parent: ViewGroup,
        loadState: LoadState
    ) = EndHolder(v)

    override fun onBindViewHolder(holder: EndHolder, loadState: LoadState){}
    //不管什么状态. 必定返回 true
    override fun displayLoadStateAsItem(loadState: LoadState) = true
}
class EndHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

好吧, 一运行, 崩了 [捂脸]; called attach on a child which is not detached

怎么办? 取消 RecycleView 的刷新闪烁动画:
(mDataBind.rvRecycle.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false;

整个 RecycleView 的条目刷新动画都没了, 这不是个事啊!
好吧我放弃了. 我们还是用 ConcatAdapter 组装头尾吧!

五、map 数据转换

有的时候, 我们需要对响应数据 进行预先处理;

例如: 根据条件,预先改变实体内容;

val flow: Flow<PagingData<DynamicTwo>> = Pager(
    PagingConfig(pageSize = 10, prefetchDistance = 2,initialLoadSize = 10)
) {
    DynamicDataSource()
}.flow
    .map {
        it.map { entity ->
            // 这里根据条件,  预先处理数据
            if(entity.isLike == 1){
                entity.nickName = "变变变, 我是百变小魔女"
            }else{
                entity.nickName = "呜哈哈哈"
            }
            entity
        }
    }
    .cachedIn(viewModelScope)

又例如: 组合实体; 根据条件产生不同实体;

val flow: Flow<PagingData<GroupEntity>> = Pager(
    PagingConfig(pageSize = 10, prefetchDistance = 2,initialLoadSize = 10)
) {
    DynamicDataSource()
}.flow
    .cachedIn(viewModelScope)
    .map {
        it.map { entity ->
            // 这里根据条件,  预先处理数据
            if(entity.isLike == 1){
                GroupEntity.DynamicTwoItem(entity)
            }else{
                GroupEntity.DynamicItem(DynamicEntity())
            }
        }
    }

sealed class GroupEntity{
    class DynamicTwoItem (val entity: DynamicTwo): GroupEntity()
    class DynamicItem (val entity: DynamicEntity): GroupEntity()
}

总结

好吧, 没有总结。

上一篇: ListAdapter封装, 告别Adapter代码 (三)- 头尾,多类型,嵌套,单多选
下一篇: Paging3-分页数据加载库(结合room)

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
以下是一个使用 Kotlin 和 Paging3+RecyclerView 实现数据分页的示例: 1. 首先,我们需要定义一个包含数据的列表和当前页码的类,使用 PagingData 类型: ``` data class Data( val id: Int, val name: String, val description: String ) ``` 2. 接下来,我们需要定义一个 PagingSource,它负责从数据源获取数据: ``` class DataPagingSource(private val api: DataApi) : PagingSource<Int, Data>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Data> { try { val page = params.key ?: 1 val pageSize = params.loadSize val response = api.getData(page, pageSize) val data = response.data val prevKey = if (page == 1) null else page - 1 val nextKey = if (data.isEmpty()) null else page + 1 return LoadResult.Page( data = data, prevKey = prevKey, nextKey = nextKey ) } catch (e: Exception) { return LoadResult.Error(e) } } override fun getRefreshKey(state: PagingState<Int, Data>): Int? { return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) } } } ``` 3. 然后,我们需要定义一个 PagingData 类型的数据流,并使用它创建一个 PagingDataAdapter: ``` class DataAdapter : PagingDataAdapter<Data, DataViewHolder>(DataDiffCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.item_data, parent, false) return DataViewHolder(view) } override fun onBindViewHolder(holder: DataViewHolder, position: Int) { val item = getItem(position) holder.bindData(item) } object DataDiffCallback : DiffUtil.ItemCallback<Data>() { override fun areItemsTheSame(oldItem: Data, newItem: Data): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: Data, newItem: Data): Boolean { return oldItem == newItem } } } ``` 4. 最后,在 Activity 或 Fragment 中使用 RecyclerView 和 DataAdapter: ``` class MainActivity : AppCompatActivity() { private lateinit var recyclerView: RecyclerView private lateinit var adapter: DataAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) recyclerView = findViewById(R.id.recycler_view) recyclerView.layoutManager = LinearLayoutManager(this) adapter = DataAdapter() recyclerView.adapter = adapter val api = DataApi() val pagingSourceFactory = { DataPagingSource(api) } val pagingConfig = PagingConfig(pageSize = 10) val dataFlow = Pager( config = pagingConfig, pagingSourceFactory = pagingSourceFactory ).flow dataFlow.cachedIn(lifecycleScope).collectLatest { pagingData -> adapter.submitData(pagingData) } } } ``` 这是一个简单的示例,你可以根据自己的需求进行修改和扩展。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值