距离上一篇Jetpack源码分析的文章已经两个月,时间间隔确实有点长。最近,感觉自己的学习积极性不那么的高,看Paging的源码也是断断续续的。时至今日,才算是完成对Paging的源码学习。今天我们就来学习Paging的实现原理。
本文参考资料:
注意,本文Paging相关源码均来自于2.1.2版本。
1. 概述
在日常开发中,我们经常能接触得到一个场景--需要加载列表数据,通常来说,列表数据的显示可以用RecyclerView,但是列表数据的加载并没有现成的库或者工具类供我们使用。从另一个方面来说,对于大量的列表数据,我们不可能一次将它一次性从后台获取过来,所以分页加载是必要的事。
由于Google爸爸没有给我们提供现成的轮子,所以在此之前,我们需要分页加载,都是自己简单的实现。通常实现方案是:通过RecyclerView对OnScrollerListener
的onScrolled方法的回调,我们可以在这个方法里面监听并且计算位置,当符合加载时机时,就可以加载下一页的数据。
上面的实现方案是无可厚非的,并且还比较简单,实现起来也比较容易。但是有啥问题呢?我们从下面几个方面来看看:
- 耦合度比较高。OnScrollerListener在计算位置的时候,通常来说会依赖RecyclerView的LayoutManager,不同LayoutManager有不同计算方式,如果后面RecyclerView有很多不同的LayoutManager,OnScrollerListener里面就会变的非常复杂。
- 扩展性比较差。这个可以从两个方面来介绍:首先,在不同的业务场景中,加载下一页的方式可能不一样,可能是通过Position获取下一页,也有可能是通过key获取下一次,针对于此类情形,OnScrollerListener必须单独处理;其次,通常来说,RecyclerView不仅仅有加载下一页数据的场景,也有可能加载上一页的场景的,针对于此类情形,OnScrollerListener也需要单独处理。
其实,理想的情况是每种业务场景互相互相独立,而不是糅合在一个类里面。当然,这些问题其实可有可无,因为经过简单的拆分和整理,还是可以完全避免。而我们今天介绍的Paging
,是Google爸爸为了解决分页加载的问题而推出的一个库。出于偷懒的原则,既然Google爸爸已经为我们实现了,我们为啥还要自己搞呢,对吧?
针对于Paging,我也不过多的介绍它是啥,它是怎么使用,相信大家非常的熟悉。我们就直接进入本文的主题--从源码角度来学习一下Paging的实现原理。
2. 基本架构
Paging虽然是Jetpack成员中的一份子,但是却跟其他成员(Lifecycle、ViewModel等)不一样。其他的成员可能就是几个类就能搞定实现,但是Paging却不一样,里面涉及的类特别的多,所以在正式分析它的源码之前,我们先来看一下它的架构实现。同时,我们从这里可以看出来,Google爸爸对Paging的期望很高,否则为啥会不遗余力的设计和实现它。
从实现上来看,Paging
主要分为3个部分:PagedListAdapter
、PagedList
和DataSource
。这其中,PagedListAdapter
和DataSource
比较熟悉,因为我们在使用过程中必须自定义它俩,相对而言,我们对PagedList
要陌生一些。不管怎么样,我们都先来了解一下它们。
(1). PagedListAdapter
从本质上来说,PagedListAdapter
其实就是RecyclerView中 的Adapter的实现类,本身承载的作用就是Adapter本身的作用。不过,相比于其他的Adapter,PagedListAdapter
的内部却也有些不同。
PagedListAdapter
内部有一个AsyncPagedListDiffer
类,这个类接管了Adapter对数据源的所有操作,其中包括:
- submitList:该方法的作用就是给Adapter设置一个新的数据源,由于Adapter可能存在的旧数据源,所以需要使用DiffUtil来进行差量计算。
AsyncPagedListDiffer
将这个方法的具体操作接管了过去,其实内部就是进行差量计算。我们通过这个方法的参数,还可以注意到一个小细节,就是该方法的参数是一个PagedList
。进而可以知道,AsyncPagedListDiffer
内部的维护PagedList对象。- getItem:该方法的作用是从数据源中获取对应位置的Data数据。
AsyncPagedListDiffer
将其也接管过去了,其内部实现其实就是从PagedList
里面获取,PagedList
的本质就是一个List。需要特别注意的是,该方法的数据可能会为空,所以一定要做防空的保护,具体为啥会为空呢?待会我们分析在PagedList会重点介绍。- getItemCount:该方法的作用是返回数据源的总个数。同
getItem
方法,该方法也被AsyncPagedListDiffer
接管过去了。
总的来说,AsyncPagedListDiffer
接管了Adapter对数据源的操作,同时在这个过程中还承担了一个角色:作为PagedList操作Adapter的中间桥梁。
可能有人会问,什么是PagedList操作Adapter?我们知道,当数据源发生了改变,比如说进行了add、remove或者update的操作,要想操作生效,必须调用对应的notifyXXX方法。AsyncPagedListDiffer
在初始化PagedList时,会向其中注册一个回调接口,用来监听这一部分的操作,当回调产生,会调用Adapter对应的方法。这个待会我们在分析源码,可以简单的从源码角度看一下。
(2). PagedList
PagedList
相较于PagedListAdapter
来说,要稍微复杂。我们主要从两个方面看一下PagedList:
PagedList本身是基类,提供很多通用的方法,比如说
size
方法、getLastKey
方法等。这些方法每个子类的实现都差不多,但是isContiguous
方法就不一样,它可以将PagedList分为两个部分:连续的还是非连续的。那么我们怎么来理解这连续的概念呢?我们知道数据都是通过分页加载的方式,连续的数据,我们理解为下一页的数据跟上一页的数据有一定的关系,比如说下一页的数据是通过上一页某一个key获取的得来;非连续的数据,我们可以连接为下一页的数据跟上一页的数据没有关系,比如说PositionalDataSource
是完全通过position来获取数据,当然从一定意义来说,连续性的数据和非连续性的数据没有本质的区别,这个我们在后面可以看到。
通过isContiguous
方法划分,我们大致可以将PageList分为两类:
从上面的uml类图中,我们知道连续的PagedList对应的实现类是ContiguousPagedList
,非连续的PagedList
对应的实现类是TiledPagedList
。从uml类图,我们还可以得到一个信息就是,就是这两个个部分的PagedList关心的重点是不一样的:
- ContiguousPagedList关心的是
onPagePrepended
和onPageAppended
,也就是说,连续的PagedList关心的是上一页数据和下一页数据的加载。同时我们从源码可以简单的看到,类似于onPageInserted
这类TiledPagedList
比较关心的方法,在ContiguousPagedList
的内部是不支持的。- TiledPagedList关心的是
onPageInserted
方法,也就是说,非连续的PagedList关心的是数据的插入,这里我们将其理解为下一页数据的加载。同理,ContiguousPagedList
关心的方法在TiledPagedList
的内部也是不支持的。
PagedList还有一个简单的实现类--SnapshotPagedList
,该类的实现比较简单,且用途单一,本文就不讨论了(不知道Google爸爸实现这个类干嘛用的,很鸡肋)。
PagedList从本质来说,就是一个List接口的实现类,跟ArrayList差不多,其实就是集合,所以Adapter通过它来获取对应的Data,也是不无道理的。与ArrayList不同的是,PagedList
还负责加载数据的功能(实则不是PagedList来加载,而是通过PagedList通知dataSource来加载数据。)。
(3). DatDataSource
要说这三兄弟中最复杂的部分非DataSource莫属,DatDataSource
复杂点主要是体现如下两个方面:
- DataSource的实现类比较多。跟PagedList比较类似,DataSource也可以非分为连续的和非连续的;但是跟PagedList不一样,每个部分的实现类均还有实现类(主要分页加载的场景比较多。)。
- DataSource承担的功能比较复杂。顾名思义,我们从
DataSource
的名字,就知道它的作用是产生和维护数据。
我们先来简单的看一下DatDataSource的uml类图:
跟PagedList类似,我们可以从上面的uml类图发现,连续的和非连续的DataSource的重点是不一样的,这里就不反复介绍了。
(4).三兄弟的关系
上面分别介绍了一下三兄弟的各自作用,在这里,我们简单的看一下这三兄弟的关系,即它们三兄弟是怎么联系来的。
- PagedListAdapter:直接面对RecyclerView,只是要从PagedList里面获取对应的Data。同时,加载下一页数据的时机也是由它触发的,Adapter通过
getItem
方法从PagedList中获取数据的同时,还通过调用PagedList的loadAround
方法触发加载下一页数据的时机。- PagedList:首先是给
PagedListAdapter
提供对应的接口,让其能够获取数据以及加载下一页的数据;其次就是,直接持有DataSource的引用,可以直接对其进行对应的操作,比如说,加载数据等。- DataSource:三兄弟中最底层和最累的一个,主要是对
PagedList
提供接口,让其能够进行对应的操作。
到这里,我们对Paging库里面基本组成部分有了一个大概的了解,接下来我们将从源码角度来分析一下Paging的主要实现原理,本文主要从如下几个方面来分析Paging:
- paging如何进行初始化第一页数据(类似于刷新)。
- paging如何加载下一页的数据。
- 从源码角度来分析 PagedList的Config配置。
3.数据的加载
我们都知道paging是用来进行分页加载的,所谓分页加载,重点当然在加载,进一步的细化,我们需要了解的是:paging是怎么初始化数据,以及怎么加载下一页数据的。这里,我们分开来看这个方面,至于paging的基本使用,本文就不介绍了,不熟悉的同学可以参考 Android Jetpack- paging的基本使用这篇文章。
(1).加载第一页数据。
通常来说,加载第一页数据的方式不仅是第一次加载数据,还有一种方式就是通过刷新加载数据,此种方式会使之前的PagedList完全,进而重新创建一个新的PagedList对象来存储数据。
虽然说加载的方式有两种,但是从源码角度来看,其实都是一样的,接下来我们看一下对应的源码。
通常来说,我们使用Paging,都是在ViewModel里面创建一个LiveData<PagedList>对象,我们就从这个点开始分析源码。我们可以通过如下的方式创建LiveData<PagedList>对象:
val mPageListLiveData = LivePagedListBuilder(mFactory, PagedList.Config.Builder().apply {
setPageSize(20)
setEnablePlaceholders(true)
}.build()).build()
LiveData<PagedList>对象是通过LivePagedListBuilder
的build方法创建的,这其中LivePagedListBuilder
的构造方法,第一个参数是DataSource.Factory
,该工厂类的作用用来创建DataSource对象,所以我们使用Paging
的步骤中,一个必不可少的步骤就是创建对应的DataSource的工厂类;第二参数就是创建PagedList.Config对象,主要的作用是设置分页加载基本参数,比如说每页加载大的大小以及预取下一页的距离等。
假设我们正确的配置了分页加载的基本参数(我们这里强调了正确的配置,顾名思义也有错误的配置,这个我们在后面分析Config会重点介绍。),最后就是调用LivePagedListBuilder
的build方法创建LiveData<PagedList>对象。我们来看看build方法的实现:
@NonNull
@SuppressLint("RestrictedApi")
public LiveData<PagedList<Value>> build() {
return create(mInitialLoadKey, mConfig, mBoundaryCallback, mDataSourceFactory,
ArchTaskExecutor.getMainThreadExecutor(), mFetchExecutor);
}
build
方法本身没有做什么事,直接调用了create方法,我们看一下create方法实现:
@AnyThread
@NonNull
@SuppressLint("RestrictedApi")
private static <Key, Value> LiveData<PagedList<Value>> create(
@Nullable final Key initialLoadKey,
@NonNull final PagedList.Config config,
@Nullable final PagedList.BoundaryCallback boundaryCallback,
@NonNull final DataSource.Factory<Key, Value> dataSourceFactory,
@NonNull final Executor notifyExecutor,
@NonNull final Executor fetchExecutor) {
return new ComputableLiveData<PagedList<Value>>(fetchExecutor) {
@Nullable
private PagedList<Value> mList;
@Nullable
private DataSource<Key, Value> mDataSource;
private final DataSource.InvalidatedCallback mCallback =
new DataSource.InvalidatedCallback() {
@Override
public void onInvalidated() {
invalidate();
}
};
@SuppressWarnings("unchecked") // for casting getLastKey to Key
@Override
protected PagedList<Value> compute() {
// ·······
}
}.getLiveData();
}
create
方法里面看似代码非常多且复杂,实际上就是创建ComputableLiveData
对象,然后获取了ComputableLiveData
里面的LiveData。
从名字上来看,我们都以为ComputableLiveData
是LiveData的实现类,实际上不是的;ComputableLiveData
可以理解为LiveData
的包装类。那么ComputableLiveData
里面都封装了啥玩意呢?我们可以简单的看一下ComputableLiveData
的源码:
@VisibleForTesting
final Runnable mRefreshRunnable = new Runnable() {
@WorkerThread
@Override
public void run() {
boolean computed;
do {
computed = false;
// compute can happen only in 1 thread but no reason to lock others.
if (mComputing.compareAndSet(false, true)) {
// as long as it is invalid, keep computing.
try {
T value = null;
while (mInvalid.compareAndSet(true, false)) {
computed = true;
value = compute();
}
if (computed) {
mLiveData.postValue(value);
}
} finally {
// release compute lock
mComputing.set(false);
}
}
// check invalid after releasing compute lock to avoid the following scenario.
// Thread A runs compute()
// Thread A checks invalid, it is false
// Main thread sets invalid to true
// Thread B runs, fails to acquire compute lock and skips
// Thread A releases compute lock
// We've left invalid in set state. The check below recovers.
} while (computed && mInvalid.get());
}
};
// invalidation check always happens on the main thread
@VisibleForTesting
final Runnable mInvalidationRunnable = new Runnable() {
@MainThread
@Override
public void run() {
boolean isActive = mLiveData.hasActiveObservers();
if (mInvalid.compareAndSet(false, true)) {
if (isActive) {
mExecutor.execute(mRefreshRunnable);
}
}
}
};
简单来说,ComputableLiveData
的核心就是两个Runnable:mInvalidationRunnable
和mRefreshRunnable
。
- mInvalidationRunnable:通过调用
ComputableLiveData
的invalidate
方法会执行这个Runnable。这个Runnable内部本身没有承载很多的功能,就是简单的判断了一下状态,然后执行mRefreshRunnable
来刷新数据。mInvalidationRunnable
存在的意义就是为我们提供刷新的操作,比如说我们通过下拉刷新想要刷新当前的数据,应该怎么怎么实现呢?我们都是通过调用DataSource的invalidate
方法来实现,而DataSource的invalidate
方法就会回调到ComputableLiveData
的invalidate
方法,进而实现刷新逻辑。至于为啥如此回调,大家可以看一下上面create方法中的InvalidatedCallback
的实现。- mRefreshRunnable:
mRefreshRunnable
的实现比mInvalidationRunnable
比较复杂一点,但是不管怎么复杂,实际就是调用compute
方法创建一个PagedList对象。
我们来compute方法的实现,看看它是怎么创建PagedList对象的:
protected PagedList<Value> compute() {
@Nullable Key initializeKey = initialLoadKey;
if (mList != null) {
initializeKey = (Key) mList.getLastKey();
}
do {
if (mDataSource != null) {
mDataSource.removeInvalidatedCallback(mCallback);
}
// 创建DataSource对象。
mDataSource = dataSourceFactory.create();
mDataSource.addInvalidatedCallback(mCallback);
// 创建PagedList。
mList = new PagedList.Builder<>(mDataSource, config)
.setNotifyExecutor(notifyExecutor)
.setFetchExecutor(fetchExecutor)
.setBoundaryCallback(boundaryCallback)
.setInitialKey(initializeKey)
.build();
} while (mList.isDetached());
return mList;
}
我可以compute
方法的实现分为两步:
- 创建DataSource对象。在这里,我们可以看到调用
DataSource.Factory
的create
方法创建了DataSource;同时,从这里,我们可以知道每次刷新,DataSource对象都会重新创建,所以大家在使用Paging时,千万不要尝试在DataSource.Factory
里面复用DataSource
对象。- 通过
PagedList.Builder
创建一个PagedList对象。
到这里,我们并没有看到调用数据加载的方法。我们进一步往下看,看一下PagedList.Builder
的build方法:
@WorkerThread
@NonNull
public PagedList<Value> build() {
// TODO: define defaults, once they can be used in module without android dependency
if (mNotifyExecutor == null) {
throw new IllegalArgumentException("MainThreadExecutor required");
}
if (mFetchExecutor == null) {
throw new IllegalArgumentException("BackgroundThreadExecutor required");
}
//noinspection unchecked
return PagedList.create(
mDataSource,
mNotifyExecutor,
mFetchExecutor,
mBoundaryCallback,
mConfig,
mInitialKey);
}
build
方法并没有做啥事,只是调用了PagedList
的create方法,我们来看看create方法的实现(不得不吐槽,这调用栈太深了...):
static <K, T> PagedList<T> create(@NonNull DataSource<K, T> dataSource,
@NonNull Executor notifyExecutor,
@NonNull Executor fetchExecutor,
@Nullable BoundaryCallback<T> boundaryCallback,
@NonNull Config config,
@Nullable K key) {
if (dataSource.isContiguous() || !config.enablePlaceholders) {
int lastLoad = ContiguousPagedList.LAST_LOAD_UNSPECIFIED;
if (!dataSource.isContiguous()) {
//noinspection unchecked
dataSource = (DataSource<K, T>) ((PositionalDataSource<T>) dataSource)
.wrapAsContiguousWithoutPlaceholders();
if (key != null) {
lastLoad = (Integer) key;
}
}
ContiguousDataSource<K, T> contigDataSource = (ContiguousDataSource<K, T>) dataSource;
return new ContiguousPagedList<>(contigDataSource,
notifyExecutor,
fetchExecutor,
boundaryCallback,
config,
key,
lastLoad);
} else {
return new TiledPagedList<>((PositionalDataSource<T>) dataSource,
notifyExecutor,
fetchExecutor,
boundaryCallback,
config,
(key != null) ? (Integer) key : 0);
}
}
在create方法里面,我们可以发现,这里通过一定的条件来判断是创建ContiguousPagedList
还是TiledPagedList
。这个条件主要从两个方面考虑:
- DataSource是否支持连续的数据,通过
isContiguous
方法来判断。通过上面的内容,我们知道ItemKeyedDataSource
和PageKeyedDataSource
都是连续的。- 如果config里面配置不支持占位符,表示DataSource支持连续的数据。如果DataSource本身不支持连续的数据,那么就通过
wrapAsContiguousWithoutPlaceholders
方法将DataSource转换成支持连续性数据的DataSource。也就是说,如果我们使用的是PositionalDataSource
,但是在config配置了不支持占位符,那么就DataSource转换为支持连续性数据的DataSource。
create方法的实现主要涉及到上面的两点,看上去实现没有啥问题,但是我不得不吐槽一下:
- create方法是在
PagedList
里面。PagedList
作为父类,还要关心子类的实现,这个设计我觉得有待商榷的,这里完全可以使用工厂模式或者建造者模式来创建对象,而不是在父类里面创建子类对象。- 如果config里面配置了不支持占位符,就将DataSource变为连续性的。这个坑,我相信大家都多多少少的躺过,我不得不吐槽,为啥要这样的设计。对外的实现不透明固然是好的,但是这里总感觉是为了实现占位符的功能,而挖了大坑。在这种情况下,非连续的DataSource不支持占位符完全可以抛异常,而不是兼容...不知道Google爸爸是怎么想的。
吐槽归吐槽,我们还是继续的看一下两个PagedList构造方法的实现,先来看看ContiguousPagedList
:
ContiguousPagedList(
@NonNull ContiguousDataSource<K, V> dataSource,
@NonNull Executor mainThreadExecutor,
@NonNull Executor backgroundThreadExecutor,
@Nullable BoundaryCallback<V> boundaryCallback,
@NonNull Config config,
final @Nullable K key,
int lastLoad) {
super(new PagedStorage<V>(), mainThreadExecutor, backgroundThreadExecutor,
boundaryCallback, config);
mDataSource = dataSource;
mLastLoad = lastLoad;
if (mDataSource.isInvalid()) {
detach();
} else {
mDataSource.dispatchLoadInitial(key,
mConfig.initialLoadSizeHint,
mConfig.pageSize,
mConfig.enablePlaceholders,
mMainThreadExecutor,
mReceiver);
}
mShouldTrim = mDataSource.supportsPageDropping()
&& mConfig.maxSize != Config.MAX_SIZE_UNBOUNDED;
}
其他地方我们不用关心,我们可以看到在这里调用了DataSource
的dispatchLoadInitial
方法,这个方法就是用来请求第一页的数据。我们来看看它的实现,这里以ItemKeyedDataSource
为例:
@Override
final void dispatchLoadInitial(@Nullable Key key, int initialLoadSize, int pageSize,
boolean enablePlaceholders, @NonNull Executor mainThreadExecutor,
@NonNull PageResult.Receiver<Value> receiver) {
LoadInitialCallbackImpl<Value> callback =
new LoadInitialCallbackImpl<>(this, enablePlaceholders, receiver);
loadInitial(new LoadInitialParams<>(key, initialLoadSize, enablePlaceholders), callback);
// If initialLoad's callback is not called within the body, we force any following calls
// to post to the UI thread. This constructor may be run on a background thread, but
// after constructor, mutation must happen on UI thread.
callback.mCallbackHelper.setPostExecutor(mainThreadExecutor);
}
dispatchLoadInitial
方法做的是比较简单,就是创建了一个LoadInitialCallbackImpl
,然后就是调用loadInitial
方法进行请求数据,这个方法也是我们在自定义DataSource时必须重写和实现的方法。
在这里,最最关键的一个一步就是调用setPostExecutor
方法设置mainThreadExecutor
。有人疑惑这个到底有啥用?这个就得从loadInitial
本身的实现说起,相信大家都有一个疑惑,就是我们在这方法执行网络到底应该放在子线程,还是保持该方法的原线程呢?从Okhttp层面上来说,我们是应该直接调用execute
还是enqueue
方法呢?
从这个方法本身的注解来看,我们直接通过execute
就行了,因为该方法的执行本身就放在子线程里面的:
@WorkerThread
public abstract void loadRange(@NonNull LoadRangeParams params,
@NonNull LoadRangeCallback<T> callback);
而我想说的是,其实两种方式都是可以,就是因为调用了setPostExecutor
方法。从两个方面来分析一下这个问题:
- 不切换线程。如果我们不切换线程,那么
loadInitial
方法就是阻塞型,必须等网络请求完成之后,才能保证PagedList创建成功。也就是说,PagedListAdapter的submitList方法会等待到网络请求才会回调,同时保证了提交的PagedList是肯定有数据的。- 切换线程。
loadInitial
方法就不是阻塞型的,那么肯定在网络请求完成之前,setPostExecutor
会被调用,那么请求会的数据也会通过mainThreadExecutor
对象post到主线程,从而保证Adapter的notifyXXX方法在主线程被调用。这种情况,需要特别注意的是submitList
方法被回调时,提交的PagedList是一个空数据的数组。
我记得在Google的Demo--PagingWithNetworkSample(现在是paging3了)里面,既有子线程调用的样例,也有主线程的样例,其实都是可以的。对此,大家不用再存疑。
我们自定义loadInitial
方法,会将请求完成的结果通过callback
的onResult方法回调过来,比如说,如下的代码:
@WorkerThread
override fun loadInitial(
params: LoadInitialParams,
callback: LoadInitialCallback<Message>
) {
val execute = getService().getMessage(params.pageSize, 0).execute()
val messageList = execute.body()
val errorBody = execute.errorBody()
if (execute.code() == 200 && messageList != null && errorBody == null) {
callback.onResult(messageList, 0, Int.MAX_VALUE)
} else {
callback.onResult(Collections.emptyList(), 0)
}
}
那么为什么必须要调用onResult
方法呢?onResult
方法里面到底做什么啥事呢?今天我们看一下LoadInitialCallbackImpl
的onResult
方法的实现:
public void onResult(@NonNull List<T> data, int position) {
if (!mCallbackHelper.dispatchInvalidResultIfInvalid()) {
if (position < 0) {
throw new IllegalArgumentException("Position must be non-negative");
}
if (data.isEmpty() && position != 0) {
throw new IllegalArgumentException(
"Initial result cannot be empty if items are present in data set.");
}
if (mCountingEnabled) {
throw new IllegalStateException("Placeholders requested, but totalCount not"
+ " provided. Please call the three-parameter onResult method, or"
+ " disable placeholders in the PagedList.Config");
}
mCallbackHelper.dispatchResultToReceiver(new PageResult<>(data, position));
}
}
LoadInitialCallbackImpl
存在两个onResult
方法,其中如果在Config中开启了执行占位符,最好是调用带totalCount
的onResult
;反之,则调用另一个onResult。我相信,大家对此也有疑问,本文在后面介绍Config的配置时,会重点介绍,这里就先不赘述。
回调最终会走到LoadCallbackHelper
的dispatchResultToReceiver
方法里面,我们来看看:
void dispatchResultToReceiver(final @NonNull PageResult<T> result) {
Executor executor;
synchronized (mSignalLock) {
if (mHasSignalled) {
throw new IllegalStateException(
"callback.onResult already called, cannot call again.");
}
mHasSignalled = true;
executor = mPostExecutor;
}
if (executor != null) {
executor.execute(new Runnable() {
@Override
public void run() {
mReceiver.onPageResult(mResultType, result);
}
});
} else {
mReceiver.onPageResult(mResultType, result);
}
}
在这里,我们可以看到mPostExecutor
的身影, 这就是前面通过setPostExecutor
方法设置的,不过这些都不重要。回调最后会走到PageResult.Receiver
的onPageResult
,那么onPageResult
方法里面做了啥事呢?
PageResult.Receiver<V> mReceiver = new PageResult.Receiver<V>() {
// Creation thread for initial synchronous load, otherwise main thread
// Safe to access main thread only state - no other thread has reference during construction
@AnyThread
@Override
public void onPageResult(@PageResult.ResultType int resultType,
@NonNull PageResult<V> pageResult) {
// ······
List<V> page = pageResult.page;
if (resultType == PageResult.INIT) {
// 将数据存储到mStorage
// ······
} else {
// 将数据存储到mStorage
// ······
if (mShouldTrim) {
// 裁剪数据。
}
}
// ······
}
};
onPageResult
方法看上去挺复杂的,其实就只做了两件事:
- 将数据存储到
mStorage
中去,主要是区分了三种情况:INIT表示第一次加载数据;APPEND表示加载下一页的数据;PREPEND表示加载上一页的数据。- 裁剪数据。有人可能会有疑问,为啥会有裁剪数据的操作,什么才叫裁剪数据呢?这个先要介绍一下
PagedStorage
这个类。顾名思义,PagedStorage
的作用就是存储数据的,用什么样的数据结构存储数据呢?分页加载当然就是一页一页的存储,所以数据结构就是类似于ArrayList<ArrayList<Data>>
。PagedStorage
内部便是这样的实现,裁剪数据的目的将一些没必要的数据裁剪掉,比如说,某些用于占位符的数据,在PagedStorage
内部就是一个PLACEHOLDER_LIST
对象,还就是裁剪一些某些为null数据,在Config里面有一个mMaxSize
的配置项,我们可以通过设置具体的数目,但是设置了这么了一定的数目,那么没有还没有加载的数据怎么表示呢?PagedStorage
会通过null表示。这个我们可以从Adpater的getItemCount方法得到一定的答案。假设我们设置为1000,那么getItemCount
方法肯定返回1000,没有加载的数据都是用null来表示的。mMaxSize
这个配置项实际上比较复杂,我们后面重点介绍。
至此,我们对加载第一页数据的逻辑已经理解的差不多了。简单的来说,就是在创建PagedList的时候会进行请求。我们需要注意的是,在loadInitial
方法里面,区分异步加载和同步加载的不同点。
(2). 加载下一页数据
由于ContiguousDataSource
存在dispatchLoadAfter
和dispatchLoadBefore
两个不同的加载逻辑,这里我将这两个方法加载的数据统称为加载下一页数据。
PagedListAdapter
在通过getItem
方法回去对应位置的数据时,会有一个特殊的调用,我们来看看具体的代码--AsyncPagedListDiffer
的getItem
方法:
public T getItem(int index) {
// ······
mPagedList.loadAround(index);
// ······
}
下一页数据的加载就是通过这里来触发的,我们来看看具体的实现:
public void loadAround(int index) {
if (index < 0 || index >= size()) {
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size());
}
mLastLoad = index + getPositionOffset();
loadAroundInternal(index);
mLowestIndexAccessed = Math.min(mLowestIndexAccessed, index);
mHighestIndexAccessed = Math.max(mHighestIndexAccessed, index);
/*
* mLowestIndexAccessed / mHighestIndexAccessed have been updated, so check if we need to
* dispatch boundary callbacks. Boundary callbacks are deferred until last items are loaded,
* and accesses happen near the boundaries.
*
* Note: we post here, since RecyclerView may want to add items in response, and this
* call occurs in PagedListAdapter bind.
*/
tryDispatchBoundaryCallbacks(true);
}
loadAround
方法本身没有做多少事,真正的操作都在loadAroundInternal
方法里面,我们来看看具体的实现,这里以ContiguousPagedList
为例:
@MainThread
@Override
protected void loadAroundInternal(int index) {
int prependItems = getPrependItemsRequested(mConfig.prefetchDistance, index,
mStorage.getLeadingNullCount());
int appendItems = getAppendItemsRequested(mConfig.prefetchDistance, index,
mStorage.getLeadingNullCount() + mStorage.getStorageCount());
mPrependItemsRequested = Math.max(prependItems, mPrependItemsRequested);
if (mPrependItemsRequested > 0) {
schedulePrepend();
}
mAppendItemsRequested = Math.max(appendItems, mAppendItemsRequested);
if (mAppendItemsRequested > 0) {
scheduleAppend();
}
}
loadAroundInternal
方法做的事实际上非常的简单,就是判断调用schedulePrepend
方法还是scheduleAppend
方法,主要是通过设置的预取距离跟当前所处的位置对比。这两个就是用来分别触发DataSource的dispatchLoadAfter
方法和dispatchLoadBefore
方法。
那么,这两个方法最后调用到哪里呢?其实就是我们在自定义DataSource重写的两个方法:loadAfter
和loadBefore
。在这里,我们需要的是注意的这两个方法是在子线程里面调用的,同时,在创建LoadCallbackImpl
时还设置了mainThreadExecutor
:
@Override
final void dispatchLoadBefore(int currentBeginIndex, @NonNull Value currentBeginItem,
int pageSize, @NonNull Executor mainThreadExecutor,
@NonNull PageResult.Receiver<Value> receiver) {
loadBefore(new LoadParams<>(getKey(currentBeginItem), pageSize),
new LoadCallbackImpl<>(this, PageResult.PREPEND, mainThreadExecutor, receiver));
}
跟loadInitial
方法不一样的是,在loadBefore
方法调用的时候,mainThreadExecutor
已经不为null了,所以在loadBefore
方法中,不要使用异步方法进行求网络请求,主要出于如下两个方面考虑:
- 尽量减少异步线程的数量。
loadBefore
方法本身就在子线程里面调用的,我们没有必要再去启动线程,我们都知道系统的资源都是有限的,启动一个线程还是比较消耗系统资源的。- 避免出现一些奇怪的问题。子线程里面再去启一个子线程,最后的回调接口最初的主线程里面,中间垮了两个线程,这个过程极易容易出现线程安全问题。
4. PagedList的Config配置
相信大家才开始使用的PagedList的时候,在Config配置上踩了很多的坑。今天,我就在这里重点介绍每个配置的作用。
字段名称 | 解释 |
---|---|
mPageSize | 每页的大小,主要透传到请求方法里面,用来决定请求数据的数量。 |
mPrefetchDistance | 预取范围,用来设置滑动什么位置才请求下一页的数据。 |
mInitialLoadSizeHint | 初始化请求数据的数量。 |
mEnablePlaceholders | 是否开启占位符,true表示开启,false则表示不开启。 |
mMaxSize | 数据的总数。 |
上面简单的介绍了一下每个字段含义,接下来我们将详细的解释每个字段的作用。
(1). mPageSize
其实我们从这个名字里面就可以知道,这个字段的含义就表示每页的大小,其实我们在进行网络请求的请求时,也完全没必要通过mPageSize
字段决定请求数据的数量。
针对于两个实现不同的DataSource,对mPageSize字段应用的程度也是不同的。其中ContiguousPagedList
没有对mPageSize
做过多的要求,包括我们在请求数据的时候,也可以忽略这个字段(虽然可以这么做,但是最好别这样做)。
而TiledPagedList
对mPageSize
则是强依赖,从两个方面来说:
首先,从初始化请求方面说起,在计算初始化的数量时,会通过mPageSize
来计算:
@WorkerThread
TiledPagedList(@NonNull PositionalDataSource<T> dataSource,
@NonNull Executor mainThreadExecutor,
@NonNull Executor backgroundThreadExecutor,
@Nullable BoundaryCallback<T> boundaryCallback,
@NonNull Config config,
int position) {
// ······
if (mDataSource.isInvalid()) {
// ······
} else {
final int firstLoadSize =
(Math.max(mConfig.initialLoadSizeHint / pageSize, 2)) * pageSize;
final int idealStart = position - firstLoadSize / 2;
final int roundedPageStart = Math.max(0, idealStart / pageSize * pageSize);
mDataSource.dispatchLoadInitial(true, roundedPageStart, firstLoadSize,
pageSize, mMainThreadExecutor, mReceiver);
}
}
通过上面的代码,我们可以发现,TiledPagedList
会将初始化页面大小设置为mPageSize
的整倍数。
需要特别注意的是:我们在loadInitial
方法设置请求数量,必须是mPageSize
的整数倍。因为我们可以从onResult
方法里面看到一个判断:
@Override
public void onResult(@NonNull List<T> data, int position, int totalCount) {
if (!mCallbackHelper.dispatchInvalidResultIfInvalid()) {
LoadCallbackHelper.validateInitialLoadParams(data, position, totalCount);
if (position + data.size() != totalCount
&& data.size() % mPageSize != 0) {
throw new IllegalArgumentException("PositionalDataSource requires initial load"
+ " size to be a multiple of page size to support internal tiling."
+ " loadSize " + data.size() + ", position " + position
+ ", totalCount " + totalCount + ", pageSize " + mPageSize);
}
if (mCountingEnabled) {
int trailingUnloadedCount = totalCount - position - data.size();
mCallbackHelper.dispatchResultToReceiver(
new PageResult<>(data, position, trailingUnloadedCount, 0));
} else {
// Only occurs when wrapped as contiguous
mCallbackHelper.dispatchResultToReceiver(new PageResult<>(data, position));
}
}
}
那么Google爸爸为啥要千方百计的保证请求数据的数量是pageSize的整数倍呢?这个其实跟占位符有关,一旦开启了占位符,Google爸爸就认为每一页请求都是应该是一样的,所以当遇到不同的size时,比如说在初始化时时整数倍的pageSize,Google爸爸就会进行分页。
比如说,mPageSize
为20,第一次请求的数据有40条;那么就会把这40条数据拆分成为两页的数据,那么在哪里进行拆分的呢?就在PagedStorage
的initAndSplit
方法里面:
void initAndSplit(int leadingNulls, @NonNull List<T> multiPageList,
int trailingNulls, int positionOffset, int pageSize, @NonNull Callback callback) {
int pageCount = (multiPageList.size() + (pageSize - 1)) / pageSize;
for (int i = 0; i < pageCount; i++) {
int beginInclusive = i * pageSize;
int endExclusive = Math.min(multiPageList.size(), (i + 1) * pageSize);
List<T> sublist = multiPageList.subList(beginInclusive, endExclusive);
if (i == 0) {
// Trailing nulls for first page includes other pages in multiPageList
int initialTrailingNulls = trailingNulls + multiPageList.size() - sublist.size();
init(leadingNulls, sublist, initialTrailingNulls, positionOffset);
} else {
int insertPosition = leadingNulls + beginInclusive;
insertPage(insertPosition, sublist, null);
}
}
callback.onInitialized(size());
}
initAndSplit
方法很简单,就是数据拆分为一页的一页的存储起来,保证每页大小都是我们设置的mPageSize。
其次,再来看看加载下一页的数据的请求,当请求到下一页的数据,会通过PagedStorage
的insertPage
方法存储起来,在insertPage方法里面有一个特别的判断:
public void insertPage(int position, @NonNull List<T> page, @Nullable Callback callback) {
final int newPageSize = page.size();
if (newPageSize != mPageSize) {
// differing page size is OK in 2 cases, when the page is being added:
// 1) to the end (in which case, ignore new smaller size)
// 2) only the last page has been added so far (in which case, adopt new bigger size)
int size = size();
boolean addingLastPage = position == (size - size % mPageSize)
&& newPageSize < mPageSize;
boolean onlyEndPagePresent = mTrailingNullCount == 0 && mPages.size() == 1
&& newPageSize > mPageSize;
// OK only if existing single page, and it's the last one
if (!onlyEndPagePresent && !addingLastPage) {
throw new IllegalArgumentException("page introduces incorrect tiling");
}
if (onlyEndPagePresent) {
mPageSize = newPageSize;
}
}
// ······
}
如果我们请求的数据大小不符合要求,直接回抛出异常。那么什么是不符合要求呢?就是请求返回的不为mPageSize。
那么为什么必须要保证每页是一样的,这里我就简单的介绍一下,感兴趣的可以看看PagedStorage
的实现:
当我们的Adpter通过getItem方法获取数据时,其实调用的是
PagedStorage
的get方法获取。我们知道分页数据其实是通过数组包裹数组的数据结构进行存储数据的,所以在获取数据时,需要获取两个Index,在PagedStorage
内部称为localIndex
和pageInternalIndex
,这两个index一个是一维数组的index,一个是二维数组的index。如果每页大小都是一样的(这种情况在PagedStorage
内部被称为Tiled
),那么就可以通过如下方式如下计算:
// it's inside mPages, and we're tiled. Jump to correct tile.
localPageIndex = localIndex / mPageSize;
pageInternalIndex = localIndex % mPageSize;
所以,这就是为啥要保证每页大小必须一样的原因。
上面介绍那么多,总结起来就是:初始化请求时,请求数据的总数必须是mPageSize的整数倍,加载下一页时必须为mPageSize。简而言之,我们在请求传参的时候,不要乱搞,避免出现各种问题,建议都传mPageSize,即param里面带的那个Size。
(2). mPrefetchDistance
mPrefetchDistance
表示也是非常的简单,就是表示预取距离,比如说,我们设置为5,就表示滑动到倒数第5个的时候,我们才请求下一页的数据。
我们先来看看ContiguousPagedList
的应用:
@MainThread
@Override
protected void loadAroundInternal(int index) {
int prependItems = getPrependItemsRequested(mConfig.prefetchDistance, index,
mStorage.getLeadingNullCount());
int appendItems = getAppendItemsRequested(mConfig.prefetchDistance, index,
mStorage.getLeadingNullCount() + mStorage.getStorageCount());
mPrependItemsRequested = Math.max(prependItems, mPrependItemsRequested);
if (mPrependItemsRequested > 0) {
schedulePrepend();
}
mAppendItemsRequested = Math.max(appendItems, mAppendItemsRequested);
if (mAppendItemsRequested > 0) {
scheduleAppend();
}
}
ContiguousPagedList
就在loadAroundInternal
方法里面进行判断的,具体的细节这里我们就不深入的讨论了。
我们再来看看PositionalDataSource
的实现:
public void allocatePlaceholders(int index, int prefetchDistance,
int pageSize, Callback callback) {
if (pageSize != mPageSize) {
if (pageSize < mPageSize) {
throw new IllegalArgumentException("Page size cannot be reduced");
}
if (mPages.size() != 1 || mTrailingNullCount != 0) {
// not in single, last page allocated case - can't change page size
throw new IllegalArgumentException(
"Page size can change only if last page is only one present");
}
mPageSize = pageSize;
}
final int maxPageCount = (size() + mPageSize - 1) / mPageSize;
int minimumPage = Math.max((index - prefetchDistance) / mPageSize, 0);
int maximumPage = Math.min((index + prefetchDistance) / mPageSize, maxPageCount - 1);
allocatePageRange(minimumPage, maximumPage);
int leadingNullPages = mLeadingNullCount / mPageSize;
for (int pageIndex = minimumPage; pageIndex <= maximumPage; pageIndex++) {
int localPageIndex = pageIndex - leadingNullPages;
if (mPages.get(localPageIndex) == null) {
//noinspection unchecked
mPages.set(localPageIndex, PLACEHOLDER_LIST);
callback.onPagePlaceholderInserted(pageIndex);
}
}
}
ContiguousPagedList
就在PagedStorage
的allocatePlaceholders
方法里面进行判断的,具体的细节这里我们就不深入的讨论了。说一句题外话,PagedStorage
很重要,如果想要理解PagedList的机制,一定要了解它,有机会我会专门的写一篇文章来分析它。
(4).mInitialLoadSizeHint
这个字段的含义也非常的简单,就是初始化请求数据的大小。这里需要特别的是:ContiguousPagedList
的大小是设置的多少,请求数据时拿到就是多少;TiledPagedList
的大小要根据mInitialLoadSizeHint
设置的大小而定,如果mInitialLoadSizeHint
比mPageSize
大,那么就是 2 * mPageSize。
(5). mEnablePlaceholders
这个字段表示的含义就是是否开启占位符,意思看上去非常的简单,但是Paging的内部使用这个字段贯穿全文。相信大家在使用Paging的时候都有一个问题,就是当我们init方法里面回调结果时,应该调用带totalCount的onResult
方法,还是不带toltalCount的onResult
方法?
同时,大家在使用PositionalDataSource
时,发现将mEnablePlaceholders
设置为true,此时只能调用带totalCountonResult
方法。在以前的版本中,这里调用错了,页面什么反应都没有,现在还好,会抛异常了。这又是为啥呢?
当我们不开启占位符时,为啥不能用TiledPagedList
?我们在PagedList
的create方法中发现,当没有开启占位符,尽管我们使用的是PositionalDataSource
,最后是还是会使用wrapAsContiguousWithoutPlaceholders
方法将PositionalDataSource
转换成为连续的DataSource,创建的PagedList也是ContiguousPagedList
。
接下来的内容,我们将一一的解答上面三个问题。
回到这个字段的本身,开启占位符到底表示什么意思,可以看一下下面的效果图:
占位符的意思非常简单,就是指有些Item的内容还没有加载回来,先用一些默认的UI来表示,比如说,上图中显示
加载中
就是表示没有数据还没有加载回来。所以,在这里,我们可以解释上面的第三个问题。当我们没有开启占位符的时候,Adapter通过getItem方法获取的数据肯定不为空,所以可以认为每一页的每一项数据都是有效,且是完整的,这个就比较符合连续性的数据的逻辑,同时连续的数据方便维护,因为连续的数据通常不用进行trim,更不会使用null和类似于
PLACEHOLDER_LIST
这种来表示占位,所以将其转换成为连续的数据类型是简化实现。接下来,我们来看一下两个
onResult
方法。熟悉Paging 的同学应该都知道,如果我们开启了占位符,一定要调用带totalCount的方法?事实真是如此的吗?这里分别从ContiguousDataSource
和PositionalDataSource
来看下。在
ContiguousDataSource
及其子类中,我们会发现onResult
方法一共如下两个:
public abstract void onResult(@NonNull List<Value> data);
public abstract void onResult(@NonNull List<Value> data, int position, int totalCount);
其实,在ContiguousDataSource
内部,不管是否开启占位符,带totalCount
的onResult
方法都可以调用,只是有一定区别:
totalCount表示的意思,我们可以简单的理解为当前数据的总数。
- 当开启开启了占位符。调用带
totalCount
的onResult
方法,就表示当前数据总数一定为totalCount
,Adapter的itemCount也会是totalCount
,此时getItem获取的数据可能为空;如果调用的是不带totalCount
的onResult
方法,那么Adapter的itemCount就是具体数据的数量,此时getItem获取的肯定不为空。- 当没有开启占位符。两个
onResult
方法没有区别。
我们再来看看 PositionalDataSource
,在其内部两个onResult
方法的定义如下:
public abstract void onResult(@NonNull List<T> data, int position);
public abstract void onResult(@NonNull List<T> data, int position, int totalCount);
我们分别来看看这个两个方法的区别:
- 当开启了占位符,只有调用带
totalCount
的方法,调用另一个方法直接抛异常。需要特别注意的是,此时totalCount传递的最大值为Int.MAX_VALUE - params.pageSize
,如下的代码:
@WorkerThread
override fun loadInitial(
params: LoadInitialParams,
callback: LoadInitialCallback<Message>
) {
val execute = RequestUtils.getService().getMessage(params.pageSize, 0).execute()
val messageList = execute.body()
val errorBody = execute.errorBody()
if (execute.code() == 200 && messageList != null && errorBody == null) {
callback.onResult(messageList, 0, Int.MAX_VALUE - params.pageSize)
} else {
callback.onResult(Collections.emptyList(), 0)
}
}
因为如果我们传递Integer.MAX_VALUE
,在加载下一页数据的时候,PagedStorage
计算数据时会溢出,这也是为什么当我们传递Integer.MAX_VALUE
,下一页的数据没有成功加载,溢出代码如下:
public void allocatePlaceholders(int index, int prefetchDistance,
int pageSize, Callback callback) {
// ······
// 这里会溢出
final int maxPageCount = (size() + mPageSize - 1) / mPageSize;
int minimumPage = Math.max((index - prefetchDistance) / mPageSize, 0);
int maximumPage = Math.min((index + prefetchDistance) / mPageSize, maxPageCount - 1);
allocatePageRange(minimumPage, maximumPage);
int leadingNullPages = mLeadingNullCount / mPageSize;
for (int pageIndex = minimumPage; pageIndex <= maximumPage; pageIndex++) {
int localPageIndex = pageIndex - leadingNullPages;
if (mPages.get(localPageIndex) == null) {
//noinspection unchecked
mPages.set(localPageIndex, PLACEHOLDER_LIST);
callback.onPagePlaceholderInserted(pageIndex);
}
}
}
- 当没有开启占位符,两个方法没有区别。
(6). mMaxSize
这个字段表示的意思虽然是数据的总数,但是实际上,这个字段极其的坑人。我们在使用这个字段时,发现不仅不会生效,而且设置了之后还是出现各种问题:
- 假设我们在使用
ContiguousDataSource
设置为100,没有开启占位符的话,会出现混乱的问题,具体效果如下:
开启占位符的话,mMaxSize不生效,具体的效果如下:
- 假设我们在使用
ContiguousDataSource
设置为100,没有开启占位符的效果跟ContiguousDataSource
,数据会混乱;开启占位符的话,mMaxSize就生效了,这也是唯一生效的地方,具体效果:
简单的来说,这个配置极其的不好用,同时Google爸爸在方法上也进行了特别的注释,Google爸爸说:mMaxSize只能尽力而为,不能百分百的保证。可想而知,这个配置是多么的鸡肋。
需要特别的注意的是:在mMaxSize
唯一生效的地方,如果我们设置的mMaxSize
和totalCount是不一样的值,那么就以totalCount
为准。
所以,如果我们要限制大小的话,最好是自己来实现,不要使用这个字段。
5. 总结
到这里,本文的内容就到此结束了,其实关于pagin的内容不仅仅是这些,本文的内容只能说起到一个提纲挈领的作用,比如说,PagedStorage
的设计,这部分内容并没有深入的介绍,有兴趣的同学可以去看看,我相信大家理解这个类所做的事,对Paging
的理解会更加深入。最后我来简单的总结一下本文的内容:
- 在Paging中,我们可以
PagedList
和DataSource
分为两类:非连续的和连续的,两者其实没有本质上的区别,只是在一些特殊业务场景上可能会有一点区别,比如说占位符,非连续的数据如果没有开启占位符的特性,其实本质上跟连续的数据是一样的。- 初始化请求数据,是在PagedList的构造方法里面进行,其中初始化请求方法本身在子线程里面执行,所以我们直接使用同步方法进行网络请求即可(当然也可以使用异步方法,但是不推荐。);下一页数据的请求时机,是在getItem方法里面的触发,PagedList会根据position来决定是否请求下一页的数据。
- Config的
mPageSize
用来限制每页数据的大小,同时我们在网络请求时,一定要使用给定的size,不要想着搞各种骚操作,避免出现各种问题。- Config中的
mEnablePlaceholders
用来控制是否使用占位符。ContiguousDataSource
和PositionalDataSource
对于开启占位符有不同的要求。ContiguousDataSource
在网络请求回调的时候,两个onResult
方法都可以使用,本质上并没有什么区别,只是要注意的是当调用带toltalCount
的onResult
方法是,getItem可能返回为null,这个在使用的时候需要特别关心;PositionalDataSource
开启了占位符,只能调用带toltalCount
的onResult
方法。- 如果使用的是
PositionalDataSource
,onResult
方法中的toltalCount
的值不要超过Integer.MAX_VALUE - pageSize
,因为在计算位置的时候可能会溢出,导致不能加载下一页的数据。- Config中的
mMaxSize
用来限制总数据的大小,但是实际上作用范围非常的小,只在PositionalDataSource
开启占位符才生效,同时如果toltalCount
跟mMaxSize
不一样的,会以toltalCount
为准。总之来说,不要轻易的使用mMaxSize
。
最后,我想简单的说几句话,paging是为了解决分页加载的问题而出现,这个初衷是很好的,但是使用的门槛实在是太高了,稍稍不注意就可能出现出错误,比如说Config的配置,onResult的回调。同时,我觉得paging在代码设计上也有一定的问题,比如说区分连续和非连续的,这个直接导致实现DataSource和PagedList的工作量翻倍;PagedStorage
将各种代码和实现糅合在一个类里面,导致阅读起来特别费劲。不过最近有一个好消息的是,Google爸爸在最新的JetPack推出了paging3,我希望这些问题都已经解决了。