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
接下来可查看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.ItemCallbackRecyclerView.Adapter
的AsyncListDiffer
封装类,其内创建了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.Factory 、 PagedListAdapter、 DiffUtil.ItemCallback、PagedListBuilder、DataSource和Room数据库的使用
接下来通过单独介绍这几种组件和关系,来探究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个数据?所以接下来DataSource
和PagedListBuilder
对象,通过简单的配置将数据源和分页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
中取最新的数据进行展示——当然,这之后的分页流程都是相同的,无需再次复述。
引用一幅图用于描述三者之间的关系,读者可参考上述文字和图片加以理解
3.串联两者:PagedListBuilder
分页中的相关业务配置,如每次加载多少条数据等等
现在在Dao中接口的返回值已经是DataSource.Factory,而ViewModel中的成员被观察者则是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
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
适用于 目标数据总数固定,通过特定的位置加载数据,这里Key
是Integer
类型的位置信息,并且被内置固定在了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
的字段,以表示当前页数据的下一页数据的接口(比如Github
的API
),这种分页数据加载的方式正是PageKeyedDataSource
的拿手好戏。
这是日常开发中用到最多的DataSource
类型,和ItemKeyedDataSource
不同的是,前者的数据检索关系是单个数据与单个数据之间的,后者则是每一页数据和每一页数据之间的。
同样拿联系人列表举例,这种分页加载方式是按照页码进行数据加载的,比如一次请求15条数据,服务器返回数据列表的同时会返回下一页数据的url
(或者页码),借助该参数请求下一页数据成功后,服务器又回返回下下一页的url
,以此类推。
总的来说,DataSource
针对不同种数据分页的加载策略提供了不同种的抽象类以方便开发者调用,很多情况下,同样的业务使用不同的DataSource
都能够实现,开发者按需取用即可。
三、通过Paging加载网络数据列表
通过以上,相信读者能明白Paging中的核心类的认识和作用,接下来,通过Paging加载一个简单的网络列表,具体的实现自定义DataSource和Repository等,更加深刻的理解Paging框架。
请求地址为:https://www.wanandroid.com/article/list/0/json (感谢玩Android)
效果图如下图所示:
- 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