Android 架构组件之 Paging

非 Y 不嫁゛ 提交于 2019-12-31 23:16:23

Paging Library 是 Google 提出的分页加载库,本文将从以下几个方面对 Paging 进行介绍:

  • 为什么要使用 Paging Library?
  • 分析 Paging 的组成及原理
  • 通过一个简单的案例,介绍如何使用 Paging Library
  • 最后对 Paging Library 进行简单的总结

1. 为什么要使用 Paging Library?

我们经常需要处理大量数据,但大多数情况下,只需要加载和显示其中的一小部分。如果去请求用户不需要的数据,势必会浪费用户设备的电量和带宽。如果数据比较多情况下,消耗用户的流量也会比较多。

Paging Library 是 Google 提出的分页加载库,它可以妥善的逐步加载数据, 解决上面提到的痛点。此外:

  • Paging Library 可以与 RecyclerView 无缝结合;
  • Paging Library 还支持加载有限、或无限的 List,从而使得 RecyclerView 快速,无限滚动;
  • Paging Library 可以配合 LiveData、RxJava 集成使用,来观察界面中的数据变化;
  • Paging Library 可以选择本地数据库,网络或两者结合的方式作为分页数据的数据源,还可以自定义如何加载内容。

Paging Library 有这么多的特点,正是我们选择的使用它的主要原因。接下来分析一下它的组成及原理。

2. 分析 Paging 的组成及原理

Paging Library 的原理是,将数据分解成多个 List,使用 RecyclerView 中的 Adapter来观察 LiveDdata 中的数据变化,在此基础上加上分页功能,从而实现逐步加载内容。

我们来看一下具体的实现过程:

看过上面Paging Library 的实现过程,我们来总结一下:

  • DataSource 负责从数据源加载数据,它是连接 数据源与 PagedList 的桥梁,DataSource 的数据源可以是本地的数据库,也可以是网络,或者两者结合的方式;
  • PagedList 是List 的子类,从 DataSource 中取得的数据先放到 PagedList 中,我们可以在 PagedList 中配置每次加载多少条数据;
  • PagedListAdapter 是 RecyclerView.Adapter 的实现类,从 PagedList 过来的数据,经过 DiffUtil 计算出数据的差异,计算的过程是一个异步的过程;
  • 计算后的数据,通过 RecyclerView.Adapter 的 onBindViewHolder() 方法,更新到 UI 上。

看过了 Paging Library 具体的执行过程,我们来分析一下它的组成。我们先来看一下 Paging Library 相关的类图。

Paging Library 的核心组件是 PagedList 和 DataSource,在上面的类图中,用不同的颜色进行了区分。下面我们分别来介绍。

2.1 PagedList

PagedList 是一个集合类,它以分块的形式异步加载数据,每一块就称为一页。

在上面的类图中,我们可以看到:

  • PagedList 有四个内部类,分别是 Config、抽象类 Callback、抽象类 BoundaryCallback 和 Builder。
  • Config 类可以自定义 PagedList 从数据源加载数据的一些行为,比如每页加载多少条数据 pageSize,初始加载多少数据 mInitialLoadSizeHint,是否使用占位符 mEnablePlaceholders ,预加载距离 prefetchDistance 等。通常设置 mInitialLoadSizeHint 是pageSize 的整数倍,默认是3 倍。预加载距离 prefetchDistance ,即列表当距离加载边缘多远时触发分页的请求,通常应该是屏幕上可见项的数倍,默认与 pageSize 相等。
  • Callback 当数据被加载到 PagedList 中时会触发这个类中的回调方法。
  • BoundaryCallback 当 PagedList 到达可用数据的末端时(需要加载分页内容时)就会触发这个类中的回调方法;
  • Builder 是 PagedList 的生成器类,PagedList 的实例都是通过 Builder 类中的 build()方法产生。

在 PagedList 中,除了上面提到的这四个内部类的成员变量之外,还有两个比较重要的成员变量:

  • mMainThreadExecutor 将数据传递到 Adapter 的主线程;
  • mBackgroundThreadExecutor 加载数据的后台线程;

Paging Library 还提供了 LivePagedListBuilder类,用于获取 PagedList 中的 LiveData 对象,创建 LivePagedListBuilder 的参数,创建 DataSource.Factory 对象和分页配置对象。LivePagedListBuilder 获取 LiveData 对象的过程如下:

@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() {
                @Nullable Key initializeKey = initialLoadKey;
                if (mList != null) {
                    initializeKey = (Key) mList.getLastKey();
                }

                do {
                    if (mDataSource != null) {
                        mDataSource.removeInvalidatedCallback(mCallback);
                    }

                    mDataSource = dataSourceFactory.create();
                    mDataSource.addInvalidatedCallback(mCallback);

                    mList = new PagedList.Builder<>(mDataSource, config)
                            .setNotifyExecutor(notifyExecutor)
                            .setFetchExecutor(fetchExecutor)
                            .setBoundaryCallback(boundaryCallback)
                            .setInitialKey(initializeKey)
                            .build();
                } while (mList.isDetached());
                return mList;
            }
        }.getLiveData();
    }

可以看到,创建 PagedList 对象,还是通过 PagedList 的内部类 Builder 的 build()方法。

如果倾向于使用 RxJava,而不是 LiveData,可以使用 RxPagedListBuilder, 它的构建方式与LivePagedListBuilder类似,不同之处在于RxPagedListBuilder返回一个 Observable 对象或 Flowable 对象,而不是 LiveData 对象。

2.2 数据源 DataSource

再来看看 Paging Library 的另一个核心组成部分 DataSource。DataSource 是将数据加载到 PagedList 中的基类,任何数据都可以作为 DataSource 的来源,比如网络、数据库、文件等等。 DataSource.Factory 类可以用来创建 DataSource。

从上面的类图中,我们总结一下:

  • DataSource 是一个抽象的泛型类,接收两个泛型参数<Key,Value>,其中 Key 表示从数据源加载数据项的唯一标识,Value 与标识 Key 对应的数据项。DataSource 中定义了一个抽象的静态内部类 Factory<Key, Value>,是创建 DataSource 的工厂类。
  • DataSource 有两个直接的子类,分别是 PositionalDataSource 和 ContiguousDataSource。ContiguousDataSource 是一个非 public 的类,我们通常使用它的两个子类,PageKeyedDataSource 和 ItemKeyedDataSource 。
  • PositionalDataSource 和 ContiguousDataSource 这两个类最大的区别是对抽象方法 isContiguous() 的实现方式不同,PositionalDataSource 中的 isContiguous() 方法返回 false,ContiguousDataSource 中的 isContiguous() 方法返回 true。所以我们在代码中,能够使用的 DataSource 有三种,分别是 PositionalDataSource 和 ContiguousDataSource 的两个子类 PageKeyedDataSource 和 ItemKeyedDataSource 。
public abstract class DataSource<Key, Value> {
    /**
         * Returns true if the data source guaranteed to produce a contiguous set of items,
         * never producing gaps.
         */
        abstract boolean isContiguous();
}

abstract class ContiguousDataSource<Key, Value> extends DataSource<Key, Value> {
        @Override
        boolean isContiguous() {
            return true;
        }
    }
    
public abstract class PositionalDataSource<T> extends DataSource<Integer, T> {
        @Override
        boolean isContiguous() {
            return false;
        }
    }

关于数据源产生的数据项是否为连续的,结合后面三种 DataSource 的使用场景更好理解。

我们来看一下 PositionalDataSource、PageKeyedDataSource 和 ItemKeyedDataSource 分别适用哪些场景

  • 使用 PositionalDataSource,需要我们的实现类实现 loadInitial() 和 loadRange() 方法,适用于数据项总数固定,要通过特定的位置加载数据。比如从某个位置开始的 100 条数据;
  • 使用 PageKeyedDataSource,需要实现 loadInitial()、loadBefore() 和 loadAfter() 方法,适用于以页信息加载数据的场景。比如在网络加载数据的时候,需要通过 setNextKey() 和 setPreviousKey() 方法设置下一页和上一页的标识 Key。
  • 使用 ItemKeyedDataSource 除了需要实现 loadInitial()、loadBefore() 和 loadAfter() 方法以外,还要实现getKey() 方法,适用于所加载的数据依赖其他现有数据信息的场景。比如要加载的下一页的数据,依赖于当前页的数据。

2.3 总结一下不同的数据源,如何创建 DataSource

假设数据源是数据库,Room 存储库可以作为 Paging Library 的数据源,对于给定查询的关键字,Room 可以从 DAO 中返回 DataSource.Factory 对象,从而无缝处理 DataSource 的实现。

假设数据库是从网络加载的数据缓存,从 DAO中返回 DataSource.Factory 对象,还需要另外一个分页组件,BoundaryCallback,当界面显示缓存中靠近结尾的数据时,BoundaryCallback 将加载更多的数据,在获得更多的数据后,Paging Library 将自动更新界面,不要忘记将创建的 BoundaryCallback 对象与之前创建的 LivePagedListBuilder 对象进行关联,关联之后,PagedList 就可以使用它了。

仅将网络作为数据源,在这种情景中,需要创建 DataSource 和 DataSource.Factory 对象,选择 DataSource 类型时, 需要综合考虑后端 API 的架构,如果通过键值请求后端数据,使用 ItemKeyedDataSource。

举个例子,我们需要在某个特定日期起,github的前 100 项提交,该日期将成为 DataSource 的键,ItemKeyedDataSource 允许自定义如何加载初始页,以及如何加载某个键值前后的数据,如果后端数据返回的是分页后的,那么我们可以使用 PageKeyedDataSource,比如 Github API 中的 SearchRepository 就可以返回分页数据,我们在 Github API 的请求中,指定查询的关键字和要查询哪一页,同时也可以指定每个页面的项数,不管网络数据源的创建方式是什么,都需要创建 DataSource.Factory对象,有了 DataSource.Factory 对象就可以创建 DataSource。

2.4 PagedListAdapter

Paging Library 提供了 PagedListAdapter,可以将 PagedList 中的数据加载到 RecyclerView 中,PagedListAdapter 会在页加载时收到通知,收到新数据时,会使用 DiffUtil 精细计算更新。

在 PagedListAdapter 中使用的是 AsyncPagedListDiffer,从名字就能看出这是一个异步计算更新的过程。

protected PagedListAdapter(@NonNull DiffUtil.ItemCallback<T> diffCallback) {
        mDiffer = new AsyncPagedListDiffer<>(this, diffCallback);
        mDiffer.addPagedListListener(mListener);
    }

在创建 PagedListAdapter 实例的时候,可以通过构造参数 DiffUtil.ItemCallback 对象,在 DiffUtil.ItemCallback 中可以来实现计算的规则。

public abstract static class ItemCallback<T> {
        public abstract boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem);
        public abstract boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem);
}

3. 通过一个简单的案例,介绍如何使用 Paging Library

我们使用 Github 的 api,实现按照指定关键字检索仓库,按照 star 数量和仓库名称降序的方式,将检索到的结果显示到 UI 上。

1. 首先使用 PageList 来批量加载数据,比如将 List 替换为 PagedList:

data class RepoSearchResult(
    val data: LiveData<PagedList<Repo>>,
    val networkErrors: LiveData<String>
)

当创建PagedList时,它会立即加载第一块数据,并随着时间的推移随着内容的加载而扩展。PagedList 的大小是每次传递期间装载的数据项的数目。该类既支持无限列表,也支持元素数量固定的非常大的列表。

**2. 定义 DataSource,为 PagedList 准备加载的内容。**在我们的例子中,因为数据库是UI的主要来源,所以在 Dao 中可以把 DataSource.Factory 作为返回值类型,方便创建 DataSource 实例。

@Dao
interface RepoDao {
    fun reposByName(queryString: String): Factory<Int, Repo>
}

在 Repository 中通过返回的 DataSource.Factory来创建 DataSource 实例:

class GithubRepository(
    private val service: GithubService,
    private val cache: GithubLocalCache
) {
    /**
     * Search repositories whose names match the query.
     */
    fun search(query: String): RepoSearchResult {
        Log.d("GithubRepository", "New query: $query")

        // Get data source factory from the local cache
        val dataSourceFactory = cache.reposByName(query)

        // Construct the boundary callback
        val boundaryCallback = RepoBoundaryCallback(query, service, cache)
        val networkErrors = boundaryCallback.networkErrors

        val data = LivePagedListBuilder(dataSourceFactory, DATABASE_PAGE_SIZE)
            .setBoundaryCallback(boundaryCallback)
            .build()

        return RepoSearchResult(data, networkErrors)
    }

    companion object {
        private const val DATABASE_PAGE_SIZE = 20
    }
}

**3.配置 PagedList,**这里使用 LivePagedListBuilder 来配置,配置的内容可以包括以下内容:

  • 由 PagedList 加载的页面的大小;
  • 加载的距离;
  • 第一次加载时要加载多少项;
  • 是否可以将空项添加到PagedList中,以表示尚未加载的数据。

4. 使用 PagedListAdapter、RecyclerView 将结果显示在 UI 上

class ReposAdapter :
    PagedListAdapter<Repo, androidx.recyclerview.widget.RecyclerView.ViewHolder>(REPO_COMPARATOR) {
    
    ......
    
    companion object {
        private val REPO_COMPARATOR = object : DiffUtil.ItemCallback<Repo>() {
            override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean =
                oldItem.fullName == newItem.fullName

            override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean =
                oldItem == newItem
        }
    }
}

这里的 REPO_COMPARATOR 是 DiffUtil.ItemCallback 的实现类,确定了后台计算数据更新的规则。

**5. 处理RecyclerView 滚动,实现数据的网络更新。**通过 BoundaryCallback 来实现。

class RepoBoundaryCallback(
    private val query: String,
    private val service: GithubService,
    private val cache: GithubLocalCache
) : BoundaryCallback<Repo>() {
    
    override fun onZeroItemsLoaded() {
        requestAndSaveData(query)
    }
    
    override fun onItemAtEndLoaded(itemAtEnd: Repo) {
        requestAndSaveData(query)
    }

    private fun requestAndSaveData(query: String) {
        if (isRequestInProgress) return

        isRequestInProgress = true
        searchRepos(service, query, lastRequestedPage, NETWORK_PAGE_SIZE, { repos ->
            cache.insert(repos) {
                lastRequestedPage++
                isRequestInProgress = false
            }
        }, { error ->
            _networkErrors.postValue(error)
            isRequestInProgress = false
        })
    }
}

创建 BoundaryCallback 的实例,在创建 DataSource 实例时作为参数传入。

示例代码的运行效果:

完整的项目地址 示例代码地址

4. 最后对 Paging Library 进行简单的总结

先简单概括一下如何使用 Paging Library:

  • 首先,要定义 DataSource;
  • 需要的时候,创建 BoundaryCallback;
  • 使用LivePagedListBuilder创建 PagedList 的 LiveData;
  • 将 Adapter 转化为 PagedListAdapter,
  • 最后在 UI 中观察 PagedList 的 LiveData 对象,并将 更新后的PagedList 传给 PagedListAdapter。

我们再来看一下 Paging Library 的各个组成部分是如何系统工作的。

**首先,当 PagedList 创建时,**完成了两个工作:

当 PagedList 创建时,LiveData 会将 PagedList 传给 ViewModel。UI 监听到 PagedList 更新后,从 ViewModel 中取出 PagedList 传给 PagedListAdapter,最后更新在 UI 的 RecyclerView 上。这个过程如图中蓝色空心方块的运动过程。

当 PagedList 创建时,第二个工作是加载第一块数据,如果在 app 首次启动时,DataSource 中还没有数据,这时候会触发 BoundaryCallback.onZeroItemsLoaded() 方法,在我们的示例中,会从网络加载数据,并将这些数据持久化到数据库中。这个过程如图中橙色线的运动过程。

然后,当数据源中有数据后, PagedList 的新实例会被创建,这个实例最终通过 ViewModel 中的 LiveData 传到 PagedListAdapter,然后更新到 RecyclerView 上。这个过程如图中蓝色方块的运动过程。

**最后,当用户滑动屏幕触发加载下一页数据时,**如果数据源中还有可提供的数据时,重复上图中的过程。如果数据源中没有可以提供的数据,会触发 BoundaryCallback.onItemAtEndLoaded() 方法,BoundaryCallback 会从网络请求更多的数据,然后持久化到数据库中,然后根据新加载的数据,重新填充 UI。


至此,Android 架构组件 Paging 就介绍完了,下一篇我们来分析 Android 架构组件 Room 的使用。

更多内容,可以订阅 我的博客


参考链接

Android Paging
应用架构指南

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!