Android Jetpack架构组件-Paging介绍及实践

Android 列表分页组件Paging的设计与实现

先通过官方Paging示例开始,通过Paging实现加载Room数据库中的联系人列表简单介绍jetpack中的Paging的使用

数据库为Room,于是先定义的数据查询Dao,如下所示:

@Dao
interface CheeseDao {
    @Query("select * from cheese order by name ")
    fun findAllCheese(): DataSource.Factory  //返回的为    DataSource.Factory对象
}

可以看到Room数据库直接返回的为DataSource.Factory而不是livedata,后文会提出来,因为它也可构建出一个可观察的对象LiveData数据。

接下来可查看ViewModel和Activity中的实现:

class MainActivity : AppCompatActivity() {
  
    //负责数据的类的加载
    private val viewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // Create adapter for the RecyclerView
        val adapter = CheeseAdapter()
        cheeseList.adapter = adapter
        // Subscribe the adapter to the ViewModel, so the items in the adapter are refreshed
        // 当viewModel中的allCheeses发生变化后会调用
        viewModel.allCheeses.observe(this, Observer {
            mAdapter.submitList(it)
        })
        //...
    }
}

在来看看Paging中为RecycleView准备的CheeseAdapter

class CheeseAdapter :
    PagedListAdapter(object : DiffUtil.ItemCallback() {
        override fun areItemsTheSame(oldItem: Cheese, newItem: Cheese): Boolean =
            oldItem.id == newItem.id
        override fun areContentsTheSame(oldItem: Cheese, newItem: Cheese): Boolean =
            oldItem == newItem
    }) {
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CheeseViewHolder =
        CheeseViewHolder(parent)
    
    override fun onBindViewHolder(holder: CheeseViewHolder, position: Int) {
        holder.bindData(getItem(position))
    }
}

这里使用到了PagedListAdapter 需要一个DiffUtil.ItemCallback类型参数,它是官方基于RecyclerView.AdapterAsyncListDiffer封装类,其内创建了AsyncListDiffer的示例,以便在后台线程中使用DiffUtil计算新旧数据集的差异,从而节省Item更新的性能。

viewModel中负责处理数据,则可以去到CheeseViewModel中,查看数据是如何加载,可以看到dao.findAllCheese()是DataSource.Factory对象。通过toLiveData(),传入Paging所需要的Config,即可完成数据的转化和查找。

class CheeseViewModel(app: Application) : AndroidViewModel(app) {
    val dao = CheeseDb.get(app).cheeseDao()
        //LiveData类型数据
    val allCheese = dao.findAllCheese().toLiveData(
        Config(
            pageSize = 30,
            enablePlaceholders = true,
            maxSize = 200
        )
    )
}

以上:则一个Paging最简单的列表完成,可以看到用的如下几个核心类:DataSource.FactoryPagedListAdapterDiffUtil.ItemCallbackPagedListBuilderDataSourceRoom数据库的使用

接下来通过单独介绍这几种组件和关系,来探究Paging框架

一、分页组件的简介

1.核心类 PagedList

上文提到,一个普通的RecyclerView展示的是一个列表的数据,比如List,但在列表分页的需求中,列表局部更新或者差分异比对,显然一个List不太够用了。

为此,Google设计出了一个新的角色PagedList,顾名思义,该角色的意义就是 分页列表数据的容器

既然有了List,为什么需要额外设计这样一个PagedList的数据结构?本质原因在于加载分页数据的操作是异步的 ,因此定义PagedList的第二个作用是 对分页数据的异步加载 ,这个我们后文再提。

所以ViewModel可以定义成这样,因为PagedList也作为列表数据的容器(就像List一样):

class viewModel :viewModel(){
    //before 
    //val users :LiveData> = dao.findAllUsers()
    
    //after
     val users:LiveData> = dao.findAllUsers()
}

ViewModel中,开发者可以轻易通过对users进行订阅以响应分页数据的更新,这个LiveData的可观察者是通过Room组件创建的,我们来看一下我们的dao:

@Dao
interface UserDao {
  // 注意,这里 LiveData> 改成了 LiveData>  
  @Query("SELECT * FROM user")
  fun queryUsers(): LiveData>  
}

乍得一看似乎理所当然,但实际需求中有一个问题,这里的定义是模糊不清的——对于分页数据而言,不同的业务场景,所需要的相关配置是不同的。那么什么是分页相关配置呢?

最直接的一点是每页数据的加载数量PageSize,不同的项目都会自行规定每页数据量的大小,一页请求15个数据还是20个数据?所以接下来DataSourcePagedListBuilder对象,通过简单的配置将数据源和分页Page的相关属性。

2.数据源:DataSource及其工厂

回答这个问题之前,我们还需要定义一个角色,用来为PagedList容器提供分页数据,那就是数据源DataSource

什么是DataSource呢?可以理解为 数据库数据 或者是 服务端数据 的一个快照,而不应该是数据库数据或者是服务端数据

每当Paging被告知需要更多的数据的时候,数据源DataSource就会将当前的快照对应的索引的数据交给PagedList处理

但是需要构建一个新的PagedList的时候,比如数据已经失效,DataSource中旧的数据就有意义了,因为DataSource需要被重置

在代码中,这意味着新的DataSource对象被创建,因此,我们需要提供的不是DataSource,而是提供DataSource的工厂(DataSouce.Factory) 这就是为什么查找数据库的时候,返回的事DataSouce.Factory而不是DataSouce>或者是LiveData>的原因

为什么要提供DataSource.Factory而不是一个DataSource? 复用这个DataSource不可以吗,当然可以,但是将DataSource设置为immutable(不可变)会避免更多的未知因素。

接下来如何修改方法中放回的类型,如下所示:

@Dao
interface UserDao{
        @Query("select * from user")
        fun findAllUser():DataSource.Factory
}

返回的是一个数据源的提供者DataSource.Factory,页面初始化时,会通过工厂方法创建一个新的DataSource,这之后对应会创建一个新的PagedList,每当PagedList想要获取下一页的数据,数据源都会根据请求索引进行数据的提供。

当数据失效时,DataSource.Factory会再次创建一个新的DataSource,其内部包含了最新的数据快照(本案例中代表着数据库中的最新数据),随后创建一个新的PagedList,并从DataSource中取最新的数据进行展示——当然,这之后的分页流程都是相同的,无需再次复述。

引用一幅图用于描述三者之间的关系,读者可参考上述文字和图片加以理解

截屏2020-03-2215.59.30.png

3.串联两者:PagedListBuilder

分页中的相关业务配置,如每次加载多少条数据等等

现在在Dao中接口的返回值已经是DataSource.Factory,而ViewModel中的成员被观察者则是LiveData>类型,那么如何将数据源的工厂DataSource.Factory,和LiveData进行串联?

因此需要定义一个新的角色PagedListBuilder,开发者将 数据源工厂相关配置统一交给PagedListBuilder,即可生成对应的LiveData>:

class MyViewModel(val dao: UserDao) : ViewModel() {
  val users: LiveData>

  init {
    // 1.创建DataSource.Factory
    val factory: DataSource.Factory = dao.queryUsers()

    // 2.通过LivePagedListBuilder配置工厂和pageSize, 对users进行实例化
    //  users = LivePagedListBuilder(factory, config).build()
    // 也可以是具体的config对象,定制更多的配置参数
    users = LivePagedListBuilder(factory, 30).build()
  }
}

如代码所示:在viewmodel中先通过dao获取到DataSource.Factory,工厂创建数据源DataSource,后者为PagedList提供列表所需要的数据;此外,另外一个Int类型的参数则制定每页数据加载的数量,这里指定数量为30

所以在viewmodel中创建了一个LiveData> 的可观察对象,则在Actiivty中的代码如下所示:

class MyActivity : Activity {
  val myViewModel: MyViewModel
  // 1.这里我们使用PagedListAdapter
  val adapter: PagedListAdapter

  fun onCreate(bundle: Bundle?) {
    // 2.在Activity中对LiveData进行订阅
    myViewModel.users.observe(this) {
      // 3.每当数据更新,计算新旧数据集的差异,对列表进行更新
      adapter.submitList(it)
    }
  }    
}

4.更多可选的配置:PagedList.Config

目前介绍中,分页的功能大致已经介绍完成,但是这些在现实开发中往往不够,因此,设计者额外定义了更复杂的数据结构PagedList.Config,以描述更细节化的配置参数

// after
val config = PagedList.Config.Builder()
      .setPageSize(15)              // 分页加载的数量
      .setInitialLoadSizeHint(30)   // 初次加载的数量
      .setPrefetchDistance(10)      // 预取数据的距离
      .setEnablePlaceholders(false) // 是否启用占位符
      .build()

// API发生了改变
val users: LiveData> = LivePagedListBuilder(factory, config).build()

4.1.分页数量:PageSize

最易理解的配置,分页请求数据时,开发者总是需要定义每页加载数据的数量。

4.2.初始加载数量:InitialLoadSizeHint

定义首次加载时要加载的Item数量。

此值通常大于PageSize,因此在初始化列表时,该配置可以使得加载的数据保证屏幕可以小范围的滚动。

如果未设置,则默认为PageSize的三倍。

4.3.预取距离:PrefetchDistance

顾名思义,该参数配置定义了列表当距离加载边缘多远时进行分页的请求,默认大小为PageSize——即距离底部还有一页数据时,开启下一页的数据加载。

若该参数配置为0,则表示除非明确要求,否则不会加载任何数据,通常不建议这样做,因为这将导致用户在滚动屏幕时看到占位符或列表的末尾。

4.4.是否启用占位符:PlaceholderEnabled

该配置项需要传入一个boolean值以决定列表是否开启placeholder(占位符),在知道DataSource知道总数的情况下,设置为true,则可实现骨架屏的效果

4.5 更多观察者类型的配置

在本文的示例中,我们建立了一个LiveData>的可观察者对象供用户响应数据的更新,实际上组件的设计应该面向提供对更多优秀异步库的支持,比如RxJava

因此,和LivePagedListBuilder一样,设计者还提供了RxPagedListBuilder,通过DataSource数据源和PagedList.Config以构建一个对应的Observable:

// LiveData support
val users: LiveData> = LivePagedListBuilder(factory, config).build()

// RxJava support
val users: Observable> = RxPagedListBuilder(factory, config).buildObservable()

二、DataSource数据源简介

ItemKeyedDataSource, PageKeyedDataSource, PositionalDataSource

Base class for loading pages of snapshot data into a PagedList.

DataSource is queried to load pages of content into a PagedList. A PagedList can grow as it loads more data, but the data loaded cannot be updated. If the underlying data set is modified, a new PagedList / DataSource pair must be created to represent the new data.

用于将快照数据页加载到PagedList的基类。

查询数据源以将内容页加载到PagedList中。页面列表可以随着加载更多数据而增长,但无法更新加载的数据。如果修改了基础数据集,则必须创建一个新的pagelist/DataSource对来表示新数据。

Paging分页组件的设计中,DataSource是一个非常重要的模块。顾名思义,DataSource中的Key对应数据加载的条件,Value对应数据集的实际类型, 针对不同场景,Paging的设计者提供了三种不同类型的DataSource抽象类:

  • PositionalDataSource
  • ItemKeyedDataSource
  • PageKeyedDataSource

接下来我们分别对其进行简单的介绍。

1.PositionalDataSource

PositionalDataSource是最简单的DataSource类型,顾名思义,其通过数据所处当前数据集快照的位置(position)提供数据。

PositionalDataSource适用于 目标数据总数固定,通过特定的位置加载数据,这里KeyInteger类型的位置信息,并且被内置固定在了PositionalDataSource类中,T即数据的类型。

最容易理解的例子就是本文的联系人列表,其所有的数据都来自本地的数据库,这意味着,数据的总数是固定的,我们总是可以根据当前条目的position映射到DataSource中对应的一个数据。

PositionalDataSource也正是Room幕后实现的功能,使用Room为什么可以避免DataSource的配置,通过dao中的接口就能返回一个DataSource.Factory

来看Room组件配置的dao对应编译期生成的源码:

// 1.Room自动生成了 DataSource.Factory
@Override
 public DataSource.Factory getAllStudent() {
   // 2.工厂函数提供了PositionalDataSource
   return new DataSource.Factory() {
     @Override
     public PositionalDataSource create() {
       return new PositionalDataSource(__db, _statement, false , "Student") {
         // ...
       };
     }
   };
 }

2.ItemKeyedDataSource

ItemKeyedDataSource适用于目标数据的加载依赖特定条目的信息,比如需要根据第N项的信息加载第N+1项的数据,传参中需要传入第N项的某些信息时。

使用场景:如QQ或者wechat中的聊天记录

3.PageKeyedDataSource

这也是最常用的DataSource,更多的用于网络请求API中,服务器返回的数据中都会包含一个String类型类似nextPage的字段,以表示当前页数据的下一页数据的接口(比如GithubAPI),这种分页数据加载的方式正是PageKeyedDataSource的拿手好戏。

这是日常开发中用到最多的DataSource类型,和ItemKeyedDataSource不同的是,前者的数据检索关系是单个数据与单个数据之间的,后者则是每一页数据和每一页数据之间的。

同样拿联系人列表举例,这种分页加载方式是按照页码进行数据加载的,比如一次请求15条数据,服务器返回数据列表的同时会返回下一页数据的url(或者页码),借助该参数请求下一页数据成功后,服务器又回返回下下一页的url,以此类推。

总的来说,DataSource针对不同种数据分页的加载策略提供了不同种的抽象类以方便开发者调用,很多情况下,同样的业务使用不同的DataSource都能够实现,开发者按需取用即可。

三、通过Paging加载网络数据列表

通过以上,相信读者能明白Paging中的核心类的认识和作用,接下来,通过Paging加载一个简单的网络列表,具体的实现自定义DataSource和Repository等,更加深刻的理解Paging框架。

请求地址为:https://www.wanandroid.com/article/list/0/json (感谢玩Android)

效果图如下图所示:

截屏2020-03-2217.04.00.png
  • Activity中的代码如下
class NetPagingActivity : AppCompatActivity() {

    lateinit var mAdapter: ArticleAdapter
    //加载数据使用的ViewModel对象
    val viewModel: ArticleViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_second)
        //为RecycleView设置的adapter
        mAdapter = ArticleAdapter()

        rv_as_article.layoutManager = LinearLayoutManager(
            this, LinearLayoutManager.VERTICAL, false
        )
        rv_as_article.adapter = mAdapter
        getData()
    }
    //请求数据,并更新列表
    private fun getData() {
        viewModel.data.observe(this, Observer {
            mAdapter.submitList(it)
        })
    }
}
  • ArticleViewModel
    ViewModel很简单,通过NetRepository().getData()获取DataSource中的可观察数据
class ArticleViewModel : ViewModel() {
    val data = NetRepository().getData()
}
  • NetRepository仓库
class NetRepository {

    var pageSize = 20
    lateinit var article: LiveData>
    
    fun getData(): LiveData> {
        val dataSourceFactory = NetDataSourceFactory()
        article = dataSourceFactory.toLiveData(
            config = Config(
                pageSize = pageSize,
                enablePlaceholders = false,
                initialLoadSizeHint = pageSize * 2
            )
        )
        return article
    }
}
  • NetDataSourceFactory
    通过继承DataSource.Factory.重写onCreate()方法,即构建出一个 DataSource对象
class NetDataSourceFactory() : DataSource.Factory() {
    val sourceLiveData = MutableLiveData()
    override fun create(): DataSource {
        //NetDataSource为具体加载服务器数据的快照
        val source = NetDataSource()
        sourceLiveData.postValue(source)
        return source
    }
}
  • NetDataSource
    通过继承PageKeyedDataSource,因为请求的列表是根据nextPage来定位查找,所以选中PageKeyedDataSource。
class NetDataSource : PageKeyedDataSource() {

    var pageNo = 0

    @SuppressLint("CheckResult")
    override fun loadInitial(
        params: LoadInitialParams,
        callback: LoadInitialCallback
    ) {
        RedditApi.create().getArticles(pageNo)
            .subscribeOn(Schedulers.io())
            .subscribe {
                it.data?.datas?.let { it1 ->
                    callback.onResult(it1, pageNo, it.data?.curPage)
                }
                pageNo = it.data?.curPage!!
            }
    }

    @SuppressLint("CheckResult")
    override fun loadAfter(params: LoadParams, callback: LoadCallback) {
        RedditApi.create().getArticles(pageNo)
            .subscribeOn(Schedulers.io())
            .subscribe {
                callback.onResult(it.data?.datas!!, it.data?.curPage)
                pageNo = it.data?.curPage!!
            }
    }

    override fun loadBefore(params: LoadParams, callback: LoadCallback) {

    }
}
  • ArticleAdapter
    通过继承子基于RecycleView的PagedListAdapter
class ArticleAdapter : PagedListAdapter(diffCallback) {

    companion object {
        val diffCallback = object : DiffUtil.ItemCallback
() { override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean = oldItem == newItem override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean = oldItem.id == newItem.id } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder { return ArticleViewHolder(parent) } override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) { holder.bindData(getItem(position)) } }

至此,Paging框架已介绍完成,待后续更新Jetpack更多的组件!

文章中的所有示例代码已上传至github:https://github.com/OnexZgj/Jetpack_Component

你可能感兴趣的:(Android Jetpack架构组件-Paging介绍及实践)