Jetpack源码分析(六)-Paging3源码分析(下)

Jetpack源码分析(六)-Paging3源码分析(下)

2023年7月9日发(作者:)

Jetpack源码分析(六)-Paging3源码分析(下)  本篇是Paging3源码分析的下篇,将重点介绍RemoteMediator的实现原理。⽹络上有很多的⽂章介绍这个多级数据源⼯具类,但是多多少少有点问题,⼀般都没有彻底理解清楚RemoteMediator整个过请求的流程。本⽂将从源码⾓度解析RemoteMediator的实现原理,同时也会分享RemoteMediator的⼀些⼩建议。  本⽂内容续接上篇内容,建议先看⼀下上篇⽂章:Jetpack 源码分析(五) - Paging3源码分析(上)。  本⽂主要内容如下:1. 多级数据源的请求过程。2. 分别分析RemoteMediator和PagingSource的实现细节。3. Refresh操作,Prepend操作,Append操作在多级数据源和单⼀数据源中的不同。4. 关于RemoteMediator使⽤的⼀些⼩建议。  本⽂参考资料:1. Page from network and database2. 使⽤ Paging 3 实现分页加载3. Android Jetpack组件之数据库Room详解(三)  注意,本⽂Paging源码均来⾃于3.0.0-alpha08版本。1. RemoteMediator的请求过程  相⽐于单⼀数据源,RemoteMediator多了⼀个过程--从⽹络上获取数据放到数据库中。那么在多级数据源中,怎么将数据库中的数据拿到UI层去显⽰呢?这个就要说到PagingSource。  在这之前,我先对PaingSource和RemoteMediator做⼀个解释,⽅便⼤家理解,因为它俩的⼯作是不⼀样的。1.

PaingSource:⽤于获取UI层需要的数据,且只能从⼀个地⽅获取,这也就是所谓的单⼀数据源。UI层需要的数据都是通过该类来获取的,包括在多级数据源⾥⾯,PaingSource负责从数据库⾥⾯获取数据。2.

RemoteMediator:主要的作⽤也是获取数据,只是它是从⽹络上(或者其他地⽅)获取数据,然后放到本地数据库,供PaingSource从本地数据库中获取数据。需要特别注意的是,RemoteMediator的数据不会直接⽤于UI显⽰,⽽是保存在数据库中。  同时,我画了⼀张图来帮助⼤家来理解这两个类是如何配合⼯作的(官⽹的流程图很容易误解,以为RemoteMediator获取的数据也会⽤于Ui层显⽰)。  整个请求过程如下:⾸先,当是第⼀次请求时,RemoteMediator会进⾏⼀次刷新操作,此时会请求到第⼀批数据,同时会将这这批数据放到本地数据库⾥⾯,此时对应的PagingSource对应的从数据库加载数据(需要注意的是,RemoteMediator和PagingSource是搭配使⽤的)。PagingSource的请求过程跟普通的请求类似,这个我们已经在上篇⽂章介绍过了,有兴趣的同学可以看看:Jetpack 源码分析(五) - Paging3源码分析(上)。最终会通过发送PageEvent将PagingSource获取的数据传递给Ui层。不过在这个过程中,我们有两个问题:1. 当PagingSource发现数据不够了,怎么通知RemoteMediator继续请求。2. 除了第⼀个页⾯数据的加载,RemoteMediator是怎么加载其他页⾯的数据,以及加载完成之后怎么通知PagingSource获取的呢?对于这两个问题,这⾥就不展开分析。下⾯我们将重点分析这两个问题和其他很多的问题,⽐如,之前介绍PagPresenter的时候,说它内部存储了所有的数据,在RemoteMediator中就不成⽴;以及,即使不⼿动刷新,PagingSource也会进⾏Refresh操作。  那么多级数据源是怎么从数据库⾥⾯获取的数据呢?我们在ViewModel内部定义Flow时,直接从Dao⾥⾯获取⼀个PagingSource 对象,并不是我们⾃⼰定义的。这个PagingSource实际上是⼀个LegacyPagingSource,是Paging3框架内部的⼀个 实现。从数据库获取数据的整个过程,主要是通过LegacyPagingSource的load⽅法,调到了LimitOffsetDataSource⾥⾯去了(注意这⾥是DataSource,对的,就是Paging2⾥⾯的DataSource),LimitOffsetDataSource是PositionalDataSource的⼦类,内部处理了从数据库获取数据的操作。也就是说,LegacyPagingSource其实只是⼀个Wrapper,⽤来抹平Paging2和Paging3之间的差异。2. RemoteMediator的触发  我们知道RemoteMediator请求是通过load⽅法进⾏,那么哪⾥在调⽤这个⽅法呢?在PageFetcher和PageFetcherSnapshot内部并没有直接调⽤调⽤RemoteMediator的⽅法,⽽是通过RemoteMediatorAccessor来辅助调⽤的。RemoteMediatorAccessor内部封装了很多RemoteMediator的调⽤逻辑,包括⾸次加载和加载更多,主要是通过内部的launchRefresh和launchBoundary完成对RemoteMediator的调⽤。同时关于RemoteMediatorAccessor,我们还需要注意⼀点,该对象只会在⾸次刷新创建⼀次,这⼀点跟PageFetcherSnapshot有很⼤的不同,之所以要这样做,是因为RemoteMediatorAccessor有很多全局的状态,不能因为Refresh⽽丢失了。  RemoteMediator的触发请求主要分为两种:Refresh和Append(Prepend)。我们分开来看⼀下他们的细节。(1). Refresh  在PageFetcherSnapshot内部有⼀个Flow对象--pageEventFlow,这个对象初始化的时候,定义⼏段代码,⽤来实现RemoteMediator触发Refresh请求,主要代码如下: if (triggerRemoteRefresh) { remoteMediatorConnection?.let { val pagingState = ck { tPagingState(null) } tLoad(REFRESH, pagingState) } }  这段代码⾮常的简单,但是内部蕴含的信息可不少,主要有三点:1. ⾸先,判断是否triggerRemoteRefresh是否为true,为true进⾏Refresh操作。为啥要判断这个变量呢?因为这段代码会调⽤的话,表⽰在进⾏Refresh操作,但是不代表RemoteMediator必须要刷新数据(RemoteMediator刷新数据时,需要将数据库中旧数据清除掉。)。在单⼀数据源中,只要不⼿动Refresh,可能永远不会有第⼆次Refrsh操作进⾏(这⾥只是说的可能,因为不能保证100%,PagingConfig⾥⾯的jumpThreshold字段会打破这个规则),但是在RemoteMediator中,如果本地数据库中的数据不够了,PagingSource可能会触发多次Refresh(正常滑动触发的),所以上述的代码可能会调⽤多次。因此需要通过triggerRemoteRefresh来过滤条件。同时从另⼀个⽅⾯来看,其他地⽅可以⼿动的调⽤PagingSource的invalidate和Adapter的refresh⽅法来触发刷新,那么这两个⽅法有啥区别: (1).

invalidate只是表⽰当前PagingSource失效了,会重新创建的创建⼀个新的PagingSource,这个过程不会影响原来的已有的数据。这个⽅法⼀般不允许外部⼿动调⽤。 (2).

refresh表⽰需要所有的数据清空,重新进⾏请求。⽐如说,我们进⾏了下拉刷新,此时就会调⽤这个⽅法。同时,我们从两个⽅法实现也能看出来区别,refresh给refreshChannel传的是true,即triggerRemoteRefresh为true;invalidate⽅法传的是false,即triggerRemoteRefresh为false。为了理解清晰,介绍简单,我将refresh触发的刷新称之为完全刷新,invalidate触发的刷新称之为不完全刷新,下述内容统⼀⽤这个来表⽰。2. 将PageFetcherSnapshotState内部的PagingState设置为null,这⼀步主要是为了辅助完全刷新。在Paging刷新过程中,会获取Refresh key,⽤来判断加载哪部分的数据;如果这个key为空,表⽰是完全刷新,如果是不为空,那么表⽰是不完全刷新,这部分的代码在LegacyPagingSource的getRefreshKey⽅法⾥⾯,有兴趣的同学可以看看。3. 调⽤RemoteMediatorConnection的requestLoad⽅法,进⾏刷新的数据请求。requestLoad⽅法⾮常的重要,因为RemoteMediator在触发⽹络请求时,都是通过这个⽅法实现的。  接下来,我们来分析⼀下requestLoad⽅法,直接来看代码: override fun requestLoad(loadType: LoadType, pagingState: PagingState) { // 1. 往任务队列中添加⼀个任务。 val newRequest = { (loadType, pagingState) } // 进⾏⽹络请求。 if (newRequest) { when (loadType) { H -> launchRefresh() else -> launchBoundary() } } }  requestLoad⽅法内部主要是做了两件事:1. 通过add⽅法往任务队列⾥⾯添加⼀个任务。在RemoteMediatorAccessImpl内部,维护了⼀个pendingRequests队列,⾥⾯存储着三种LoadType的任务。在添加的时候主要是check两件事:⾸先判断当前任务队列中是否已经有对应LoadType的任务;其次,当前任务是否处于未锁定的状态。只有这两个条件同时满⾜,才能添加成功,也才能进⾏第⼆步操作。2. 调⽤launchRefresh⽅法进⾏⽹络请求。  我们来看⼀下launchRefresh: private fun launchRefresh() { { var launchAppendPrepend = false solation( priority = PRIORITY_REFRESH ) { val pendingPagingState = { dingRefresh() } pendingPagingState?.let { // 调⽤RemoteMediator的load⽅法,进⾏⽹络请求。 val loadResult = (H, pendingPagingState) launchAppendPrepend = when (loadResult) { is s -> { // 更新状态,并且从队列中移除相关任务。 { endingRequests() ckState(, UNBLOCKED) ckState(D, UNBLOCKED) or(, null) or(D, null) } false } is -> { // 如果请求失败,那么看看队列中是否Append或者Prepend的任务,如果有的话,那么就 // 执⾏。 { endingRequest(H) or(H, (ble)) dingBoundary() != null } } } } } if (launchAppendPrepend) { launchBoundary() } } }  在launchRefresh⽅法⾥⾯主要是做了两件事:1. 调⽤RemoteMediator的load⽅法。因为load⽅法是⾃⼰定义,所以做了啥事,我们都很清楚,这⾥就不展开了。2. 其次,就是更新对应的状态。如果请求成功的话,那么会把APPEND和PREPEND释放,保证后⾯可以正常进⾏操作;如果是请求失败的话,除了更新状态之外,还通过调⽤getPendingBoundary判断当前任务队列是否有Append和Prepend的任务,如果有的话,就会调⽤进⾏请求,即调⽤launchBoundary。(2). Append  为了简单起见,这⾥只看Append的场景,Prepend跟Append⽐较类似,这⾥就不赘述了。  Refresh的触发过程,我们已经理解了,接下来我们来看⼀下Append的触发过程。Append是怎么触发的呢?⽤⼀句话来总结,就是当PagingSource加载完成数据后,根据请求回来的数据(包括PagingSource的Refresh和Append两种⽅式)来判断是否需要触发RemoteMediator的Append操作。⽐如下述代码: if (remoteMediatorConnection != null) { if (y == null || y == null) { val pagingState = ck { tPagingState(lastHint) } if (y == null) { tLoad(PREPEND, pagingState) } if (y == null) { tLoad(APPEND, pagingState) } } }  PagingSource的Refresh和Append关于是否进⾏RemoteMediator的Append操作的判断条件⾮常相似,就是看请求回来的数据的nextKey(prevKey)是否为空,如果为空就表⽰需要进⾏RemoteMediator的Append操作,即需要从⽹络拉取新的数据。那么nextKey为空表⽰的是什么意思呢?总的来说,nextKey为空就表⽰当前数据已经加载到数据库种的已有数据的边界,此时就必须要从⽹络⽹络上加载下⼀页数据了,否则的话,⽤户马上就要滑不动了。那么为啥nextKey就表⽰已经到了数据边界呢?在Paging2⾥⾯,数据请求有⼀个概念,就是totalCount,如果最后⼀个数据项的位置等于这个totalCount,那么表⽰已经到了边界,此时nextKey就会为空。这⾥的nextKey涉及到了PagingSource的加载过程,以及LegacyPagingSource和LimitOffsetDataSource的加载,我们先不展开分析,后续有内容会重点分析这个。  Append的触发最终也调⽤到了RemoteMediatorConnection的requestLoad⽅法⾥⾯,我们之前已经看过这个⽅法了,这⾥直接看RemoteMediatorAccessor的launchBoundary⽅法: private fun launchBoundary() { { solation( priority = PRIORITY_APPEND_PREPEND ) { while (true) { val (loadType, pendingPagingState) = { dingBoundary() } ?: break when (val loadResult = (loadType, pendingPagingState)) { is s -> { { endingRequest(loadType) if (aginationReached) { ckState(loadType, COMPLETED) } } } is -> { { endingRequest(loadType) or(loadType, (ble)) } } } } } } }  launchBoundary做的事⾮常的简单,就是调⽤RemoteMediator的load⽅法,请求下⼀批数据到数据库中。操作跟Refresh基本类似,这⾥就不再分析了。  到此,关于RemoteMediator触发过程的内容就结束了,在这⾥,我们对此做⼀个⼩⼩的总结,以便⼤家脑海中有⼀个印象。Paging3内部的刷新可以分为两种:完全刷新(调⽤PageFetcher的refresh⽅法)和不完全刷新(调⽤PageFetcher的invalidate⽅法)。这两种刷新不同点在于,完全刷新时,RemoteMediator会清空已有的数据,重新请求数据;⽽不完全刷新则不会。这个主要通过PageFetcherSnapshot的triggerRemoteRefresh来控制的。RemoteMediator的刷新主要是通过RemoteMediatorConnection的requestLoad⽅法触发,其⽅法内部调⽤launchRefresh⽅法,进⽽调⽤了RemoteMediator的load⽅法。需要特别注意的是,requestLoad⽅法是外部(PageFetcherSnapshot)触发RemoteMediator加载⽹络数据唯⼀途径。RemoteMediator加载更多的触发是在PagingSource请求完成之后才进⾏的,当发现已经到了数据边界,此时通过requestLoad⽅法加载下⼀页的数据。RemoteMediatorConnection内部通过launchBoundary⽅法触发RemoteMediator的load⽅法。关于数据边界,主要是通过nextKey是为空来判断的,这个涉及到PagingSource的加载过程,我们马上会分析。3. PagingSource和DataSource的加载  前⾯已经说过了,在多级数据源中,PagingSource是LegacyPagingSource,DataSource是LimitOffsetDataSource。其中LegacyPagingSource只起到了⼀个桥梁作⽤,保证在Paging3⾥⾯能使⽤Paging2的DataSource。  我们直接来看LegacyPagingSource的load⽅法: override suspend fun load(params: LoadParams): LoadResult { val type = when (params) { is h -> REFRESH is -> APPEND is d -> PREPEND } val dataSourceParams = Params( type, , ze, oldersEnabled, @Suppress("DEPRECATION") ze ) return withContext(fetchDispatcher) { (dataSourceParams).run { ( data, @Suppress("UNCHECKED_CAST") if (y() && params is d) null else prevKey as Key?, @Suppress("UNCHECKED_CAST") if (y() && params is ) null else nextKey as Key?, itemsBefore, itemsAfter ) } } }  load⽅法的实现很简单,最终调⽤了LimitOffsetDataSource的load⽅法。不过这⾥有⼀点我们需要注意:当请求返回的数据为空,key会为空。数据为空,表⽰本地数据库没有更多的数据可以加载,也就是说已经加载到边界了,所以需要告诉RemoteMediator从⽹络上请求更多的数据。关于这种情况,有⼀个问题:当执⾏PagingSource发现没有更多的数据,此时需要从⽹络上获取数据,那么数据请求回来之后,怎么通知PagingSource来重新加载数据呢?这个就得说说Room的实现,Room在初始化的时候,给我们的表创建了⼀个触发器,⽤以监听表的更新,插⼊和删除三种操作。当有新的数据更新到数据库中去的时候,触发器会发送⼀个invalidate的通知,这个通知会调⽤PagingSource的invalidate⽅法,从⽽导致PagingSource重新创建和重新加载,此时就是所谓在上下滑动过程也会触发PagingSource的Refresh操作。关于触发器的逻辑,有兴趣的同学可以看看Room的⼀个类:InvalidationTracker。但是按照正常逻辑来说,PagingSource重建之后,Refresh获取数据应该是全新的,怎么能保证数据前后衔接上呢?这是因为initialKey的存在,因为在scan⽅法的时候会拿到创建之前的key,如下: val flow: Flow> = channelFlow { // ...... () .onStart { // ...... } .scan(null) { // ...... @OptIn(ExperimentalPagingApi::class) val initialKey: Key? = previousGeneration?.refreshKeyInfo() .let { reshKey(it) } : initialKey // ...... } // ...... }  这⾥获取initialKey主要是通过PagingSource的getRefreshKey⽅法。这个⽅法在之前说不完全刷新时就提到了,感兴趣的同学可以看看(内部主要通过PagingState的anchorPosition来计算key,完全刷新时,anchorPosition要么为0,要么为空,所以不会衔接之前的数据)。  这⾥给⼤家补充了⼀下额外的知识,我们继续看⼀下DataSource的loadInitial⽅法(load⽅法调⽤了loadInitial⽅法,只是在load⽅法⾥⾯有⼀些计算,这些计算逻辑有兴趣的同学可以⾃⾏看看,这⾥就不讲解了): internal suspend fun loadInitial(params: LoadInitialParams) = suspendCancellableCoroutine> { cont -> loadInitial( params, object : LoadInitialCallback() { override fun onResult(data: List, position: Int, totalCount: Int) { if (isInvalid) { //...... } else { val nextKey = position + resume( params, BaseResult( data = data, // skip passing prevKey if nothing else to load prevKey = if (position == 0) null else position, // skip passing nextKey if nothing else to load nextKey = if (nextKey == totalCount) null else nextKey, itemsBefore = position, itemsAfter = totalCount - - position ) ) } } // ...... ) }  loadInitial⽅法⾥⾯主要做了两件事:1. 调⽤另⼀个loadInitial⽅法获取数据。这个loadInitial⽅法就是从数据库⾥⾯获取数据,有兴趣的同学可以看看,这⾥就不展开了。2. 根据请求的结果,返回⼀个BaseResult。这⾥我们特别注意的是,当nextKey == totalCount时,返回nextKey,这个验证了我们之前的说法。  ⾄此PagingSource的Refresh加载就结束了,这⾥我省略Append的过程分析,因为Append过程和Refresh过程⾮常相似,只不过他们在调⽤的⽅法不⼀样⽽已。Refresh调⽤的是loadInitial⽅法,Append 调⽤的loadRange⽅法,其他地⽅都⽐较类似的,这⾥就不过多的分析了。  在这⾥,我猜测⼤家⼼⾥⾯还有疑惑,PagingSource的加载(Refresh 和Append)还是不理解,这两个操作是怎么关联起来的呢?接下来,我将继续给⼤家解疑答惑。4. PagingSource的Refresh和Append关联  在单⼀数据源中,我们都知道PagingSource⼀次完整的加载过程包括:⼀次Refresh + 多次Append + 多次Prepend。但是在多级数据源中却不是这样的,在多级数据源中,PagingSource完整加载过程是:[⼀次Refresh + 多次Append + 多次Prepend] + [⼀次Refresh + 多次Append + 多次Prepend]...... 。注意,在多级数据源中,RemoteMediator的完整加载过程是:⼀次Refresh + 多次Append + 多次Prepend。PagingSource的完整过程不是这样的,这⼀点⼀定要明确。  上⾯已经简单的说明了PagingSource完整加载过程,在这⾥我们详细的解释⼀下。主要从两个⽅⾯来说:1. Refresh + Append:当Refresh⼀次之后,本地会预取⼀批(不只是⼀页数据,这⾥默认为pagSize⼤⼩的数据量为⼀页)的数据,我们通过向下滑动的操作会将预取的数据⼀页⼀页(即每次取pageSize⼤⼩的数据)的Append到UI层。当这次Refresh的数量被消费完毕,即滑动到边界(或者说,预取的数据已经被完全加载到UI层了),此时nextKey为会空,从⽽再次触发RemoteMediator的⽹络请求(此时RemoteMediator的loadType是Append)。RemoteMediator请求完成之后,更新到数据库中,触发器会通知PagingSource重新创建并且Refresh(不完全刷新),再开始⼀轮的Append。同理Prepend也是类似的。2. Prepend:⾸先,这⾥Prepend说的不是从数据库获取新的数据,⽽是获取旧的数据。⽐如说,当前,我们向下滑动到200位置,在向上滑动,会进⾏Prepend操作(即从数据库获取之前的数据)。为啥会这样呢?因为多级数据源中,PagePresenter不会保存所有的数据(即打破了保存所有数据的规则,前⾯已经说过),最多会保留initialLoadSize⼤⼩的数据。其他的数据都会⽤占位符来代替,即PagePresenter⾥⾯的placeholdersBefore和placeholdersAfter,当再次需要使⽤的时候,会重新从数据库中加载并且显⽰。  上述第⼆点,我们从代码找到答案,就拿PositionalDataSource的loadInitial⽅法来说: override fun onResult(data: List, position: Int, totalCount: Int) { if (isInvalid) { // NOTE: this isInvalid check works around // /issues/124511903 (()) } else { val nextKey = position + resume( params, BaseResult( data = data, // skip passing prevKey if nothing else to load prevKey = if (position == 0) null else position, // skip passing nextKey if nothing else to load nextKey = if (nextKey == totalCount) null else nextKey, itemsBefore = position, itemsAfter = totalCount - - position ) ) } }  随着我们不断向下滑动,position会变得越来越⼤,因此itemsBefore就会变得越来越⼤(即PagePresenter的placeholdersBefore)。但是,我们的有效数据总量不会变⼤,始终是initialLoadSize这么多。因此,当我们使⽤RemoteMediator时,不要尝试获取任意位置的数据,因为获取的有可能是空。5. 使⽤RemoteMediator的⼀些⼩建议  ⾄此,源码分析我们算是结束了,在这⾥,我对使⽤RemoteMediator提⼀些⼩建议。(1). 不要随意的调⽤Adapter的getItem⽅法获取任意位置的数据  因为PagePresenter只会保留initialLoadSize⼤⼩的有效数据,其他位置都会⽤null来填充,所以通过getItem⽅法获取的数据很有可能为空,容易造成不必要的错误。(2). 尽量将PageConfig的initialLoadSize设置的⼤⼀点  因为在滑动过程中,PagingSource的Append和Prepend操作消费的是RemoteMediator获取的initialLoadSize⼤⼩的数据,将initialLoadSize设置⼤⼀点,可以减少RemoteMediator的请求。其次最好将initialLoadSize设置为pageSize整数倍,避免在Append和Prepend时出现断页的情况。(3). 最好RemoteMediator每次请求的数量量都设置成为⼀样,且都为initialLoadSize  对于RemoteMediator来说,每次请求虽然loadType不⼀样,但是本质都是差不多的,都是PagingSource发现数据不够了,需要新增数据。所以每次请求的数据都⼀样,能保证逻辑简单且统⼀。参考代码如下: override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { val startIndex = when (loadType) { H -> 0 D -> return s(true) -> { val stringBuilder = StringBuilder() h { ("size = ${}, count = ${()}n") } Log.i("pby123", ng()) count += lLoadSize count } } Log.i("pby123", "CustomRemoteMediator,loadType = $loadType") return try { val messages = ().getMessage(lLoadSize, startIndex) ansaction { if (loadType == H) { essage() } Message(messages) } s(y()) } catch (e: Exception) { (e) } }(4). RemoteMediator的load⽅法的PagingState存储的数据不是所有的数据  PagingState内部存储的数据并不是所有的数据,⽽是上⼀次Refresh的数据,不要尝试通过这个变量来计算所有数据的总数。不过,可以通过如下代码计算,但是不能保证100%靠谱,因为itemsBefore和itemsAfter可能是⽆效值。 val pages = var totalCount = 0 if(mpty()){ h { totalCount += + efore + fter } }6. 总结  到这⾥,Paging3源码分析的内容就结束了,我做了⼀个简单的总结:1. 正常情况下,RemoteMediator只会Refresh⼀次,除⾮⼿动Refresh;PagingSource可能会多次Refresh,除了第⼀个初始化Refresh之外,当RemoteMediator从⽹络上获取,放到数据库时,PagingSource也会Refresh。2. 在Paging3⾥⾯,分为两种刷新,分别是不完全刷新,即调⽤PageFetcher的invalidate⽅法,在这种情况下,本地数据库的数据不会清空,只会新增数据;完全刷新,即调⽤PageFetcher的refresh⽅法,此种刷新会清空本地数据库的数据。3. 多级数据源中的PagingSource完整加载过程是:[⼀次Refresh + 多次Append + 多次Prepend] + [⼀次Refresh +多次Append + 多次Prepend]......,这个跟单⼀数据源不⼀样。

发布者:admin,转转请注明出处:http://www.yc00.com/news/1688907283a182273.html

相关推荐

发表回复

评论列表(0条)

  • 暂无评论

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信