Jetpack-Paging使用教程

Jetpack-Paging使用教程

一、Paging是什么?

Paging 是一个分页库,Paging 可以帮助我们优雅地渐进加载大型数据集合,同时也可以减少网络的使用和系统资源的消耗。

  • 在内存中缓存分页数据,确保您的 App 在使用分页数据时有效地使用系统资源。

  • 内置删除重复数据的请求,确保您的 App 有效地使用网络带宽和系统资源。

  • 可配置当用户滚动到加载数据的末尾时自动请求数据。

1.1. Paging版本

截止到目前2021-01-17 Paging库现在分为两个版本,其中Paging 2 版本为稳定版,Paging 3Alpha版。
Paging 2虽然是稳定版但是使用起来还是会有问题,如:

  • 分页失败就不会在进行分页了;

  • 不支持HeaderFooter

而在 Paging 3 中很多 API 的使用方式变了,也增加了很多功能,如:

  • 支持 Kotlin 协程和 Flow

  • 支持HeaderFooter

  • 增加请求数据时状态的回调

  • 内置的错误处理支持,包括刷新和重试等功能。

1. 2. 添加Paging库依赖

使用Paging 2,请在应用或模块的 build.gradle 文件中添加以下依赖

dependencies {
      def paging_version = "2.1.2"
      implementation "androidx.paging:paging-runtime:$paging_version" // For Kotlin use paging-runtime-ktx
      // alternatively - without Android dependencies for testing
      testImplementation "androidx.paging:paging-common:$paging_version" // For Kotlin use paging-common-ktx
      // optional - RxJava support
      implementation "androidx.paging:paging-rxjava2:$paging_version" // For Kotlin use paging-rxjava2-ktx
    }

如需使用 Paging 3,请将以下依赖项添加到 build.gradle 文件中:

dependencies {
  def paging_version = "3.0.0-alpha09"
  implementation "androidx.paging:paging-runtime:$paging_version"
  // alternatively - without Android dependencies for tests
  testImplementation "androidx.paging:paging-common:$paging_version"
  // optional - RxJava2 support
  implementation "androidx.paging:paging-rxjava2:$paging_version"
  // optional - Guava ListenableFuture support
  implementation "androidx.paging:paging-guava:$paging_version"
  // Jetpack Compose Integration
  implementation "androidx.paging:paging-compose:1.0.0-alpha02"
}

详情可前往官方Paing依赖声明

二、 Paging 2的使用

我们先看一下Paging 2数据流工作原理图:在这里插入图片描述

  • DataSource:数据源提供者,数据的改变会驱动列表的更新。

  • PageList:核心类,它从数据源取出数据,同时,它负责页面初始化数据+分页数据什么时候加载,以何种方式加载。

  • PagedListAdapter:是RecyclerView.Adapter的实现类,从PagedList过来的数据,通过DiffUtil差分异定向更新列表数据。

2.1. DataSource

顾名思义就是数据来源,作用就是提供加载所需的数据,数据源可以是客户端本地数据库也可以是服务器。需要通过DataSource.Factory工厂类创建。DataSource<Key, Value> Key对应数据加载的条件,Value就是数据源的实体类。Paging 2的设计者提供了三种不同类型的DataSource抽象类:

  • PositionalDataSource<T>:适用于数据容量固定,要通过特定的位置加载数据。比如从某个位置开始的 100 条数据;

  • ItemKeyedDataSource<Key, Value>:适用于目标数据的加载依赖特定item的信息, 即Key字段包含的是Item中的信息,比如需要根据第N项的信息加载第N+1项的数据,传参中需要传入第N项的ID时。之前钱包记账就有请求下一页内容时需要传入当前页最后一笔账单的信息。

  • PageKeyedDataSource<Key, Value>:和ItemKeyedDataSource类似,适用于以页信息加载数据的场景。比如在网络加载数据的时候,需要通过 setNextKey()setPreviousKey() 方法设置下一页和上一页的标识 Key

2.1.1. PositionalDataSource

需要重写loadInitialloadRange方法

class PositionDataSource: PositionalDataSource<Cheese>() {
    
    override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<Cheese>) {
        callback.onResult(
            data = loadData(params.requestedStartPosition, params.pageSize),
            position = 0,
            totalCount = 400
        )
    }
    override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<Cheese>) {
        callback.onResult(
            data = loadData(params.startPosition, params.loadSize)
        )
    }
    
    fun loadData(itemKey: Int, pageSize: Int): List<Cheese> {
        val data = ArrayList<Cheese>()
        if (itemKey >= CHEESE_DATA.size - 1) {
            return data
        }
        var end = itemKey + pageSize
        if (end > CHEESE_DATA.size) {
            end = CHEESE_DATA.size
        }
        for (i in itemKey until end) {
            val cheese = Cheese(i, CHEESE_DATA[i])
            data.add(cheese)
        }
        return data
    }
}
  • loadInitial:加载初始列表数据。在用DataSource构建PageList的时候才会调用一次。

  • LoadInitialParams:初始加载的参数,包括请求的起始位置,加载大小和页面大小。与PageList.Config设置有关

  • LoadInitialCallback:接收初始负载数据(包括位置和数据集总大小)的回调。

  • loadRange:加载其他页面列表数据。类似LoadMore,在每次RecyclerView滑动到底部没有数据的时候就会调用此方法进行数据的加载。

  • LoadRangeParams:初始加载的参数,包括请求的起始位置,加载大小和页面大小

  • LoadRangeCallback:接收已加载数据的回调。

2.1.2. ItemKeyedDataSource

需要重写getKeyloadInitialloadBeforeloadAfter四个方法

class TextItemKeyDataSource() : ItemKeyedDataSource<Int, Cheese>() {
    
    override fun getKey(item: Cheese): Int {
        return item.id
    }
    override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Cheese>) {
        callback.onResult(loadData(params.key, params.requestedLoadSize))
    }
    override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Cheese>) {
    }
    override fun loadInitial(
        params: LoadInitialParams<Int>,
        callback: LoadInitialCallback<Cheese>
    ) {
        val data = loadData(0, params.requestedLoadSize)
        callback.onResult(data)
    }
}
  • getKey:返回下一个loadAfter调用所需要用到的key

  • loadInitial:加载初始列表数据。

  • loadAfter:在每次RecyclerView滑动到底部PagedList中没有数据的时候就会调用此方法进行数据的加载。

  • loadBefore:在每次RecyclerView滑动到顶部PagedList没有数据的时候就会调用此方法进行数据的加载。

2.1.3. PageKeyedDataSource

需要重写loadInitialloadBeforeloadAfter三个方法

class TextPageKeyDataSource : PageKeyedDataSource<Int, Cheese>() {
    private var pageKey: Int = 0
    private var pageSize: Int = 10
    override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, Cheese>) {
        callback.onResult(loadData(params.key, pageSize), params.key + 1)
    }
    override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, Cheese>) {
    }
    override fun loadInitial(
        params: LoadInitialParams<Int>,
        callback: LoadInitialCallback<Int, Cheese>
    ) {
        callback.onResult(
            data = loadData(pageKey, pageSize),
            previousPageKey = null,
            nextPageKey = pageKey + 1
        )
    }
    fun loadData(pageNumber: Int, pageSize: Int): List<Cheese> {
        val data = ArrayList<Cheese>()
        var startPosition = pageNumber * pageSize
        if (startPosition > CHEESE_DATA.size) {
            return data
        }
        var endPosition = (pageNumber + 1) * pageSize
        if (endPosition > CHEESE_DATA.size) {
            endPosition = CHEESE_DATA.size
        }
        for (i in startPosition until endPosition) {
            val cheese = Cheese(i, CHEESE_DATA[i])
            data.add(cheese)
        }
        return data
    }
}
  • loadInitial:加载初始列表数据。

  • loadAfter:在每次RecyclerView滑动到底部PagedList没有数据的时候就会调用此方法进行数据的加载。

  • loadBefore:在每次RecyclerView滑动到顶部PagedList没有数据的时候就会调用此方法进行数据的加载。

2.2. DataSource.Factory

这个接口的实现类主要是用来获取我们上面实现的DataSource

class MyDataSourceFactory() : DataSource.Factory<Int, Cheese>() {
    override fun create(): DataSource<Int, Cheese> {
        return TextPositionDataSource()
    }
}
2.3. PageList

PagedList<T>是一种List,用来存储加载的数据。其中的所有数据均从DataSource中加载。

2.4. PageList.Config

配置PagedList如何从DataSource中加载内容。不同的DataSource设置PageList.Config的效果也有差异

val pagedListConfig = PagedList.Config.Builder()
            .setPageSize(10)
            .setPrefetchDistance(5)
            .setEnablePlaceholders(true)
            .setInitialLoadSizeHint(20)
            .setMaxSize(100)
            .build()
  • pageSize : 每一页的数据量。

  • prefetchDistance : 预取数据的距离,也就是距离最后一个item多远时开始加载下一页数据,默认是一页的数据量pageSize

  • enablePlaceholders :定义PagedList是否可以显示空的占位符(如果DataSource提供的话)

  • initialLoadSize :初始化加载的数量,默认为pagesize * 3

  • maxsize:保留在内存中的最大项目数,防止由于预取而导致连续读取和丢弃负载。比如:在PositionalDataSourcemaxsize=100我们加载超过100个,倒回去第一页,会重新执行loadRange()

**注意:**默认情况下PagingConfig.maxSize是无界的,因此永远不会删除页面。如果确实要删除页面,请确保保持maxSize足够高的数量,以免用户更改滚动方向时不会导致太多网络请求。最小值为pageSize + prefetchDistance * 2

2.5. LivePagedListBuilder

LivePagedListBuilder是用于获取PagedList类型的LiveData对象的类,也就是LiveData<PagedList<T>>的生成器。沟通DataSource.FactoryPageListPageList.Config三者的桥梁,将数据源工厂和相关配置统一交给PagedListBuilder,即可生成对应的LiveData<PagedList<T>>,为 PagedListAdapter提供所需要的 PagedList
val data :LiveData<PagedList> = LivePagedListBuilder(mFactory, pagedListConfig).build()
如果需要构造Observable<PagedList>或者Flowable<PagedList>RxJava中使用请使用RxPagedListBuilder

2.6.PagedListAdapter

PagedListAdapterRecyclerView.Adapter差别不大,比起普通的Adapter要额外提供一个 DiffUtil.ItemCallback实例用于数据比较更新列表。

class BillRecyclerViewAdapter :
    PagedListAdapter<Cheese, RecyclerView.ViewHolder>(DIFF_CALLBACK) {
    companion object {
        val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Cheese>() {
            override fun areItemsTheSame(oldItem: Cheese, newItem: Cheese): Boolean =
                oldItem.id == newItem.id
            override fun areContentsTheSame(oldItem: Cheese, newItem: Cheese): Boolean =
                oldItem == newItem
        }
    }
 }
  • areItemsTheSame:比对新旧item是否是同一个item;一般比对item的唯一标识id即可,如果item不同则可能不会更新UI

  • areContentsTheSame:当areItemsTheSame确定是同一个item之后,该方法会被调用。该方法比对item的内容是否一样(数据是否相同),不一样则会更新UI;建议这里的比对把UI展示的数据都写上,写漏了会导致UI不更新对应字段;

Paging组件支持三种不同数据结构:

  • 仅从网络获取

  • 仅从设备数据库获取

  • 两种数据来源的组合,使用设备数据库作为缓存,注意是数据库作为缓存而不是即从数据库获取又从网络获取。
    在这里插入图片描述
    在单一数据源情况下,我们使用Paging 2进行分页加载的整体架构设计一般是如下图:
    在这里插入图片描述
    如果时多层数据来源,需要使用BoundaryCallback实现监听数据边界,实现本地无数据时请求网络数据。
    在这里插入图片描述

2.7. PagedList.BoundaryCallback

监听数据边界,在PagedList到达可用数据的末尾时发出信号。当本地存储是网络数据的缓存时,当本地缓存全部被加载完后,需要去触发网络加载新的数据。

abstract class BoundaryCallback<T : Any> {
    open fun onZeroItemsLoaded() {}
    open fun onItemAtFrontLoaded(itemAtFront: T) {}
    open fun onItemAtEndLoaded(itemAtEnd: T) {}
}
  • onZeroItemsLoaded:从PagedList的数据源的初始加载返回零项时调用。
  • onItemAtFrontLoaded:当PagedList前面存在加载项目的时候会被调用,在此方法之前,不会有更多数据添加到PagedList
    • PositionalDataSourceloadInitial()之后会调用一次
    • ItemKeyedDataSource:没有见到有触发
    • PageKeyedDataSourceloadInitial()之后会调用一次,超过PagedList.Config中设置的maxSize就会触发
  • onItemAtEndLoaded:当PagedList末尾的项目被加载时会被调用,此方法之后,将不再有更多数据附加到PagedList
    • PositionalDataSource中到达LoadInitialCallback中设置的totalCount触发
    • ItemKeyedDataSourcePageKeyedDataSource数据加载完了就会触发

三、Paging 3的使用

Paging 3相对于Paging 2改动可以说很大,再来看一下Paging 3数据加载流程,如下图:
在这里插入图片描述

  • PagingSource:单一数据源。

  • RemoteMediator:其实 RemoteMediator 也是单一的数据源,它会在 PagingSource 没有数据的时候,再使用 RemoteMediator 提供的数据,如果既存在数据库请求,又存在网络请求,通常 PagingSource 用于进行数据库请求,RemoteMediator 进行网络请求。

  • Pager:用于构建 Flow 提供给 PagingDataAdapter 需要的 PagingData

  • PagingData:数据列表,相当于Paging 2中的PageListPaging 3中使用PagingData替换Paging 2中的PageList

  • PagingConfig:配置PagedData如何从其PagingSource加载内容。Paging 3中使用 PagingConfig替换Paging 2中的PagedList.Config

2.1. PagingSource

使用的话需要继承该类并实现 load 方法来加载数据,根据加载情况返回LoadResult.PageLoadResult.Error

Paging 2中的三种数据源ItemKeyedDataSourcePageKeyedDataSourcePositionalDataSource 合并为一个 PagingSource

class SingleDataSource : PagingSource<Int, Cheese>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Cheese> {
        val position = params.key ?: 0
        val dataList = loadData(position, params.loadSize)
        return try {
            LoadResult.Page(
                data = dataList,
                prevKey = if (position == 0) null else position - 1,
                nextKey = if (position == 0) position + 4 else if (dataList.isEmpty()) null else position + 1
            )
        } catch (exception: Exception) {
            return LoadResult.Error(exception)
        }
    }
    private fun loadData(pageNumber: Int, pageSize: Int): List<Cheese> {
        val data = ArrayList<Cheese>()
        val startPosition = pageNumber * pageSize
        if (startPosition > CHEESE_DATA.size) {
            return data
        }
        var endPosition = (pageNumber + 1) * pageSize
        if (endPosition > CHEESE_DATA.size) {
            endPosition = CHEESE_DATA.size
        }
        for (i in startPosition until endPosition) {
            val cheese = Cheese(i, CHEESE_DATA[i])
            data.add(cheese)
        }
        return data
    }
}
  • LoadParamsLoadParams.key要加载的页面的keyparams.loadSize要加载的页面的数据大小

  • LoadResult.PagePagingSource.load加载数据成功时调用。

  • LoadResult.ErrorPagingSource.load加载数据失败时调用。失败将作为LoadState.Error转发到UI ,并且可以重试。

2.2. Pager

Pager 主要用于构建 flow类型的PagingData对象提供给PagingDataAdapter使用 。

Paging 2中的LivePagedListBuilderRxPagedListBuilder 合并为了 Pager

在其构造方法中接受 PagingConfiginitialKeyremoteMediatorpagingSourceFactory,代码如下所示::

class Pager<Key : Any, Value : Any>
@JvmOverloads constructor(
    config: PagingConfig,
    initialKey: Key? = null,
    @OptIn(ExperimentalPagingApi::class)
    remoteMediator: RemoteMediator<Key, Value>? = null,
    pagingSourceFactory: () -> PagingSource<Key, Value>
)

initialKeyremoteMediator是可选参数,默认为nullremoteMediator上面我们讲到了,是在多层数据源下负责网络加载数据的。如果是单一数据源只需要使用PagingSource就行。基本使用方式如下:

val pager : Flow<PagingData<Cheese>> = Pager(pagingConfig, pagingSourceFactory = factory).flow
  • Kotlin-Flow使用Pager.flow

  • LiveData使用Pager.liveData

  • RxJava-Flowable使用Pager.flowable

  • RxJava-Observable使用Pager.observable

2.3. PagingConfig

主要配置一些基本的分页信息,其中部分信息例如页码、需要加载size等信息,会在PagingSourceload方法中通过 LoadParams 传递过来。与paging 2中的一致。

val pagingConfig = PagingConfig(
    pageSize = 10,
    prefetchDistance = 5,
    enablePlaceholders = true,
    initialLoadSize = 40,
    maxSize = 100
)
2.4. PagingDataAdapter

paging 2中的PagedListAdapter一样要额外提供一个DiffUtil.ItemCallback实例用于数据比较更新列表,但是比PagedListAdapter多增加了一些API。

2.4.1 监听数据加载状态

PagedListAdapter提供了loadStateFlowaddLoadStateListener两种方法,方便我们知道数据的加载状态,从而展示页面是在加载中还是加载失败等UI。

  • PagedListAdapter.loadStateFlow 返回的是个Flow<CombinedLoadStates>

  • PagedListAdapter.addLoadStateListener方法回调给我们的是CombinedLoadStates

CombinedLoadStates 允许我们获取3种不同类型的加载操作的加载状态:

变量作用
refresh在初始化或者刷新的时候使用
append在当前列表末尾添加数据的时候使用
prepend在当前列表头部添加数据的时候使用

其中CombinedLoadStates 来源是有区分的,区分来自PagingSourceRemoteMediator

LoadState 也有三种状态 NotLoadingLoadingError 代表网络请求状态。

变量作用
Error表示加载失败
Loading表示正在加载
NotLoading表示当前未加载

比如为我们的数据加载增加监听数据加载失败的情况:

lifecycleScope.launch {
            mAdapter.loadStateFlow.collectLatest {
                if (loadStates.append is LoadState.Error) {
                    val errInfo = loadStates.refresh as LoadState.Error
                    Toast.makeText(
                        this,
                        "\uD83D\uDE28 Wooops ${errInfo.error}",
                        Toast.LENGTH_LONG
                    ).show()
                }
            }
        }
mAdapter.addLoadStateListener { loadStates ->
    if (loadStates.append is LoadState.Error) {
        val errInfo = loadStates.refresh as LoadState.Error
        Toast.makeText(
            this,
            "\uD83D\uDE28 Wooops ${errInfo.error}",
            Toast.LENGTH_LONG
        ).show()
    }
}
2.4.2 添加Header和Footer

Paging 3支持添加HeaderFooter。为此PagingDataAdapter有3种可用的方法:

方法名作用
withLoadStateFooter添加到列表底部(类似于加载更多)
withLoadStateHeader添加到列表的头部
withLoadStateHeaderAndFooter添加到头部和底部

他们接收的参数都是LoadStateAdapter,所以我们的HeaderFooter需要实现LoadStateAdapter

// mItemRecyclerView.adapter = mAdapter
// 添加Header和Footer时,设置adapter使用下面方法
mItemRecyclerView.adapter = mAdapter.withLoadStateHeaderAndFooter(
    header = ReposLoadStateAdapter { mAdapter.retry() },
    footer = ReposLoadStateAdapter { mAdapter.retry() }
)
2.4.3. 刷新和重试
  • refresh:会回到初始化页,常用于下拉更新数据。

  • retry:常用于底部更多样式,当请求网络失败的时候,显示重试按钮,点击调用 retry

2.5. RemoteMediator

用于将数据从远程源增量加载到由PagingSource包裹的本地源中,默认是先走PageSource的数据,当 PageSource 提供的数据返回为空的情况,才会走 RemoteMediator 的数据。所以我们一般的使用都是PageSource进行数据库请求提供数据,RemoteMediator 进行网络请求,然后将请求成功的数据存入数据库。
在这里插入图片描述
RemoteMediator 和 PagingSource 的区别

  • RemoteMediator:实现加载网络分页数据并更新到数据库中,但是数据源的变动不能直接映射到 UI 上

  • PagingSource:实现单一数据源以及如何从该数据源中查找数据,数据源的变动会直接映射到 UI 上

注意:RemoteMediator API目前处于实验阶段。所有实现的类RemoteMediator都应使用注释@OptIn(ExperimentalPagingApi::class)
当使用此OptIn批注,请确保您的应用程序的build.gradle文件,你必须freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]在你的kotlinOptions

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
    private val query: String,
    private val database: RoomDb,
    private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
    val userDao = database.userDao()
    val remoteKeyDao = database.remoteKeyDao()
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, User>
    ): MediatorResult {
        return try {
            val loadKey = when (loadType) {
                LoadType.REFRESH -> null
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    val remoteKey = database.withTransaction {
                        remoteKeyDao.remoteKeyByQuery(query)
                    }
                    if (remoteKey.nextKey == null) {
                        return MediatorResult.Success(endOfPaginationReached = true)
                    }
                    remoteKey.nextKey
                }
            }
            val response = networkService.searchUsers(query, loadKey)
            database.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    remoteKeyDao.deleteByQuery(query)
                    userDao.deleteByQuery(query)
                }
                remoteKeyDao.insertOrReplace(RemoteKey(query, response.nextKey))
                userDao.insertAll(response.users)
            }
            MediatorResult.Success(endOfPaginationReached = response.nextKey == null)
        } catch (e: IOException) {
            MediatorResult.Error(e)
        } catch (e: HttpException) {
            MediatorResult.Error(e)
        }
    }
}
  • LoadType-这告诉我们是需要将数据加载到PagingData的末尾 (LoadType.APPEND) ,还是在PagingData的开头(LoadType.PREPEND)加载数据,还是我们是第一次加载数据(LoadType.REFRESH)。

  • PagingState-这为我们提供了有关之前加载的页面(pages),列表中最近访问的索引(anchorPosition)以及PagingConfig

    • pages: List<Page<Key, Value>> 返回的上一页的数据,主要用来获取上一页最后一条数据作为下一页的开始位置
    • config: PagingConfig 返回的初始化设置的 PagingConfig 包含了 pageSizeprefetchDistanceinitialLoadSize

load() 的返回值 MediatorResultMediatorResult 是一个密封类,根据不同的结果返回不同的值

  • 请求出现错误,返回 MediatorResult.Error(e)

  • 请求成功且后面还有有数据,返回 MediatorResult.Success(endOfPaginationReached = true)

  • 请求成功但是后面没有数据了,返回 MediatorResult.Success(endOfPaginationReached = false)

load() 方法里面将会做三件事

  1. 判断参数 LoadType
  2. 请求网络数据
  3. 将网络数据插入到本地数据库中

总结

  • PagingSource负责异步加载自定义源中的数据。

  • 通过Pager.flow创建了一个Flow<PagingData>Flow提供PagingDataPagingDataAdapter使用。而Pager基于PagingConfigPagingSource实例化。

  • 数据加载失败,可以使用PagingDataAdapter.retry方法进行重试。它会触发PagingSource.load()方法。另外刷新可以使用PagingDataAdapter.refresh方法。

  • 要将加载状态显示为headerfooter,使用PagingDataAdapter.withLoadStateHeaderAndFooter()方法并实现LoadStateAdapter

  • 如果需要知道数据加载状态做对应的操作,使用PagingDataAdapter.addLoadStateListener()回调。

  • 要同时使用网络和数据库,需要实现RemoteMediator

参加文档:
https://developer.android.com/codelabs/android-paging
https://www.bilibili.com/video/BV1Wb411P7CR?from=search&seid=16871215559318514007

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值