前言
在前兩篇文章中,我們通過一張張清晰明了的「示意圖」,詳細地復盤了RecyclerView「緩存復用機制」與「預拉取機制」的工作流程,這種「圖解」創作形式也得到了來自不同平臺讀者們的一致認可。
而從本文開始,我們將正式進入ViewPager2的篇章,并將輔以更加生動易懂的「動態示意圖」來進行講解。
ViewPager2可講的內容有很多,今天我們主要介紹是ViewPager2的「離屏加載機制」,你可能是第一次聽說這個術語,但在實際開發中,你肯定使用過它,因為它對應的配置入口,就是ViewPager2的OffscreenPageLimit屬性。
OffscreenPageLimit是什么?
OffscreenPageLimit,直譯過來是離屏頁面限制值的意思,該值代表的是在滑動視圖中應保留在當前可見頁面之外的任一方向上的頁面數。
比如,當我們采用水平分頁時,該值代表的便是在左右兩側應保留的頁面數。
而當我們采用垂直分頁時,該值代表的則是在上下兩側應保留的頁面數。
保留頁面的方式是通過擴展額外的布局空間實現的,以LinearLayoutManager為例,其最關鍵的步驟在于對calculateExtraLayoutSpace
方法的重寫:
/**
* 計算額外的布局空間
*/
@Override
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
@NonNull int[] extraLayoutSpace) {
int pageLimit = getOffscreenPageLimit();
if (pageLimit == OFFSCREEN_PAGE_LIMIT_DEFAULT) {
// 僅在需要時才對屏幕外頁面進行自定義預取
super.calculateExtraLayoutSpace(state, extraLayoutSpace);
return;
}
// 計算多pageLimit*2個頁面大小的空間
final int offscreenSpace = getPageSize() * pageLimit;
extraLayoutSpace[0] = offscreenSpace;
extraLayoutSpace[1] = offscreenSpace;
}
/**
* 獲取單個頁面大小
*/
int getPageSize() {
final RecyclerView rv = mRecyclerView;
// 水平分頁時,取去除了左右內邊距后的RecyclerView寬度
// 垂直分頁時,取去除了上下內邊距后的RecyclerView高度
return getOrientation() == ORIENTATION_HORIZONTAL
? rv.getWidth() - rv.getPaddingLeft() - rv.getPaddingRight()
: rv.getHeight() - rv.getPaddingTop() - rv.getPaddingBottom();
}
該方法會計算LinearLayoutManager應布置的額外空間量(以像素為單位)。已知默認布置的空間量為單個頁面大小,則額外布置的空間量應為OffscreenPageLimit*2個單頁面大小,計算出來的結果會存儲在int數組類型的extraLayoutSpace
結構中,其中:
- extraLayoutSpace[0]應用于頂部或左側的額外空間;
- extraLayoutSpace[1]應用于底部或右側的額外空間。
雖然這部分額外創建的頁面在當前屏幕上并不可見,但實際已經被添加至我們的視圖層次結構中了。這么做可以減少切換分頁時花費在視圖創建與布局上的時間,從而提升ViewPager2滑動時的整體流暢度。
結合前面兩篇文章我們可以看到,從緩存復用機制到預拉取機制再到現在的離屏加載機制,RecyclerView與ViewPager2在提升滑動流暢度方面真的是做了非常多的努力。
區別在于:
- 緩存復用機制是通過緩存已創建的頁面,以提供給新進入屏幕的頁面重用來實現的。
- 預拉取機制是通過利用UI線程空閑的時機,提前創建并緩存下一個待進入屏幕的頁面來實現的。
- 離屏加載機制則是通過擴展額外的布局空間,以提前創建并保留屏幕兩側的頁面來實現的。
從調用方法流程上講,離屏加載機制除了常規的onCreateViewHolder、onBindViewHolder方法之外,還會執行一個多onViewAttachedToWindow
方法,以將頁面提前添加至我們的視圖層次結構中。
雖然我們一直強調的是“ViewPager2的離屏加載機制”,但其實,離屏加載機制并不是ViewPager2才引入的新特性,作為ViewPager的改進版本,ViewPager2也只是把早已存在于ViewPager中的這個特性照搬過來而已,二者的主要區別有以下幾點:
- 對于OffscreenPageLimit默認值的設置
- 對于OffscreenPageLimit賦值條件的限制
OffscreenPageLimit的默認值設置與賦值條件限制
ViewPager一直為人所詬病的一個點就是,其設置的OffscreenPageLimit默認值為1,且不允許外部傳入低于1的修改值,即會強制開啟離屏加載機制。
// 默認的離屏加載限制值為1
private static final int DEFAULT_OFFSCREEN_PAGES = 1;
public void setOffscreenPageLimit(int limit) {
// 小于默認值的數會被強制設為默認值
if (limit < DEFAULT_OFFSCREEN_PAGES) {
Log.w(TAG, "Requested offscreen page limit " + limit + " too small; defaulting to "
+ DEFAULT_OFFSCREEN_PAGES);
limit = DEFAULT_OFFSCREEN_PAGES;
}
if (limit != mOffscreenPageLimit) {
mOffscreenPageLimit = limit;
populate();
}
}
這也就意味著,在使用ViewPager構建的滑動視圖中,不管開發者需不需要,都至少會有1~2個頁面會被離屏加載,而這會導致一系列依賴于Fragment生命周期的邏輯被異常執行,進而產生非預期的結果,需要開發者手動實現延遲加載機制。
相比較之下,ViewPager2設置的OffscreenPageLimit默認值則為-1,也即默認不開啟離屏加載機制,且對于外部傳入的修改值也只要求必須是大于0的正數或默認值。
// 默認的離屏加載限制值為-1
public static final int OFFSCREEN_PAGE_LIMIT_DEFAULT = -1;
public void setOffscreenPageLimit(@OffscreenPageLimit int limit) {
// 低于1且非默認值的傳參會報異常
if (limit < 1 && limit != OFFSCREEN_PAGE_LIMIT_DEFAULT) {
throw new IllegalArgumentException(
"Offscreen page limit must be OFFSCREEN_PAGE_LIMIT_DEFAULT or a number > 0");
}
mOffscreenPageLimit = limit;
// 觸發重新布局操作,以便通過getExtraLayoutSize()方法進行離屏加載
mRecyclerView.requestLayout();
}
另外,我們在本系列的第一篇就講了,ViewPager2是在RecyclerView的基礎上構建而成的。因此,即使是默認不開啟離屏加載機制,預拉取機制也會正常工作。
而我們前面又講了,預拉取機制會提前創建并緩存下一個待進入屏幕的頁面,但不會添加至我們的視圖層次結構中,因此不會像ViewPager一樣,導致一系列依賴于Fragment生命周期的邏輯被異常執行,相當于自動幫我們實現延遲加載機制了。
從以上2個默認數值我們可以看到,無論是ViewPager還是ViewPager2,其對于OffscreenPageLimit默認值的設置都是比較克制的。實際上,在setOffscreenPageLimit方法的注釋中,Android也是建議我們將此限制值保持在較低水平,尤其是當我們的頁面具有復雜的布局時。
但實際情況是,大部分的開發者為圖方便,往往會將此值設為頁面總數-1,也即默認會離屏加載所有的頁面。
這種做法無疑是很不規范的,為什么說不規范呢?這就引申出我們下一個問題了,即OffscreenPageLimit的不同賦值,會對ViewPager2產生什么樣的影響呢?
不同的OffscreenPageLimit值產生的影響
行為表現
OffscreenPageLimit值為-1
當OffscreenPageLimit值為-1時,也即保持默認不開啟離屏加載機制,這種情況下只有RecyclerView的緩存復用機制和預拉取機制會工作。
- 當滑動視圖初始化完成時,只有position=0的頁面項會被添加至當前視圖層次結構中。
- 隨著我們往左滑動屏幕,預拉取機制會開始工作,提前創建position=2的頁面項并放入mCachedView中。
- 同時,position=0的頁面項也將隨著向左滑動的手勢被移出屏幕,并放入mCachedView中。
- 再次向左滑動屏幕,滑動視圖會取出預拉取的position=2的頁面項進行使用,同時開啟對position=3的頁面項的預拉取。
- 此時,由于還未超過mCachedView大小的限制,下一個被移出屏幕的position=1的頁面項也將放入mCachedView中。
- 第三次向左滑動屏幕,同樣,會取出預拉取的position=3的頁面項進行使用,同時開啟對position=4的頁面項的預拉取。
- 但是,由于超過了mCachedView大小的限制,在下一個被移出屏幕的position=2的頁面項嘗試進入時,會先按照先進先出的順序,先從mCachedView中移出position=0的頁面項,放入RecyclerPool中對應itemType的ArrayList容器中,然后position=2的頁面項才順利進入mCachedView。
- 之后的滑動同樣遵循這個規律,不再贅述。
OffscreenPageLimit值為1
當OffscreenPageLimit值為1時,也即會在左右兩側各離屏加載1個頁面。
- 當滑動視圖初始化完成時,由于左側無更多的頁面項,因此只有position=0及position=1的頁面項會被添加至當前視圖層次結構中。
- 隨著我們往左滑動屏幕,position=2的頁面項會被添加至當前視圖層次結構中,而position=0的頁面項會繼續保留在當前視圖層次結構中,同時預拉取機制會開始工作,提前創建position=3的頁面項并放入mCachedView中。
- 再次向左滑動屏幕,滑動視圖會取出預拉取的position=3的頁面項添加至當前視圖層次結構中,而position=1的頁面項會繼續保留在當前視圖層次結構中,并開啟對position=4的頁面項的預拉取。
- 同時,position=0的頁面項也將隨著向左滑動的手勢被移出屏幕,并放入mCachedView中。
- 第三次向左滑動屏幕,同樣,會取出預拉取的position=4的頁面項添加至當前視圖層次結構中,并保留position=2的頁面項在當前視圖層次結構中,同時開啟對position=5的頁面項的預拉取。
- 此時,由于還未超過mCachedView大小的限制,下一個被移出屏幕的position=1的頁面項也將放入mCachedView中。
- 第四次向左滑動屏幕,同樣,會取出預拉取的position=5的頁面項添加至當前視圖層次結構中,并保留position=3的頁面項在當前視圖層次結構中,同時開啟對position=6的頁面項的預拉取。
- 但是,由于超過了mCachedView大小的限制,在下一個被移出屏幕的position=2的頁面項嘗試進入時,會先按照先進先出的順序,先從mCachedView中移出position=0的頁面項,放入RecyclerPool中對應itemType的ArrayList容器中。
OffscreenPageLimit值為3
當OffscreenPageLimit值為3時,也即會在左右兩側各離屏加載3個頁面。
- 當滑動視圖初始化完成時,由于左側無更多的頁面項,因此只有position=0至position=3的頁面項會被添加至當前視圖層次結構中。
- 隨著我們往左滑動屏幕,position=4的頁面項會被添加至當前視圖層次結構中,而position=0的頁面項會繼續保留在當前視圖層次結構中,同時預拉取機制會開始工作,提前創建position=5的頁面項并放入mCachedView中。
- 再次向左滑動屏幕,滑動視圖會取出預拉取的position=5的頁面項添加至當前視圖層次結構中,而position=1的頁面項會繼續保留在當前視圖層次結構中,并開啟對position=6的頁面項的預拉取。
- 第三次向左滑動屏幕,滑動視圖會取出預拉取的position=6的頁面項添加至當前視圖層次結構中,而position=2的頁面項會繼續保留在當前視圖層次結構中。也即這個時候,所有的頁面項已經都被添加至當前視圖層次結構中了。
- 第四次向左滑動屏幕,由于超出了OffscreenPageLimit值,position=0的頁面項將隨著向左滑動的手勢被移出屏幕,并放入mCachedView中。
第五次向左滑動屏幕,此時,由于還未超過mCachedView大小的限制,下一個被移出屏幕的position=1的頁面項也將放入mCachedView中。
第六次向左滑動屏幕,由于開啟了預拉取機制,mCachedView大小的限制由默認的2項再加上預拉取的1項,變為3項,因此仍未超過mCachedView大小的限制,下一個被移出屏幕的position=2的頁面項仍將放入mCachedView中,此處不再進行動畫展示。
OffscreenPageLimit值為頁面總數-1
當OffscreenPageLimit值為頁面總數-1時,也即在滑動視圖初始化完成時就已經離屏加載所有的頁面了,這種情況下RecyclerView的緩存復用機制和預拉取機制完全沒有工作的機會。
雖然設置更高的OffscreenPageLimit值,可以更好地提升ViewPager2滑動時的流暢度,但由于需要在初始化階段同時創建多個頁面項,意味著將花費更久的創建時間,頁面項內容也將更慢顯示,同時,由于兩側有更多的頁面項被保留而不走緩存復用流程,意味著應用會占用更多的內存,且這些問題將隨著頁面復雜度提升更加突出。
為了更直觀地展示不同的OffscreenPageLimit值對應用的性能影響,我們將從白屏時間、流暢度、占用內存三個維度來進行橫向對比:
性能影響
白屏時間
可以看到,隨著OffscreenPageLimit值的增加,在滑動視圖的初始化階段,會有更多的頁面項需要被創建并被添加至當前的視圖層次結構中,白屏時間也隨之延長。
流暢度
參考上一篇的做法,我們同樣在FragmentStateAdapter中對Fragment的視圖準備工作做了延遲,以在GPU渲染模式中展示更加清晰的柱狀圖:
OffscreenPageLimit值為1時,雖然可以離屏加載下一個頁面,但由于每次滑動還要執行預拉取的工作,因此對于流暢度的提升不是很明顯。
OffscreenPageLimit值為3時,即每次都會保留當前屏幕兩側的各3個頁面項,在滑動到中間位置時,對于流暢度的提升是最大的,此時無論是往前滑還是往后滑,都無需再執行頁面項的創建工作,即使滑到邊界也可以利用緩存復用機制來重用視圖。
OffscreenPageLimit值為6時,也即在滑動視圖初始化完成時就已經離屏加載所有的頁面了,每次的滑動就相當于只是在當前的視圖層次結構中進行位移,因此全程的流暢度都有極大的提升。
內存占用
可以看到,隨著OffscreenPageLimit值的增加,在滑動視圖的初始化階段,會有更多的Fragment對象駐留在內存中。
同時,由于OffscreenPageLimit值會保留當前屏幕兩側的頁面項,因此,滑動到中間位置時,OffscreenPageLimit值為1的情況最多會保留3個Fragment對象,而OffscreenPageLimit值為3的情況最多會保留7個Fragment對象。
但在其他位置時,它會將超出OffscreenPageLimit值限制的頁面將從視圖層次結構中移除,并交由RecyclerView的緩存復用機制處理,同往常一樣回收ViewHolders對象以供重用。
OffscreenPageLimit值取多大比較合適?
現在我們知道了,當OffscreenPageLimit值設得過大,比如頁面總數-1時,會給應用帶來比較大的內存壓力,特別是在部分低端機型上。
而OffscreenPageLimit值設得過小,比如1時,又無法發揮出離屏加載機制提高頁面滑動流暢度的優勢。
一般來講,同時保持3-4個頁面項處于活動狀態是一個比較合適的值,一方面,可以提高用戶來回翻頁時的流暢度,另一方面又不會給應用帶來太大的內存壓力。當然,還需要我們自己維護好Fragment重建以及視圖回收/復用時的處理邏輯。
最好的情況下,還是希望能夠根據應用當前的內存使用情況,對該值進行動態調整,在行為表現與性能影響上取一個平衡點。
但如果多個頁面項之間存在互斥關系,同時處于活動狀態可能影響業務的判斷時,保持OffscreenPageLimit為默認值,也即默認關閉離屏加載機制,只讓預拉取機制與緩存復用機制工作,也許是個更好的選擇。
后記
講到這里,相信你對ViewPager2的離屏加載機制已經有了一定的認識,但不知道你發現沒有,我們全文講的都是ViewPager2順序依次翻頁的情況,但在實際運用中,我們常常會搭配TabLayout,提供點擊標簽頁跳轉到指定頁面項的功能。
而當增加了這一種新的交互方式后,問題的維度再一次上升了,我們會發現離屏加載機制的行為邏輯又有所不同了,而這,就是下一篇的內容了。