-
Notifications
You must be signed in to change notification settings - Fork 126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
反思|Android 列表分页组件Paging的设计与实现:系统概述 #30
Comments
三、工作流程原理概述
接下来,笔者将针对 为了便于理解,笔者将整个流程拆分为三个步骤,并为每个步骤绘制对应的一张流程图,这三个步骤分别是:
1.初次创建流程如图所示,我们定义了
当 这时候 这没有任何意义,因此我们更希望 现在, 2.UI渲染和分页加载流程通过内部线程的切换, 读者应该有印象,在上文的示例代码中, 当用户尝试对屏幕中的列表进行滚动时,我们接收到了需要加载更多数据的信号,这时, 3.刷新数据源流程当数据发生了更新, 正如前文所说,数据是动态的, 假设用户通过操作添加了一个联系人,这时数据库中的数据集发生了更新。 因此,这时屏幕中 因此, 在创建新的 通过 和初次的数据渲染不同,这一次我们使用到了 四、DataSource数据源简介
接下来我们分别对其进行简单的介绍。
1.PositionalDataSource
最容易理解的例子就是本文的联系人列表,其所有的数据都来自本地的数据库,这意味着,数据的总数是固定的,我们总是可以根据当前条目的
来看 // 1.Room自动生成了 DataSource.Factory
@Override
public DataSource.Factory<Integer, Student> getAllStudent() {
// 2.工厂函数提供了PositionalDataSource
return new DataSource.Factory<Integer, Student>() {
@Override
public PositionalDataSource<Student> create() {
return new PositionalDataSource<Student>(__db, _statement, false , "Student") {
// ...
};
}
};
} 2.ItemKeyedDataSource
同样拿联系人列表举例,另外的一种分页加载方式是通过上一个联系人的 3.PageKeyedDataSource更多的网络请求 这是日常开发中用到最多的 同样拿联系人列表举例,这种分页加载方式是按照页码进行数据加载的,比如一次请求15条数据,服务器返回数据列表的同时会返回下一页数据的 总的来说, 五、最佳实践现在读者对多种不同的数据源
回答这个问题,需要先思考另外一个问题:
1.优势读者认真思考可得,
看起来 主要原因是开发成本——本地缓存的搭建总是需要额外的代码,不仅如此,更重要的原因是,数据交互的复杂性也会导致额外的开发成本。 2.复杂的交互模型为什么说 让我们回到本文的 联系人列表 的示例中,这个示例中,所有联系人数据都来自 本地缓存,因此读者可以很轻易的构建出该功能的整体结构: 如图所示, 那么,当数据的来源不唯一时——即 我们来看看常规的实现方案的数据模型: 如图所示, 乍得一看,这种方案似乎并没有什么问题,实际上却有两个非常大的弊端: 2.1 业务并非这么简单首先,通过一个 实际上,在某些业务场景下,服务器的连接状态可以是更为复杂的,比如接收到了部分的数据包?比如某些情况下网络请求错误,这时候是否需要重新展示本地缓存? 若涉及到网络请求的重试则更复杂,成功展示网络数据,再次失败展示缓存——业务越来越复杂,我们甚至会逐渐沉浸其中无法自拔,最终醒悟,这种数据的交互模型完全不够用了 。 2.2 无用的本地缓存另外一个很明显的弊端则是,当网络连接状态良好的时候,用户看到的数据总是服务器返回的数据。 这种情况下,请求的数据再次存入本地缓存似乎毫无意义,因为网络环境的通畅, 3.使用单一数据源使用 单一数据源 (
其思路是: 这似乎无法满足上文中的需求?读者认真思考可知,其实是没问题的,当网络连接发生故障时,这时向服务端请求数据失败,并不会更新
4.分页列表的最佳实践现在我们理解了 单一数据源 的好处,该方案在分页组件中也同样适用,我们唯一需要实现的是,如何主动触发服务端数据的请求? 这是当然的,因为 针对 另外一种方式则和
class MyBoundaryCallback(
val database : MyLocalCache
val apiService: ApiService
) : PagedList.BoundaryCallback<User>() {
override fun onItemAtEndLoaded(itemAtEnd: User) {
// 请求网络数据,并更新到数据库中
requestAndAppendData(apiService, database, itemAtEnd)
}
}
5.更多优势通过 不仅仅是分页列表,这种方案使得所有列表的 状态管理 的更加容易,笔者为此撰写了另外一篇文章去阐述它,篇幅所限,本文不进行展开,有兴趣的读者可以阅读。 六、总结本文对 首先,它支持 其次, 第三, 更多 & 参考再次重申,强烈建议 读者将本文作为学习 ——是因为本文的篇幅较长吗?(1w字的确...)不止如此,本文尝试对 此外,本文附带一些学习资料,供读者参考: 1.参考视频本文的大纲来源于 2.参考文章其实也就是笔者去年写的几篇关于
关于我Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 Github。 如果您觉得文章还差了那么点东西,也请通过关注督促我写出更好的文章——万一哪天我进步了呢? |
反思|Android 列表分页组件Paging的设计与实现:系统概述
前言
本文将对
Paging
分页组件的设计和实现进行一个系统整体的概述,强烈建议 读者将本文作为学习Paging
阅读优先级最高的文章,所有其它的Paging
中文博客阅读优先级都应该靠后。本文篇幅 较长,整体结构思维导图如下:
一、起源
手机应用中,列表是常见的界面构成元素,而对于Android开发者而言,
RecyclerView
是实现列表的不二选择。在正式讨论
Paging
和列表分页功能之前,我们首先看看对于一个普通的列表,开发者如何通过代码对其进行建模:如图所示,针对这样一个简单 联系人界面 的建模,我们引出3个重要的层级:
1.服务端组件、数据库、内存
为什么说 服务端组件、数据库 以及 内存 是非常重要的三个层级呢?
首先,开发者为当前页面创建了一个
ViewModel
,并通过成员变量在 内存 中持有了一组联系人数据,因为ViewModel
组件的原因,即使页面配置发生了改变(比如屏幕的旋转),数据依然会被保留下来。而 数据库 的作用则保证了
App
即使在离线环境下,用户依然可以看到一定的内容——显然对于上图中的页面(联系人列表)而言,本地缓存是非常有意义的。对于绝大多数列表而言,服务端 往往意味着是数据源,每当用户执行刷新操作,
App
都应当尝试向服务端请求最新的数据,并将最新的数据存入 数据库,并随之展示在UI
上。通常情况下,这三个层级并非同时都是必要的,读者需正确理解三者各自不同的使用场景。
现在,借助于 服务端组件、数据库 以及 内存,开发者将数据展示在
RecyclerView
上,这似乎已经是正解了。2.问题在哪?
到目前为止,问题还没有完全暴露出来。
我们忽视了一个非常现实的问题,那就是 数据是动态的 ——这意味着,每当数据发生了更新(比如用户进行了下拉刷新操作),开发者都需要将最新的数据响应在
UI
上。这意味着,当某个用户的联系人列表中有10000个条目时,每次数据的更新,都会对所有的数据进行重建——从而导致 性能非常低下,用户看到的只是屏幕中的几条联系人信息,为此要重新创建10000个条目?用户显然无法接受。
因此,分页组件的设计势在必行。
3.整理需求
3.1、简单易用
上文我们谈到,UI响应数据的变更,这种情况下,使用 观察者模式 是一个不错的主意,比如
LiveData
、RxJava
甚至自定义一个接口等等,开发者仅需要观察每次数据库中数据的变更,并进行UI
的更新:新的组件我们也希望能拥有同样的便利,比如使用
LiveData
或者RxJava
,并进行订阅处理数据的更新—— 简单 且 易用。3.2、处理更多层级
我们希望新的组件能够处理多层,我们希望列表展示 服务器 返回的数据、 或者 数据库 中的数据,并将其放入UI中。
3.3、性能
新的组件必须保证足够的快,不做任何没必要的行为,为了保证效率,繁重的操作不要直接放在
UI
线程中处理。3.4、感知生命周期
如果可能,新的组件需要能够对生命周期进行感知,就像
LiveData
一样,如果页面并不在屏幕的可视范围内,组件不应该工作。3.5、足够灵活
足够的灵活性非常重要——每个项目都有不同的业务,这意味着不同的
API
、不同的数据结构,新的组件必须保证能够应对所有的业务场景。定义好了需求,在正式开始设计Paging之前,首先我们先来回顾一下,普通的列表如何实现数据的动态更新的。
4.普通列表的实现方式
我们依然通过 联系人列表 作为示例,来描述普通列表 如何响应数据的动态更新。
首先,我们需要定义一个
Dao
,这里我们使用了Room
组件用于 数据库 中联系人的查询:这里我们返回的是一个
LiveData
,正如我们前文所言,构建一个可观察的对象显然会让数据的处理更加容易。接下来我们定义好
ViewModel
和Activity
:这里我们使用到了
ListAdapter
,它是官方基于RecyclerView.Adapter
的AsyncListDiffer
封装类,其内创建了AsyncListDiffer
的示例,以便在后台线程中使用DiffUtil
计算新旧数据集的差异,从而节省Item
更新的性能。此外,我们还需要在
ListAdapter
中声明DiffUtil.ItemCallback
,对数据集的差异计算的逻辑进行补充:That's all, 接下来我们开始思考,新的分页组件应该是什么样的。
二、分页组件简介
1.核心类:PagedList
上文提到,一个普通的
RecyclerView
展示的是一个列表的数据,比如List<User>
,但在列表分页的需求中,List<User>
明显就不太够用了。为此,
Google
设计出了一个新的角色PagedList
,顾名思义,该角色的意义就是 **分页列表数据的容器 ** 。现在,我们的
ViewModel
现在可以定义成这样,因为PagedList
也作为列表数据的容器(就像List<User>
一样):在
ViewModel
中,开发者可以轻易通过对users
进行订阅以响应分页数据的更新,这个LiveData
的可观察者是通过Room
组件创建的,我们来看一下我们的dao
:乍得一看似乎理所当然,但实际需求中有一个问题,这里的定义是模糊不清的——对于分页数据而言,不同的业务场景,所需要的相关配置是不同的。那么什么是分页相关配置呢?
最直接的一点是每页数据的加载数量
PageSize
,不同的项目都会自行规定每页数据量的大小,一页请求15个数据还是20个数据?显然我们目前的代码无法进行配置,这是不合理的。2.数据源: DataSource及其工厂
回答这个问题之前,我们还需要定义一个角色,用来为
PagedList
容器提供分页数据,那就是数据源DataSource
。什么是
DataSource
呢?它不应该是 数据库数据 或者 服务端数据, 而应该是 数据库数据 或者 服务端数据 的一个快照(Snapshot
)。每当
Paging
被告知需要更多数据:“Hi,我需要第45-60个的数据!”——数据源DataSource
就会将当前Snapshot
对应索引的数据交给PagedList
。但是我们需要构建一个新的
PagedList
的时候——比如数据已经失效,DataSource
中旧的数据没有意义了,因此DataSource
也需要被重置。在代码中,这意味着新的
DataSource
对象被创建,因此,我们需要提供的不是DataSource
,而是提供DataSource
的工厂。重新整理思路,我们如何定义
Dao
中接口的返回值呢?返回的是一个数据源的提供者
DataSource.Factory
,页面初始化时,会通过工厂方法创建一个新的DataSource
,这之后对应会创建一个新的PagedList
,每当PagedList
想要获取下一页的数据,数据源都会根据请求索引进行数据的提供。当数据失效时,
DataSource.Factory
会再次创建一个新的DataSource
,其内部包含了最新的数据快照(本案例中代表着数据库中的最新数据),随后创建一个新的PagedList
,并从DataSource
中取最新的数据进行展示——当然,这之后的分页流程都是相同的,无需再次复述。笔者绘制了一幅图用于描述三者之间的关系,读者可参考上述文字和图片加以理解:
3.串联两者:PagedListBuilder
回归第一小节的那个问题,分页相关业务如何进行配置?我们虽然介绍了为
PagedList
提供数据的DataSource
,但这个问题似乎还是没有得到解决。此外,现在
Dao
中接口的返回值已经是DataSource.Factory
,而ViewModel
中的成员被观察者则是LiveData<PagedList<User>>
类型,如何 将数据源的工厂和LiveData<PagedList>
进行串联 ?因此我们还需要定义一个新的角色
PagedListBuilder
,开发者将 数据源工厂 和 相关配置 统一交给PagedListBuilder
,即可生成对应的LiveData<PagedList<User>>
:如代码所示,我们在
ViewModel
中先通过dao
获取了DataSource.Factory
,工厂创建数据源DataSource
,后者为PagedList
提供列表所需要的数据;此外,另外一个Int
类型的参数则制定了每页数据加载的数量,这里我们指定每页数据数量为30。我们成功创建了一个
LiveData<PagedList<User>>
的可观察者对象,接下来的步骤读者驾轻就熟,只不过我们这里使用的是PagedListAdapter
:PagedListAdapter
内部的实现和普通列表ListAdapter
的代码几乎完全相同:4.更多可选配置:PagedList.Config
目前的介绍中,分页的功能似乎已经实现完毕,但这些在现实开发中往往不够,产品业务还有更多细节性的需求。
在上一小节中,我们通过
LivePagedListBuilder
对LiveData<PagedList<User>>
进行创建,这其中第二个参数是 分页组件的配置,代表了每页加载的数量(PageSize
) :读者应该理解,分页组件的配置 本身就是抽象的,
PageSize
并不能完全代表它,因此,设计者额外定义了更复杂的数据结构PagedList.Config
,以描述更细节化的配置参数:对复杂业务配置的
API
设计来说,建造者模式 显然是不错的选择。接下来我们简单了解一下,这些可选的配置分别代表了什么。
4.1.分页数量:PageSize
最易理解的配置,分页请求数据时,开发者总是需要定义每页加载数据的数量。
4.2.初始加载数量:InitialLoadSizeHint
定义首次加载时要加载的
Item
数量。此值通常大于
PageSize
,因此在初始化列表时,该配置可以使得加载的数据保证屏幕可以小范围的滚动。如果未设置,则默认为
PageSize
的三倍。4.3.预取距离:PrefetchDistance
顾名思义,该参数配置定义了列表当距离加载边缘多远时进行分页的请求,默认大小为
PageSize
——即距离底部还有一页数据时,开启下一页的数据加载。若该参数配置为0,则表示除非明确要求,否则不会加载任何数据,通常不建议这样做,因为这将导致用户在滚动屏幕时看到占位符或列表的末尾。
4.4.是否启用占位符:PlaceholderEnabled
该配置项需要传入一个
boolean
值以决定列表是否开启placeholder
(占位符),那么什么是placeholder
呢?我们先来看未开启占位符的情况:
如图所示,没有开启占位符的情况下,列表展示的是当前所有的数据,请读者重点观察图片右侧的滚动条,当滚动到列表底部,成功加载下一页数据后,滚动条会从长变短,这意味着,新的条目成功实装到了列表中。一言以蔽之,未开启占位符的列表,条目的数量和
PagedList
中数据数量是一致的。接下来我们看一下开启了占位符的情况:
如图所示,开启了占位符的列表,条目的数量和
DataSource
中数据的总量是一致的。 这并不意味着列表从DataSource
一次加载了大量的数据并进行渲染,所有业务依然交给Paging
进行分页处理。当用户滑动到了底部尚未加载的数据时,开发者会看到还未渲染的条目,这是理所当然的,
PagedList
的分页数据加载是异步的,这时对于Item
的来说,要渲染的数据为null
,因此开发者需要配置占位符,当数据未加载完毕时,UI如何进行渲染——这也正是为何上文说到,对于PagedListAdapter
来说,getItem()
函数的返回值是可空的User?
,而不是User
。随着
PagedList
下一页数据的异步加载完毕,伴随着RecyclerView
的原生动画,新的数据会被重新覆盖渲染到placeholder
对应的条目上,就像gif
图展示的一样。4.5.关于Placeholder
这里我专门开一个小节谈谈关于
placeholder
,因为这个机制和我们传统的分页业务似乎有所不同,但Google
的工程师们认为在某些业务场景下,该配置确实很有用。开启了占位符,用户总是可以快速的滑动列表,因为列表“持有”了整个数据集,因此不会像未开启占位符时,滑动到底部而被迫暂停滚动,直到新的数据的加载完毕才能继续浏览。顺畅的操作总比期望之外的阻碍要好得多 。
此外,开启了占位符意味着用户与 加载指示器 彻底告别,类似一个 正在加载更多... 的提示标语或者一个简陋的
ProgressBar
效果真的会提升用户体验吗?也许答案是否定的,相比之下,用户应该更喜欢一个灰色的占位符,并等待它被新的数据渲染。但缺点也随之而来,首先,占位符的条目高度应该和正确的条目高度一致,在某些需求中,这也许并不符合,这将导致渐进性的动画效果并不会那么好。
其次,对于开发者而言,开启占位符意味着需要对
ViewHolder
进行额外的代码处理,数据为null
或者不为null
?两种情况下的条目渲染逻辑都需要被添加。最后,这是一个限制性的条件,您的
DataSource
数据源内部的数据数量必须是确定的,比如通过Room
从本地获取联系人列表;而当数据通过网络请求获取的话,这时数据的数量是不确定的,不开启Placeholder
反而更好。5.更多观察者类型的配置
在本文的示例中,我们建立了一个
LiveData<PagedList<User>>
的可观察者对象供用户响应数据的更新,实际上组件的设计应该面向提供对更多优秀异步库的支持,比如RxJava
。因此,和
LivePagedListBuilder
一样,设计者还提供了RxPagedListBuilder
,通过DataSource
数据源和PagedList.Config
以构建一个对应的Observable
:The text was updated successfully, but these errors were encountered: