【Android】JetPack Paging Library 分页库手把手教学(绝对靠谱,还学不会打我电话157......)

2,272 阅读15分钟

Jetpack

本文实现效果:

Paging

前言

一些资源

本文前置资源:

纯Database使用Paging(推荐):www.bilibili.com/video/av742…

纯Network使用Paging:(需翻墙,依然推荐):https://www.youtube.com/watch?v=eh4Wq7UrTok&list=PLk7v1Z2rk4hjCQw1RVoYPRdeIzwdz5_Fi

上面这个需要翻墙的视频我放到网盘里了:pan.baidu.com/s/1itD9eOm5…


本文源码地址(Java):gitee.com/littlecurl/…

本文参考源码地址(Kotlin):github.com/cerodriguez…

本文视频教程(我自己录的,没错,我自己录的):还没录,一定会录的 : )

Paging Library视频教程(Google官方, 需要梯子):https://www.youtube.com/watch?v=QVMqCRs0BNA

Paging Library视频教程(Google中国官方,和上面的内容一模一样):www.bilibili.com/video/av350…

Paging Library的Google Code Labs(需要梯子) :https://codelabs.developers.google.com/codelabs/android-paging/index.html

简要说明

纯Database作为数据源和纯Network作为数据源进行分页都非常容易理解,上面也给出了对应的资源地址。

但是,Database + Network 结合使用,就有点让人摸不着头脑了。而且,网上截止2020-01-12,这类的教程还很少,有些都是瞎扯,有些是讲半天废话不知道说的啥,这次,就让我来个终结版吧

这些技术都不是我创造的,我只是学会之后将之传播,从中收获助人之乐,大家加油!

注:全文代码不完整,不要一步一步copy,完整源代码还是自行Github下载。

先看一下官方给出的数据流的鸟瞰图:

image-20200106131955807

上图箭头顺序是我们根据UI上显示的数据,逐层深入得到的。

但是我们开发的时候,应该是倒叙开发的。

也就是以下 6 步:

  1. 编写JavaBean,为获取网络数据Json反序列化做准备(Json 转成 JavaBean 叫反序列化)

  2. 获取网络数据Json并反序列化成JavaBean

  3. JavaBean数据插入本地数据库

  4. 从本地数据库中查询数据

  5. ViewModel中将查询到的数据赋值给LiveData<PagedList<Repo>>

  6. UI(Activity或Fragment)中获得ViewModel来观察LiveData<PagedList<Repo>>当PagedList发生变化时,把观察到的数据送到RecyclerViewAdapter中,即adapter.submitList(repos);

以上 5 步可以分为两段内容:

一、 想办法得到LiveData<PagedList<Repo>>PagedList

二、将PagedList中的数据送给RecyclerViewAdapter进行数据绑定

可以看到,PagedList在这之间起到了承上启下、起承转合、中流砥柱、一夫当关万夫莫开,啥啥啥啥 啥啥啥的作用! : )

接下来就开始吧!

稍等,在开始之前,先引入以下依赖(Java版):

// architecture components:lifecycle、room、paging
implementation "android.arch.lifecycle:extensions:1.1.1"
implementation "android.arch.lifecycle:runtime:1.1.1"
implementation 'androidx.room:room-runtime:2.2.3'
annotationProcessor 'androidx.room:room-compiler:2.2.3'
implementation 'androidx.paging:paging-runtime:2.1.1'

// retrofit、OKhttp
implementation "com.squareup.retrofit2:retrofit:2.4.0"
implementation"com.squareup.retrofit2:converter-gson:2.4.0"
implementation "com.squareup.retrofit2:retrofit-mock:2.4.0"
//noinspection GradleDependency
implementation "com.squareup.okhttp3:logging-interceptor:3.9.0"
// 注解
implementation 'org.jetbrains:annotations-java5:15.0'
// ui
implementation "androidx.recyclerview:recyclerview:1.1.0"
implementation "com.google.android.material:material:1.0.0"

友情提醒:

如果加上阿里云的下载源,下载依赖的速度会起飞🛫

maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' }
maven { url 'http://maven.aliyun.com/nexus/content/repositories/jcenter' }
google()
jcenter()
maven { url "https://jitpack.io" }
mavenCentral()
jcenter{url "http://jcenter.bintray.com/"}

接下来正式开始!

正文

一、编写JavaBean

Android作为前端,JavaBean的格式是参照着后端返回的Json格式来编写的。

如果自己练手,可以从网上搜一些提供Json数据的接口。

比如:

1、本文访问的Github查询仓库的接口:api.github.com/search/repo… 2、WanAndroid首页查询接口:www.wanandroid.com/article/lis…

3、StackOverflow问题查询接口:api.stackexchange.com/2.2/answers…

Tip: 仅用于练手,Do not be evil。

访问第一个接口返回的数据如下

image-20200111134118701

这是第一层,对应的我们可以写出下面这样的JavaBean

image-20200111135204069

上面这段代码有三点需要注意:

  1. 这段我并没有直接给代码,而是一张截图,就证明这段其实可以不写。

    摆在这里只是为了一层一层解析,思路不跳步。

  2. 所有属性都用 public 修饰,这样可以省去Getter、Setter

  3. Item 是爆红的,所以我们接下来要去写Item的JavaBean

接下来我们展开items,来编写具体的item,不过,Item的属性实在是太多了

image-20200111134608159

我们只需要写一些我们关心的数据即可

public class Repo {
    public long id;
    public String name;
    public String full_name;
    public String description;
    public String html_url;
    public int stargazers_count;
    public int forks_count;
    public String language;
}

如果你只需要从网络上获取数据,那么写成上面这样就可以了。

但是,我们是拿到数据之后,还要往本地数据库(Room)中插入的,所以我们要加上对应的注解:

@Entity(tableName = "repos")
public class Repo {
    @PrimaryKey
    @ColumnInfo(name = "id")
    public long id;
    @ColumnInfo(name = "name")
    public String name;
    @ColumnInfo(name = "full_name")
    public String full_name;
    @ColumnInfo(name = "description")
    public String description;
    @ColumnInfo(name = "html_url")
    public String html_url;
    @ColumnInfo(name = "stargazers_count")
    public int stargazers_count;
    @ColumnInfo(name = "forks_count")
    public int forks_count;
    @ColumnInfo(name = "language")
    public String language;
}

以上需要注意三点:

  1. 列名称可以任意,比如驼峰命名、下划线分隔

  2. 属性名称必须和返回的Json格式一致,否则无法反序列化,也就得不到对应的数据

  3. 如果有属性名和Java关键字冲突,比如 private,可以用以下方法解决

    @SerializedName("private")
    @ColumnInfo(name = "private")
    public boolean privatee;
    

    不过,这次我这里并没有用到这个字段。

如果你想要的是更深层的数据,比如ower对象

image-20200111141233918

那么你需要写的是Owner对象的JavaBean,这里不再做演示了。

二、获取网络数据并反序列化成JavaBean

这里介绍Retrofit的使用方法。

官网有一个相对简单的介绍,就是三步走:

  1. 引入依赖
  2. 编写接口Api,指定部分Url
  3. 编写请求类,指定BaseUrl,创建Retrofit对象并调用接口中的请求,然后就可以用它来请求网络了。

0.依赖在前言中我已经给出。

1.接口如下:

public interface GitHubApi {
    
    @GET("search/repositories?sort=stars")
    Call<RepoSearchResponse> searchRepos(
            @Query("q") String query,
            @Query("page") int page,
            @Query("per_page") int itemsPerPage
    );
    
}

上面的代码应该可以直接理解,这是Retrofit的固定语法。

值得注意的一点就是,@GET() 里面部分Url不可以/开头,对应下面请求类中的基地址必须以/结尾,否则报错。

2.请求类如下:

public class GitHubClient {

    private static final String TAG = "GitHubClient";

    private static GitHubClient mInstance;
    private Retrofit retrofit;
    
	// 构造方法
    public GitHubClient() {
        retrofit = new Retrofit.Builder()
                .client(getOkhttpClientBuilder().build())
                .baseUrl(BASE_URL)
                .addConverterFactory(GsonConverterFactory.create())
                .build();
    }

    // 单例模式
    public static synchronized GitHubClient getInstance() {
        if (mInstance == null) {
            mInstance = new GitHubClient();
        }
        return mInstance;
    }

    // 实例化接口
    public GitHubApi getApi() {
        return retrofit.create(GitHubApi.class);
    }

    // 请求网络数据
    public void searchRepos(
            GithubLocalCache cache,
            String query,
            int page,
            int itemsPerPage) {

        Log.d(TAG, "query: " + query + ", page: " + page + ", itemsPerPage: " + itemsPerPage);

        String apiQuery = query + IN_QUALIFIER;

        GitHubClient.getInstance()
                .getApi()
                .searchRepos(apiQuery, page, itemsPerPage)
                .enqueue(new Callback<RepoSearchResponse>() {
                    @Override
                    public void onResponse(Call<RepoSearchResponse> call, Response<RepoSearchResponse> response) {
                        Log.d(TAG, "got a response " + response);
                        if (response.isSuccessful()) {
                            List<Repo> repos = response.body().items;
                            // 成功回调,数据入库
                            OnResponse onSuccess = new OnResponseImpl(cache);
                            onSuccess.onSuccess(repos);
                        } else {
                            // 失败回调,保存异常信息
                            OnResponse onError = new OnResponseImpl();
                            String errorMsg = response.errorBody().toString();
                            onError.onError(errorMsg == null ? "Unknown error" : errorMsg);
                        }
                    }

                    @Override
                    public void onFailure(Call<RepoSearchResponse> call, Throwable t) {
                        Log.d(TAG, "fail to get data");
                    }
                });
    }

}

上面代码做了三件事:

  1. 构造方法初始化retrofit对象 ===> 单例模式,确保运行时全局唯一,避免浪费内存。

  2. 调用retrofit对象的create()方法,传入接口类名,得到一个可以调用接口中方法的方法。

  3. 在 searchRepos() 方法中,

    ① 调用了上面的单例模式方法 getInstance()获得请求类的对象

    ② 继续调用接口类方法getApi()获得接口类实例化

    ③ 继续调用接口中的searchRepos()方法并传入请求参数

    ④ 使用enqueue() 进行异步请求

    ​ (1)如果请求成功,则将得到的response.body().items 传给成功回调方法,去插入数据库

    ​ (2)如过请求失败,则将得到的response.errorBody().toString() 传给失败回调方法,保存异常信息

三、把JavaBean数据插入本地数据库

其中成功回调方法实现如下:

@Override
public void onSuccess(List<Repo> repos) {
    // 本地数据库插入返回值
    cache.insert(repos);
    // 设置当前页 +1,注意这里设置静态值需要使用 类名.静态值
    // 否则使用new对象,然后调用对象的Setter方法是不管用的,
    // 会导致无法加载下一页
    RepoBoundaryCallback.lastRequestedPage = RepoBoundaryCallback.lastRequestedPage + 1;
    RepoBoundaryCallback.isRequestInProgress = false;
    Log.d(TAG, "设置当前页 +1---" + RepoBoundaryCallback.lastRequestedPage);
}

其中插入数据库的方法实现如下:

public void insert(final List<Repo> repos) {
    // Executor
    ioExecutor.execute(new Runnable() {
        @Override
        public void run() {
            try {
                // 插入数据库
                repoDao.insert(repos);
            } catch (Exception e) {
                CRUDApi crudApi = new CRUDApiImpl();
                crudApi.insertFinished("GithubLocalCache: Insert Error");
            }
        }
    });
}

以上代码有两点需要注意

  1. ioExecutor,这里使用到了Executors.newSingleThreadExecutor(),Java的四种线程池的一种---单例线程池。具体细节不再细究,简单一句话总结就是:该线程池会在并发执行时,确保按照任务提交顺序执行(即便是并发,也总会有个先来后到的),如果有线程中途执行失败了,有可能会调取其他线程来继续执行。

  2. repoDao.insert(repos),这里使用到了Dao层的方法。

    @Dao
    public interface RepoDao {
        @Insert(onConflict = OnConflictStrategy.REPLACE)
        public void insert(List<Repo> repos);
    }
    

    我们在写JavaBean时,指定了Repo为Entity,这里,insert()方法传入参数的泛型又是Repo,这样,Room就可以精准的识别表名,从而将数据插入进去。

失败回调方法如下:

@Override
public void onError(String errorMsg) {	
    RepoBoundaryCallback.networkErrors.postValue(errorMsg);
    RepoBoundaryCallback.isRequestInProgress = false;
}

这里的networkErrors最终会被ViewModel拿到,在Activity中进行观察。如果内容改变,就会以Toast方式显示。

现在我们已经完成了获取网络数据,成功时将数据入库。

四、从本地数据库中查询数据

接下来我们就需要去数据库中获取数据了。

也就是实现下面箭头这一步

image-20200106125513281

我们直接来看一下DataSource的源码

public class GithubDataSource {
    private GithubLocalCache cache;
    public LiveData<PagedList<Repo>> itemPagedList;
	
	// 构造方法
    public GithubDataSource(GithubLocalCache cache) {
        this.cache = cache;
    }


    public RepoSearchResult search(String query) {
        DataSource.Factory<Integer, Repo> dataSourceFactory = cache.reposByName(query);

        // 每次查询都创建一个新的边界回调
        RepoBoundaryCallback boundaryCallback = new RepoBoundaryCallback(cache, query);
        LiveData<String> networkErrors = RepoBoundaryCallback.networkErrors;

        PagedList.Config config = new PagedList.Config.Builder()
                .setInitialLoadSizeHint(2 * DATABASE_PAGE_SIZE)  // 第一次加载多少数据,必须是分页加载数量的倍数
                .setPageSize(DATABASE_PAGE_SIZE)  // 每次加载多少数据
                .setMaxSize(Integer.MAX_VALUE)  // Maximum number of items to keep in memory
                // MaxSize必须满足 MaxSize >= PageSize + 2 * PrefetchDistance
                .setPrefetchDistance(5) // 距底部还有几条数据时,加载下一页数据
                .setEnablePlaceholders(false) // 是否启用占位符,若为true,则视为固定数量的item,比如PositionalDataSource
                .build();
        // Get the paged list
        itemPagedList = new LivePagedListBuilder(dataSourceFactory, config) // 从本地数据库中拿数据
                .setBoundaryCallback(boundaryCallback) // 如果触发边界回调,再从网络中获取
                .build();
        // Get the network errors exposed by the boundary callback
        return new RepoSearchResult(itemPagedList, networkErrors);
    }
}

上面代码主要就两个方法:构造方法和查询数据方法

因为获取数据库需要Context参数,所以我们会在Activity中获取数据库对象,然后再将数据库对象传递给需要用到的类。

查询数据方法里面,先是去本地查询了一下 cache.reposByName(query); 返回一个DataSource工厂,然后根据工厂及每次从数据库中查询的数据量去创建一个PagedList,将PagedList和网络请求异常组装成一个对象进行统一返回。

另外,这里使用了建造者模式建造了一个PagedList.Config对象,用来配置分页加载的一些参数。

三种分页的区分

如果是纯从DataBase中获取数据,那么就没有前面的成功、失败回调了,这里也不需要RepoBoundaryCallback,直接返回一个PagedList即可

如果是纯从Network中获取数据,那么就没有前面的**@Dao**、@Entity了,这里也不需要RepoBoundaryCallback,Android提供了三种DataSourcePositionalDataSource、ItemKeyedDataSource、PageKeyedDataSource,直接继承它们中的一个去实现对应的方法即可。

但这是DataBase+Network,这里的DataSource不需要继承Android提供的三种DataSource,也就不需要重写它们的方法了。但是,这里需要一个PagedList的边界回调。当RecyclerView从PagedList中获取数据触发到List边界时,就会触发这个回调。

public abstract static class BoundaryCallback<T> {

    public void onZeroItemsLoaded() {}

    public void onItemAtFrontLoaded(@NonNull T itemAtFront) {}

    public void onItemAtEndLoaded(@NonNull T itemAtEnd) {}
}

这个边界回调需要实现以上两个个方法,onItemAtFrontLoaded()在这里没有使用,具体实现如下:

public class RepoBoundaryCallback extends PagedList.BoundaryCallback<Repo> {
    private String query;

    private static final String TAG = "RepoBoundaryCallback";

    // 保存最新的请求页数,当请求成功时,递增 1 
    public static int lastRequestedPage = 1;
    // 避免同一时刻触发多次请求
    public static boolean isRequestInProgress = false;

    public static MutableLiveData<String> networkErrors = new MutableLiveData<String>();

    private GithubLocalCache cache;

    public RepoBoundaryCallback(GithubLocalCache cache, String query) {
        this.cache = cache;
        this.query = query;
    }

    /*
        一开始,PagedList为空,RecyclerView想要渲染数据时,触发此方法
        相当于初始化数据,获取第一页数据
     */
    @Override
    public void onZeroItemsLoaded() {
        Log.d("RepoBoundaryCallback", "onZeroItemsLoaded");
        requestAndSaveData(query);
    }

    /*
        RecyclerView渲染PagedList中最后一项数据时(不算prefetch),触发此方法
        相当于上拉加载
    */
    @Override
    public void onItemAtEndLoaded(Repo itemAtEnd) {
        Log.d(TAG, "onItemAtEndLoaded");
        Log.d(TAG, "query:" + query);
        requestAndSaveData(query);
    }
	
    // 
    private void requestAndSaveData(String query) {
		// 避免同一时刻多次请求
        if (isRequestInProgress) return;
        isRequestInProgress = true;
        // retrofit对象实例化,请求数据并插入到数据库中
        GitHubClient.getInstance().searchRepos(
                cache,
                query,
                lastRequestedPage,
                NETWORK_PAGE_SIZE
        );
        Log.d(TAG, "NETWORK_PAGE_SIZE: " + NETWORK_PAGE_SIZE);
    }

}

上面代码主要就是两个方法

onZeroItemsLoaded() 初始化数据 和onItemAtEndLoaded() 加载更多数据

这里和DataSource是整个DB+Network代码流程中最关键的两环:分页的核心逻辑,因为俩之前,是网络请求和数据库插入,都是在准备数据,终于在DataSource这一环节通过与BoundaryCallback的配合,准备好了数据:PagedList,接下来,就要准备和UI打交道了。

五、在ViewModel中把查询到的数据赋值给LiveData<PagedList<Repo>>

我们前面通过网络请求 ===> 插入数据库 ===> 查询数据库 终于得到了PagedList,即,需要分页展示的数据集合。

众所周知,ViewModel是JetPack中用于管理数据的地方。

所以,我们要在ViewModel中获取一份PagedList的数据,也就是下图所示的虚线箭头--->。

直接看ViewModel的代码

public class SearchRepositoriesViewModel extends AndroidViewModel {
    private GithubDataSource githubDataSource;
    private MutableLiveData<String> queryLiveData = new MutableLiveData<String>();

    // 构造方法
    public SearchRepositoriesViewModel(@NonNull Application application, GithubDataSource githubDataSource) {
        super(application);
        this.githubDataSource = githubDataSource;
    }
    
    // SearchRepositoriesActivity.onCreate()查询方法
    public void searchRepo(String queryString) {
        queryLiveData.postValue(queryString);
    }
    
    // 使用 queryLiveData
    private LiveData<RepoSearchResult> repoResult = Transformations.map(
            queryLiveData,
            it -> githubDataSource.search(it) // 调用DataSource中的方法
    );

    // 构造 repoResult
    public LiveData<PagedList<Repo>> repos = Transformations.switchMap(
            repoResult,
            it -> it.data
    );
    public LiveData<String> networkErrors = Transformations.switchMap(
            repoResult,
            it -> it.networkErrors
    );

	// SearchRepositoriesActivity.onSaveInstanceState()查询方法
    public String lastQueryValue() {
        return queryLiveData.getValue();
    }

一个构造方法

因为继承自AndroidViewModel,所以必须传个Application参数

因为要调用DataSource中的search()方法,获取到RepoSearchResult,再将其映射为PagedList和networkErrors,所以需要传递一个githubDataSource参数。

一个初始查询方法

searchRepo(String queryString) 接收来自Actvity的查询参数,将推送到queryLiveData中。

三个map方法

第一个map,从queryLiveData中取出String,调用githubDataSource.search(it),返回查询数据。

其他两个switchMap,分别取出刚刚查询出来的数据的PagedList和networkErrors。

一个Activity恢复显示时调用的查询方法。

上面有两点迷惑行为我还没搞懂

1、query为啥要转为MutableLiveData,我现在还没看懂,明明普通字符串就可也啊,为啥要用MutableLiveData,就是为了装X?还是说,ViewModel的要求?

2、map和switchMap,看来源码的注释,依然没看懂:)

虽然很迷惑,但大致上还是能看懂这些代码的。

继续往下看。

六、UI(Activity或Fragment)中获得ViewModel来观察LiveData<PagedList>当PagedList发生变化时,把观察到的数据送到RecyclerView的Adapter中,即adapter.submitList(repos);

我们来完成最后一部分UI拼图

这一部分最主要的就是一个PagedListAdapter了。

Adapter代码其实都是固定套路,没啥看的,唯一有看头的就是ViewHolder绑定数据了。

我直接贴一下代码

public class RepoViewHolder extends RecyclerView.ViewHolder {
    private TextView name;
    private TextView description;
    private TextView stars;
    private TextView language;
    private TextView forks;
    private Repo repo;
    public RepoViewHolder(@NonNull View view) {
        super(view);
        name = view.findViewById(R.id.repo_name);
        description = view.findViewById(R.id.repo_description);
        stars = view.findViewById(R.id.repo_stars);
        language = view.findViewById(R.id.repo_language);
        forks = view.findViewById(R.id.repo_forks);
        view.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(repo.html_url));
                view.getContext().startActivity(intent);
            }
        });
    }
    public void bind(Repo repo){
        if (repo == null) {
            Resources resources = itemView.getResources();
            name.setText(resources.getString(R.string.loading));
            description.setVisibility(View.GONE);
            language.setVisibility(View.GONE);
            stars.setText(resources.getString(R.string.unknown));
            forks.setText(resources.getString(R.string.unknown));
        } else {
            showRepoData(repo);
        }
    }

        private void showRepoData (Repo repo){
        this.repo = repo;
        name.setText(repo.full_name);

        // if the description is missing, hide the TextView
        int descriptionVisibility = View.GONE;
        if (repo.description != null) {
            description.setText(repo.description);
            descriptionVisibility = View.VISIBLE;
        }
        description.setVisibility(descriptionVisibility);

        stars.setText(String.valueOf(repo.stargazers_count));
        forks.setText(String.valueOf(repo.forks_count));

        // if the language is missing, hide the label and the value
        int languageVisibility = View.GONE;
        if (!TextUtils.isEmpty(repo.language)) {
            Resources resources = this.itemView.getContext().getResources();
            language.setText(resources.getString(R.string.language, repo.language));
            languageVisibility = View.VISIBLE;
        }
        language.setVisibility(languageVisibility);
    }

        public static RepoViewHolder create (ViewGroup parent){

            View view = LayoutInflater.from(parent.getContext())
                    .inflate(R.layout.repo_view_item, parent, false);
            return new RepoViewHolder(view);
        }
}

完结!

结尾

本文源码资源gitee.com/littlecurl/…

我本以为这篇能写一万字呢,结果还不到4500

是不是写的有点着急了?

说一个Bug

其实这样尽管已经实现了分页查询,但还是有个Bug,就是当你第一次启动程序,查询了几页之后,比如查询了5页内容,这时候,你退出了程序。再次进入程序时,首先会去Database里面查询,当查询到最下方时,因为没有记录当前已经查到第多少页了,所以它会从第 1 页开始查询,直到查到第5页之后,你这里才能拿到更多数据。

关于这个问题,有人说:每次查询,都把最新页数记录下来,比如SharedPreference,或者记录到数据库中。等下次触发到边界时再查出来这个数。

这是目前大多数人的解决方案,但其实也不是很优雅,因为,在这期间,你中间的数据有可能增删,那样,你上次记录的那个页码有可能就不对了。

另一种不是很优雅的解决方案是可以通过LayoutManager获得当前所有Item的数量,然后除以你每页显示的数量,来判断该去查询第几页了。

我并没有在Demo中解决这个Bug!因为两个解决方案都不是很优雅,所以,优雅的解决这个问题的任务,留给你了,思考思考,或许能想出来类似 P = NP的问题,加油!

关于这个问题,网上讨论还是很多的,典型的两个如下:

StackOverflow:stackoverflow.com/questions/5…

Github Issue:github.com/android/arc…

其中**ChrisCraik**是Android Paging Library的官方维护人之一(竟然能看到活人说话:)

由于

再来个简短的总结吧。

总结:Paging Core elements

  • PagedList

    1. Collection
    2. Loads data in pages
    3. Asynchronous data loading
  • DataSource

    1. Base Class for loading snapshots of data

    2. Can be backed by:

      ① Network

      ② Database

      ③ File

      ④ Any other source

Jetpack