當今移動應用開發中,列表控件是最常用的 UI 控件之一,它可以顯示各種信息,如圖片、文本、視頻等等。然而,在移動設備上,列表數據的加載和顯示是非常耗費資源的操作。當列表中的數據量較大時,用戶往往需要等待較長的時間才能看到完整的列表。為了提高用戶體驗,開發人員需要採取一些策略來減少加載時間,如預加載。
預加載是指在用戶滑動列表之前,提前加載一部分列表數據,以便在用戶滑動到這些數據時可以立即顯示,從而提高用戶的體驗和感知速度。Android 系統提供了一些 API 和技術來實現列表預加載,本課題旨在對 Android 列表預加載進行深入研究和分析,探究其實現原理、優化策略和性能影響,為開發人員提供參考和指導。
接下來我們就深入研究幾種預加載列表的方法,它們或是通過 Android 系統的 API 擴展或是各種第三方框架完備的實現,了解它們的實現原理,方便我們根據具體的業務需求和性能要求進行綜合評估和比較,從而選擇最合適的方案來實現列表預加載功能。
RecyclerView.OnScrollListener#
通過 RecyclerView 的 addOnScrollListener 接口,我們可以監聽到 RecyclerView 的滑動狀態,然後通過該 RecyclerView 裝載的 LayoutManager 來得到當前滑動最下方或最上方展示的 item 的索引,痛殴索引來判斷我們是否需要執行預加載邏輯。
rvList.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
// 獲取 LayoutManger
RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
// 如果 LayoutManager 是 LinearLayoutManager
if (layoutManager instanceof GridLayoutManager) {
GridLayoutManager manager = (GridLayoutManager) layoutManager;
int nextPreloadCount = 8;
int previousPreloadCount = 4;
if (dy > 0
&& manager.findLastVisibleItemPosition()
== layoutManager.getItemCount() - 1 - nextPreloadCount) {
mViewModel.loadNext();
} else if (dy > 0 && manager.findFirstVisibleItemPosition() == previousPreloadCount) {
mViewModel.loadPrevious();
}
}
}
});
通過給列表頁的 RecyerView 增加這一段代碼,我們就可以很輕鬆的實現列表的預加載,在有本地本地數據緩存排除網絡狀態的情況下,和之前通過框架的 loadMore
觸發有著顯而易見的差別。但於同時,在查看日誌是就很容易發現,onScrolled
方法會在一次滑動中多次重複調用,造成同一個頁面加載的多次調用,網絡加載下對性能以及數據流量有很大的影響,需要業務邏輯上做去重處理。
結論#
RecyclerView.onScrollListener
的實現和集成方式大致如上,通過實踐我們可以得出以前結論:
優點:
- 編碼簡單
- 代碼入侵性低,無需修改現有的 RecyclerView 或 adapter
缺點:
onScrolled
方法會在一次滑動中多次重複調用,需要業務邏輯自行做去重判斷- 不同的
LayoutManager
會有不同的判斷邏輯,需要不停的兼容擴展
Adapter.onBindViewHolder#
我們監聽列表的原因是想知道當前滑動到第幾項目,從而來決定是否要開始預加載,為此需要拿到滑動的狀態和 LayoutManager
。 實際上 Adapter 就有天生的簡單易用的回調,那就是 onBindViewHolder
onBindViewHolder
在 RecyclerView
需要顯示指定的 Position
的 數據時才會通知,這時我們就可以根據 BindViewHolder
的 Position
以及整個列表的數據對比來判斷我們是否需要進行預加載,而無需實時的關心列表的滑動狀態和 LayoutManager
的類型
接下來我們就使用 Adpater 的 onBindViewHolder 來實現以下預加載:
public class ListAdapter extends RecyclerView.Adapter<ViewHolder> {
private int nextPreloadCount = 8;
private int previousPreloadCount = 4;
private boolean isScroll;
public void onBindViewHolder(@NonNull VH holder, int position,
@NonNull List<Object> payloads) {
checkPreload(position);
}
public void bindRecyelerView(@NonNull RecyclerView recyclerView ) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
isScroll = false;
} else {
isScroll = true;
}
}
private void checkPreload(int position) {
if (!isScroll){
return;
}
if (position == previousPreloadCount) {
mViewModel.loadPrevious();
} else if (position == getItemCount() - 1 - nextPreloadCount) {
mViewModel.loadNext();
}
}
}
代碼整體實現也簡單易懂,列表滑動過程中所有的列表項的加載都會經過 Adapter.onBindViewHolder
從而觸發預加載檢測一致,由於 RecyclerView 的邏輯處理,onBindViewHolder
不會存在單次滑動中被多次調用的情況。且由於 Adpater 的 position 獲取是與 layoutManger 無關的,所以也不需要 layoutManger 相關的代碼邏輯。但是這個方案也仍然優缺點,那就是當觸發預加載的 viewHolder 在列表加載過程中被向上滑出了 RecyclerView 的緩存區域時,再向下滑動到頁尾時會再次被綁定導致 onBindViewHolder
觸發,從而使得預加載重複觸發。
結論#
Adapter.onBindViewHolder
的實現也並不複雜,且相較於監聽列表的實現差異明顯
優點:
- 代碼編寫邏輯簡單,基類可多處復用,與類型無關
RecyclerView
對頁首頁尾的ViewHolder
並不會立即回收,不會在正常的滑動事件內觸發多次加載
缺點:
- 仍會重新觸發上拉加載,還是需要做去重操作。
BaseRecyclerViewAdapterHelpr#
BaseRecyclerViewAdapterHelpr
是一個強大而靈活的 RecyclerView Adapter ,是一個在 github 擁有 23.4 K star 的庫,很多商業項目的的 adapter 都會採用它,以下簡稱 BRVAH
。 我們來探究下它是如何處理預加載方案的,通過文檔查看我們發現它的加載更多的邏輯是專門有 QuickAdapterHelper.kt
來實現的,直接查看它的預加載方案 :
加載上一頁#
leadingLoadStateAdapter?.let {
mAdapter.addAdapter(it);
firstAdapterOnViewAttachChangeListener =
object : BaseQuickAdapter.OnViewAttachStateChangeListener {
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
leadingLoadStateAdapter.checkPreload(holder.bindingAdapterPosition);
}
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
}
}.apply { contentAdapter.addOnViewAttachStateChangeListener(this) }
加載下一頁#
trailingLoadStateAdapter?.let {
mAdapter.addAdapter(it);
lastAdapterOnViewAttachChangeListener =
object : BaseQuickAdapter.OnViewAttachStateChangeListener {
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
trailingLoadStateAdapter.checkPreload(
holder.bindingAdapter?.itemCount ?: 0,
holder.bindingAdapterPosition
);
}
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
}
}.apply { contentAdapter.addOnViewAttachStateChangeListener(this) }
}
BRVAH
整體對加載的方案採用的是 ConcatAdapter
,加載的頭部和尾部通過獨立的 adapter 來做邏輯控制,所以兩部的代碼基本一致,這裡我們就拿加載下一頁的邏輯來梳理。 contentAdapter
就是實際的列表 adapter ,trailingLoadStateAdapter
則是專門負責列表尾部邏輯處理的 adapter ,可以看到它創建了一個 OnViewAttachStateChangeListener
用來監聽 viewHolder 的 onViewAttachedToWindow , 其具體實現是綁定了 Adapter 的 onViewAttachedToWindow
,通過這個契機觸發預加載監測機制。具體實現就不在深究了,我們知道它的觸發契機和大致實現即可,感興趣的可以直接去查閱對應的源碼,其實際實現也並不複雜。
可以看到 BRVAH
雖然採用了對 Adapter 進行封裝處理預加載邏輯,但它並沒有採用 Adpater 的 onBindViewHolder
當作觸發契機而是採用了 onViewAttachedToWindow
造成這樣的差異是什麼,我們可以看下這兩者的實際差別:
onViewAttachedToWindow
方法在 RecyclerView 中顯示一個 ViewHolder 時被調用。當 RecyclerView 需要顯示一個新的 ViewHolder 時,它會調用 Adapter 的onCreateViewHolder
方法來創建一個 ViewHolder,然後將這個 ViewHolder 綁定到數據源中對應的數據上,最後調用 ViewHolder 的onBindViewHolder
方法將數據顯示在 ViewHolder 的視圖上。這時,如果 ViewHolder 被成功添加到 RecyclerView 中,onViewAttachedToWindow
方法就會被調用。
因此,onViewAttachedToWindow
方法在 ViewHolder 顯示在 RecyclerView 上時觸發,而 **onBindViewHolder
方法則是在 RecyclerView 需要更新 ViewHolder 數據時觸發。**
從我們的業務場景出發 —— 預加載的目的是通過滑動來判斷用戶可能有向下滑動的意圖,提前補充列表數據,避免用戶等待。業務場景其實並不太依賴當前視圖是否真的展示在界面上了,所以這裡沒有用生命週期更靠前的 onBindViewHolder
而用了更靠後的 onViewAttachedToWindow
從源碼角度上來看並沒有得到好的解釋,去查看倉庫也沒有相關的提交註釋。只能後續看是否能聯繫上作者詢問了
結論#
BRVAH
帶了新的預加載方案,雖然目前看本質上與 onBindViewHolder 類似,但是BRVAH
除此之外還提供了成套的解決方案,包括防止重複加載以及列表頭尾的優雅處理。
優點:
- 集成難度中等
- 有較高的 star 和活躍度,出現問題的概率較小
- 提供了成套的解決方案,避免造輪子
缺點:
- 需要引入新的庫,修改調整現有的 adapter
BRV#
BRV
是一個基於 SmartRefreshLayout
框架的擴展庫,他在 SmartRefreshLayout
的基礎上提供了預加載,缺省頁,懸停標題等功能,號稱擁有比 BRVAH
更強大的功能以及實用性,它的其中預加載邏輯如下:
/** 監聽onBindViewHolder事件 */
var onBindViewHolderListener = object : OnBindViewHolderListener {
override fun onBindViewHolder(
rv: RecyclerView,
adapter: BindingAdapter,
holder: BindingAdapter.BindingViewHolder,
position: Int,
) {
if (mEnableLoadMore && !mFooterNoMoreData &&
rv.scrollState != SCROLL_STATE_IDLE &&
preloadIndex != -1 &&
(adapter.itemCount - preloadIndex <= position)
) {
post {
if (state == RefreshState.None) {
notifyStateChanged(RefreshState.Loading)
onLoadMore(this@PageRefreshLayout)
}
}
}
}
}
可以看到與我們編寫 onBindViewHolder 的監聽邏輯基本如出一轍,通過當前的觸發的 bindViewHolder position 來判斷是否要觸發預加載,而加載的頭部和尾部則是基於 SmartRefreshLayout
來的,通過 ViewGruop 單獨的 add 添加和 remove 掉。
結論#
BRV
的預加載方案基本與我們自己基於 Adpater 的基本一致,在此基礎上增加了去重處理
優點:
- 提供了成套的解決方案,避免數據重複造輪子
缺點:
- 集成難度複雜,依賴
SmartRefreshLayout
沒有引入SmartRefreshLayout
庫的話還需要單獨引入 - 只提供了向後預加載,不支持向前預加載
Paging 3#
Paging 庫概覽 | Android 開發者 | Android Developers
Paging 作為 Jetpack 的組件,專門用於加載和顯示來自本地和網絡中的數據頁面,同樣也提供了數據預加載的功能,那麼作為官方的列表加載方案,它又是如何實現的。
Paging3
作為一整套的列表解決方案,它提供了分頁數據的內存緩存、內置的請求重複信息刪除功能 以及對刷新與重試功能的支持等等,此外, paging3
還大量的使用了 Flow 作為數據處理實現,功能調用棧也極深。導致代碼閱讀複雜較高,這裡我們就只了解下 Paging3
預加載的契機以及判斷邏輯,用於跟其他框架進行對比。
觸發契機#
首先是觸發契機,Paging3
提供了 PagingDataAdapter
作為 RecyclerView 的適配器,開發者必須使用基於它的 Adapter 來進行列表適配,PagingDataAdapter
內置了 diff 機制以及直接管理列表數據,列表設置和更新需要通過 submitData
方法,獲取數據則通過 getItem
方法,而 paging3
的預加載機制則就藏匿在 getItem
的具體實現中,由於列表數據是完全封裝起來的,調用者只能通過 getItem
來獲取列表數據,而調用 getItem
往往是在 onBindViewHolder
時,所以 paging3 的分頁觸發契機也基本等同於 onBindViewHolder
方式
判斷邏輯#
getItem
方法觸發時,paging3 會生成 ViewportHint
的快照,用來存儲描述當前列表的狀態,同時依據這些信息來判斷是否要觸發預加載
/**
* Processes the hint coming from UI.
*/
fun processHint(viewportHint: ViewportHint) {
state.modify(viewportHint as? ViewportHint.Access) { prependHint, appendHint ->
if (viewportHint.shouldPrioritizeOver(
previous = prependHint.value,
loadType = PREPEND
)
) {
prependHint.value = viewportHint
}
if (viewportHint.shouldPrioritizeOver(
previous = appendHint.value,
loadType = APPEND
)
) {
appendHint.value = viewportHint
}
}
}
prependHint
, appendHint
本質也分別是一個 Flow
,當前符合預加載機制後,它們會將 viewportHint
發送到專門用於處理此類數據的 PageFetcherSnapshot
將來轉換成一個刷新事件從而融入整個數據加載流程。
Paging3
的預加載機制大致就是如此,更詳細的機制由於代碼量太多不變深入,如果對 Paging3
不熟悉和感興趣的可以放下邊官方的 CodeLab 做深入了解
結論#
paging3
的預加載只是整個庫的冰山一角,但是由此也可以看到官方也是通過 onBindViewHodler
作為預加載的判斷契機的,給我們挑選更輕量的方案做了一定的背書
優點
- 有完備的機制以及官方背書,出現問題的概率較小
- 預加載還加入了鎖的處理,考慮了多線程並發,完全解決了可能出現的多次請求問題。
缺點
- 集成難度非常大,
paging3
是一整套列表解決方案,需要各個層級的邏輯變更 - 代碼由 kotlin 、Flow 以及協程編寫,預讀和調適性教差,Java 接入不友好
CodeLab#
Google 提供了兩個引導文檔來讓開發人員快速的學習如何集成和使用 Paging 3
Android Paging 基礎知識 | Android Developers
Android Paging 進階 Codelab | Android Developers
總結#
在實現列表預加載的過程中,選擇合適的技術方案非常關鍵,今天我們介紹了以下幾種列表預加載方案:
- 使用 onScrollListener 技術,可以通過監聽滾動事件,在滑動到指定位置之前提前加載數據,以此實現列表預加載的功能。它的實現簡單,同時缺點也相當明顯。
- 使用 BindViewHolder 技術,可以在綁定 ViewHolder 時進行數據的預加載,以此提高列表數據的加載速度和用戶體驗。它的邏輯簡單明瞭,也是很多解決方案的核心邏輯。如果考慮自己封裝的話,那麼以它為藍本是不二之選。
- BRV 框架是一個開源的 Android 列表框架,它基於
SmartRefreshLayout
,提供了很多常用的列表功能,包括列表預加載。BRV 框架可以方便地實現列表預加載,並提供了許多其他的功能,如分組、拖拽等等。遺憾的是並不支持列表向前預加載。 - Paging 框架是一個 Android 官方提供的用於實現分頁加載的框架。它可以方便地實現列表預加載,同時還提供了分頁加載、數據緩存等功能。
- BaseRecyclerViewAdapterHelper 是一個輕量級的 RecyclerView 適配器,它可以快速地構建 RecyclerView 列表,並支持列表預加載等功能。
選擇哪種方案實現列表預加載,需要根據具體的業務需求和性能要求進行綜合評估和比較,從而選擇最合適的方案來實現列表預加載功能。
參考資料:#
換一個思路,超簡單的 RecyclerView 預加載 - 掘金