本文已授權「玉剛說」微信公眾號獨家釋出
2019/12/24 補充
距本文釋出時隔一年,筆者認為,本文不應該作為入門教程的部落格系列,相反,讀者真正想要理解 Paging 的使用,應該先嚐試理解其分頁元件的本質思想:
反思|Android 列表分頁元件Paging的設計與實現:系統概述
反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析
以上兩篇文章將對Paging分頁元件進行了系統性的概述,筆者強烈建議 讀者將以上兩篇文章作為學習 Paging 閱讀優先順序 最高 的學習資料,所有其它的Paging中文部落格閱讀優先順序都應該靠後。
本文及相關引申閱讀:
Android官方架構元件Paging:分頁庫的設計美學
Android官方架構元件Paging-Ex:為分頁列表新增Header和Footer
Android官方架構元件Paging-Ex:列表狀態的響應式管理
概述
Paging
是Google
在2018年I/O大會上推出的適用於Android
原生開發的分頁庫,如果您還不是很瞭解這個 官方欽定 的分頁架構元件,歡迎參考筆者的這篇文章:
筆者在實際專案中已經使用Paging
半年有餘,和市面上其它熱門的分頁庫相比,Paging
最大的亮點在於其 將列表分頁載入的邏輯作為回撥函式封裝入 DataSource
中,開發者在配置完成後,無需控制分頁的載入,列表會 自動載入 下一頁資料並展示。
本文將闡述:為使用了Paging
的列表新增Header
和Footer
的整個過程、這個過程中遇到的一些阻礙、以及自己是如何解決這些阻礙的——如果您想直接瀏覽最終的解決方案,請直接翻閱本文的 最終的解決方案 小節。
初始思路
為RecyclerView
列表新增Header
或Footer
並不是一個很麻煩的事,最簡單粗暴的方式是將RecyclerView
和Header
共同放入同一個ScrollView
的子View
中,但它無異於對RecyclerView
自身的複用機制視而不見,因此這種解決方案並非首選。
更適用的解決方式是通過實現 多型別列表(MultiType),除了列表本身的Item
型別之外,Header
或Footer
也被視作一種Item
,關於這種方式的實現網上已有很多文章講解,本文不贅述。
在正式開始本文內容之前,我們先來看看最終的實現效果,我們為一個Student
的分頁列表新增了一個Header
和Footer
:
實現這種效果,筆者最初的思路也是通過 多型別列表 實現Header
和Footer
,但是很快我們就遇到了第一個問題,那就是 我們並沒有直接持有資料來源。
1.資料來源問題
對於常規的多型別列表而言,我們可以輕易的持有List<ItemData>
,從資料的控制而言,我更傾向於用一個代表Header
或者Footer
的佔位符插入到資料列表的頂部或者底部,這樣對於RecyclerView
的渲染而言,它是這樣的:
正如我所標註的,List<ItemData>
中一個ItemData
對應了一個ItemView
——我認為為一個Header
或者Footer
單獨建立對應一個Model
型別是完全值得的,它極大增強了程式碼的可讀性,而且對於複雜的Header
而言,代表狀態的Model
類也更容易讓開發者對其進行渲染。
這種實現方式簡單、易讀而不失優雅,但是在Paging
中,這種思路一開始就被堵死了。
我們先看PagedListAdapter
類的宣告:
// T泛型代表資料來源的型別,即本文中的 Student
public abstract class PagedListAdapter<T, VH extends RecyclerView.ViewHolder>
extends RecyclerView.Adapter<VH> {
// ...
}
複製程式碼
因此,我們需要這樣實現:
// 這裡我們只能指定Student型別
class SimpleAdapter : PagedListAdapter<Student, RecyclerView.ViewHolder>(diffCallback) {
// ...
}
複製程式碼
有同學提出,我們可以將這裡的Student
指定為某個介面(比如定義一個ItemData
介面),然後讓Student
和Header
對應的Model
都去實現這個介面,然後這樣:
class SimpleAdapter : PagedListAdapter<ItemData, RecyclerView.ViewHolder>(diffCallback) {
// ...
}
複製程式碼
看起來確實可行,但是我們忽略了一個問題,那就是本小節要闡述的:
我們並沒有直接持有資料來源。
回到初衷,我們知道,Paging
最大的亮點在於 自動分頁載入,這是觀察者模式的體現,配置完成後,我們並不關心 資料是如何被分頁、何時被載入、如何被渲染 的,因此我們也不需要直接持有List<Student>
(實際上也持有不了),更無從談起手動為其新增HeaderItem
和FooterItem
了。
以本文為例,實際上所有邏輯都交給了ViewModel
:
class CommonViewModel(app: Application) : AndroidViewModel(app) {
private val dao = StudentDb.get(app).studentDao()
fun getRefreshLiveData(): LiveData<PagedList<Student>> =
LivePagedListBuilder(dao.getAllStudent(), PagedList.Config.Builder()
.setPageSize(15) //配置分頁載入的數量
.setInitialLoadSizeHint(40) //初始化載入的數量
.build()).build()
}
複製程式碼
可以看到,我們並未直接持有List<Student>
,因此list.add(headerItem)
這種 持有並修改資料來源 的方案几乎不可行(較真而言,其實是可行的,但是成本過高,本文不深入討論)。
2.嘗試直接實現列表
接下來我針對直接實現多型別列表進行嘗試,我們先不討論如何實現Footer
,僅以Header
而言,我們進行如下的實現:
class HeaderSimpleAdapter : PagedListAdapter<Student, RecyclerView.ViewHolder>(diffCallback) {
// 1.根據position為item分配型別
// 如果position = 1,視為Header
// 如果position != 1,視為普通的Student
override fun getItemViewType(position: Int): Int {
return when (position == 0) {
true -> ITEM_TYPE_HEADER
false -> super.getItemViewType(position)
}
}
// 2.根據不同的viewType生成對應ViewHolder
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
ITEM_TYPE_HEADER -> HeaderViewHolder(parent)
else -> StudentViewHolder(parent)
}
}
// 3.根據holder型別,進行對應的渲染
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderViewHolder -> holder.renderHeader()
is StudentViewHolder -> holder.renderStudent(getStudentItem(position))
}
}
// 4.這裡我們根據StudentItem的position,
// 獲取position-1位置的學生
private fun getStudentItem(position: Int): Student? {
return getItem(position - 1)
}
// 5.因為有Header,item數量要多一個
override fun getItemCount(): Int {
return super.getItemCount() + 1
}
// 省略其他程式碼...
// 省略ViewHolder程式碼
}
複製程式碼
程式碼和註釋已經將我的個人思想展示的很清楚了,我們固定一個Header
在多型別列表的最上方,這也導致我們需要重寫getItemCount()
方法,並且在對Item
進行渲染的onBindViewHolder()
方法中,對Sutdent
的獲取進行額外的處理——因為多了一個Header,導致產生了資料來源和列表的錯位差—— 第n個資料被獲取時,我們應該將其渲染在列表的第n+1個位置上。
我簡單繪製了一張圖來描述這個過程,也許更加直觀易懂:
程式碼寫完後,直覺告訴我似乎沒有什麼問題,讓我們來看看實際的執行效果:
Gif也許展示並不那麼清晰,簡單總結下,問題有兩個:
- 1.在我們進行下拉重新整理時,因為
Header
更應該是一個靜態獨立的元件,但實際上它也是列表的一部分,因此白光一閃,除了Student
列表,Header
作為Item
也進行了重新整理,這與我們的預期不符; - 2.下拉重新整理之後,列表 並未展示在最頂部,而是滑動到了一個奇怪的位置。
導致這兩個問題的根本原因仍然是Paging
計算列表的position
時出現的問題:
對於問題1,Paging
對於列表的重新整理理解為 所有Item的重新整理,因此同樣作為Item
的Header
也無法避免被重新整理;
問題2依然也是這個問題導致的,在Paging
獲取到第一頁資料時(假設第一頁資料只有10條),Paging
會命令更新position in 0..9
的Item
,而實際上因為Header
的關係,我們是期望它能夠更新第position in 1..10
的Item
,最終導致了重新整理以及對新資料的展示出現了問題。
3.向Google和Github尋求答案
正如標題而言,我嘗試求助於Google
和Github
,最終找到了這個連結:
PagingWithNetworkSample - PagedList RecyclerView scroll bug
如果您簡單研究過PagedListAdapter
的原始碼的話,您應該瞭解,PagedListAdapter
內部定義了一個AsyncPagedListDiffer
,用於對列表資料的載入和展示,PagedListAdapter
更像是一個空殼,所有分頁相關的邏輯實際都 委託 給了AsyncPagedListDiffer
:
public abstract class PagedListAdapter<T, VH extends RecyclerView.ViewHolder>
extends RecyclerView.Adapter<VH> {
final AsyncPagedListDiffer<T> mDiffer;
public void submitList(@Nullable PagedList<T> pagedList) {
mDiffer.submitList(pagedList);
}
protected T getItem(int position) {
return mDiffer.getItem(position);
}
public int getItemCount() {
return mDiffer.getItemCount();
}
public PagedList<T> getCurrentList() {
return mDiffer.getCurrentList();
}
}
複製程式碼
雖然Paging
中資料的獲取和展示我們是無法控制的,但我們可以嘗試 瞞過 PagedListAdapter
,即使Paging
得到了position in 0..9
的List<Data>
,但是我們讓PagedListAdapter
去更新position in 1..10
的item不就可以了嘛?
因此在上方的Issue
連結中,onlymash 同學提出了一個解決方案:
重寫PagedListAdapter
中被AsyncPagedListDiffer
代理的所有方法,然後例項化一個新的AsyncPagedListDiffer
,並讓這個新的differ代理這些方法。
篇幅所限,我們只展示部分核心程式碼:
class PostAdapter: PagedListAdapter<Any, RecyclerView.ViewHolder>() {
private val adapterCallback = AdapterListUpdateCallback(this)
// 當第n個資料被獲取,更新第n+1個position
private val listUpdateCallback = object : ListUpdateCallback {
override fun onChanged(position: Int, count: Int, payload: Any?) {
adapterCallback.onChanged(position + 1, count, payload)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
adapterCallback.onMoved(fromPosition + 1, toPosition + 1)
}
override fun onInserted(position: Int, count: Int) {
adapterCallback.onInserted(position + 1, count)
}
override fun onRemoved(position: Int, count: Int) {
adapterCallback.onRemoved(position + 1, count)
}
}
// 新建一個differ
private val differ = AsyncPagedListDiffer<Any>(listUpdateCallback,
AsyncDifferConfig.Builder<Any>(POST_COMPARATOR).build())
// 將所有方法重寫,並委託給新的differ去處理
override fun getItem(position: Int): Any? {
return differ.getItem(position - 1)
}
// 將所有方法重寫,並委託給新的differ去處理
override fun submitList(pagedList: PagedList<Any>?) {
differ.submitList(pagedList)
}
// 將所有方法重寫,並委託給新的differ去處理
override fun getCurrentList(): PagedList<Any>? {
return differ.currentList
}
}
複製程式碼
現在我們成功實現了上文中我們的思路,一圖勝千言:
4.另外一種實現方式
上一小節的實現方案是完全可行的,但我個人認為美中不足的是,這種方案 對既有的Adapter
中程式碼改動過大。
我新建了一個AdapterListUpdateCallback
、一個ListUpdateCallback
以及一個新的AsyncPagedListDiffer
,並重寫了太多的PagedListAdapter
的方法——我新增了數十行分頁相關的程式碼,但這些程式碼和正常的列表展示並沒有直接的關係。
當然,我可以將這些邏輯都抽出來放在一個新的類裡面,但我還是感覺我 好像是模仿並重寫了一個新的PagedListAdapter
類一樣,那麼是否還有其它的思路呢?
最終我找到了這篇文章:
Android RecyclerView + Paging Library 新增頭部重新整理會自動滾動的問題分析及解決
這篇文章中的作者通過細緻分析Paging
的原始碼,得出了一個更簡單實現Header
的方案,有興趣的同學可以點進去檢視,這裡簡單闡述其原理:
通過檢視原始碼,以新增分頁為例,Paging
對拿到最新的資料後,對列表的更新實際是呼叫了RecyclerView.Adapter
的notifyItemRangeInserted()
方法,而我們可以通過重寫Adapter.registerAdapterDataObserver()
方法,對資料更新的邏輯進行調整:
// 1.新建一個 AdapterDataObserverProxy 類繼承 RecyclerView.AdapterDataObserver
class AdapterDataObserverProxy extends RecyclerView.AdapterDataObserver {
RecyclerView.AdapterDataObserver adapterDataObserver;
int headerCount;
public ArticleDataObserver(RecyclerView.AdapterDataObserver adapterDataObserver, int headerCount) {
this.adapterDataObserver = adapterDataObserver;
this.headerCount = headerCount;
}
@Override
public void onChanged() {
adapterDataObserver.onChanged();
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount) {
adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount);
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) {
adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount, payload);
}
// 當第n個資料被獲取,更新第n+1個position
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
adapterDataObserver.onItemRangeInserted(positionStart + headerCount, itemCount);
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
adapterDataObserver.onItemRangeRemoved(positionStart + headerCount, itemCount);
}
@Override
public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
super.onItemRangeMoved(fromPosition + headerCount, toPosition + headerCount, itemCount);
}
}
// 2.對於Adapter而言,僅需重寫registerAdapterDataObserver()方法
// 然後用 AdapterDataObserverProxy 去做代理即可
class PostAdapter extends PagedListAdapter {
@Override
public void registerAdapterDataObserver(@NonNull RecyclerView.AdapterDataObserver observer) {
super.registerAdapterDataObserver(new AdapterDataObserverProxy(observer, getHeaderCount()));
}
}
複製程式碼
我們將額外的邏輯抽了出來作為一個新的類,思路和上一小節的十分相似,同樣我們也得到了預期的結果。
經過對原始碼的追蹤,從效能上來講,這兩種實現方式並沒有什麼不同,唯一的區別就是,前者是針對PagedListAdapter
進行了重寫,將Item
更新的程式碼交給了AsyncPagedListDiffer
;而這種方式中,AsyncPagedListDiffer
內部對Item
更新的邏輯,最終仍然是交給了RecyclerView.Adapter
的notifyItemRangeInserted()
方法去執行的——兩者本質上並無區別。
5.最終的解決方案
雖然上文只闡述了Paging library
如何實現Header
,實際上對於Footer
而言也是一樣,因為Footer
也可以被視為另外一種的Item
;同時,因為Footer
在列表底部,並不會影響position
的更新,因此它更簡單。
下面是完整的Adapter
示例:
class HeaderProxyAdapter : PagedListAdapter<Student, RecyclerView.ViewHolder>(diffCallback) {
override fun getItemViewType(position: Int): Int {
return when (position) {
0 -> ITEM_TYPE_HEADER
itemCount - 1 -> ITEM_TYPE_FOOTER
else -> super.getItemViewType(position)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
ITEM_TYPE_HEADER -> HeaderViewHolder(parent)
ITEM_TYPE_FOOTER -> FooterViewHolder(parent)
else -> StudentViewHolder(parent)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderViewHolder -> holder.bindsHeader()
is FooterViewHolder -> holder.bindsFooter()
is StudentViewHolder -> holder.bindTo(getStudentItem(position))
}
}
private fun getStudentItem(position: Int): Student? {
return getItem(position - 1)
}
override fun getItemCount(): Int {
return super.getItemCount() + 2
}
override fun registerAdapterDataObserver(observer: RecyclerView.AdapterDataObserver) {
super.registerAdapterDataObserver(AdapterDataObserverProxy(observer, 1))
}
companion object {
private val diffCallback = object : DiffUtil.ItemCallback<Student>() {
override fun areItemsTheSame(oldItem: Student, newItem: Student): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Student, newItem: Student): Boolean =
oldItem == newItem
}
private const val ITEM_TYPE_HEADER = 99
private const val ITEM_TYPE_FOOTER = 100
}
}
複製程式碼
如果你想檢視執行完整的demo,這裡是本文sample的地址:
6.更多優化點?
文末最終的方案是否有更多優化的空間呢?當然,在實際的專案中,對其進行簡單的封裝是更有意義的(比如Builder
模式、封裝一個Header
、Footer
甚至兩者都有的裝飾器類、或者其它...)。
本文旨在描述Paging使用過程中 遇到的問題 和 解決問題的過程,因此專案級別的封裝和實現細節不作為本文的主要內容;關於Header
和Footer
在Paging
中的實現方式,如果您有更好的解決方案,期待與您的共同探討。
參考&感謝
- Paging library 原始碼
- Android RecyclerView + Paging Library 新增頭部重新整理會自動滾動的問題分析及解決
- PagingWithNetworkSample - PagedList RecyclerView scroll bug
系列文章
爭取打造 Android Jetpack 講解的最好的部落格系列:
Android Jetpack 實戰篇:
關於我
Hello,我是卻把清梅嗅,如果您覺得文章對您有價值,歡迎 ❤️,也歡迎關注我的個人部落格或者Github。
如果您覺得文章還差了那麼點東西,也請通過關注督促我寫出更好的文章——萬一哪天我進步了呢?