【動畫圖解】這個值取對了,ViewPager2才能縱享絲滑

語言: CN / TW / HK

ViewPager2系列: 1. 圖解RecyclerView快取複用機制 2. 圖解RecyclerView預拉取機制 3. 圖解ViewPager2離屏載入機制(上)

前言

在前兩篇文章中,我們通過一張張清晰明瞭的「示意圖」,詳細地覆盤了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; } // 計算多pageLimit2個頁面大小的空間 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的快取複用機制和預拉取機制會工作。

  1. 當滑動檢視初始化完成時,只有position=0的頁面項會被新增至當前檢視層次結構中。
  2. 隨著我們往左滑動螢幕,預拉取機制會開始工作,提前建立position=2的頁面項並放入mCachedView中。
  3. 同時,position=0的頁面項也將隨著向左滑動的手勢被移出螢幕,並放入mCachedView中。

  1. 再次向左滑動螢幕,滑動檢視會取出預拉取的position=2的頁面項進行使用,同時開啟對position=3的頁面項的預拉取。
  2. 此時,由於還未超過mCachedView大小的限制,下一個被移出螢幕的position=1的頁面項也將放入mCachedView中。

  1. 第三次向左滑動螢幕,同樣,會取出預拉取的position=3的頁面項進行使用,同時開啟對position=4的頁面項的預拉取。
  2. 但是,由於超過了mCachedView大小的限制,在下一個被移出螢幕的position=2的頁面項嘗試進入時,會先按照先進先出的順序,先從mCachedView中移出position=0的頁面項,放入RecyclerPool中對應itemType的ArrayList容器中,然後position=2的頁面項才順利進入mCachedView。
  3. 之後的滑動同樣遵循這個規律,不再贅述。
OffscreenPageLimit值為1

當OffscreenPageLimit值為1時,也即會在左右兩側各離屏載入1個頁面。

  1. 當滑動檢視初始化完成時,由於左側無更多的頁面項,因此只有position=0及position=1的頁面項會被新增至當前檢視層次結構中。
  2. 隨著我們往左滑動螢幕,position=2的頁面項會被新增至當前檢視層次結構中,而position=0的頁面項會繼續保留在當前檢視層次結構中,同時預拉取機制會開始工作,提前建立position=3的頁面項並放入mCachedView中。

  1. 再次向左滑動螢幕,滑動檢視會取出預拉取的position=3的頁面項新增至當前檢視層次結構中,而position=1的頁面項會繼續保留在當前檢視層次結構中,並開啟對position=4的頁面項的預拉取。
  2. 同時,position=0的頁面項也將隨著向左滑動的手勢被移出螢幕,並放入mCachedView中。

  1. 第三次向左滑動螢幕,同樣,會取出預拉取的position=4的頁面項新增至當前檢視層次結構中,並保留position=2的頁面項在當前檢視層次結構中,同時開啟對position=5的頁面項的預拉取。
  2. 此時,由於還未超過mCachedView大小的限制,下一個被移出螢幕的position=1的頁面項也將放入mCachedView中。

  1. 第四次向左滑動螢幕,同樣,會取出預拉取的position=5的頁面項新增至當前檢視層次結構中,並保留position=3的頁面項在當前檢視層次結構中,同時開啟對position=6的頁面項的預拉取。
  2. 但是,由於超過了mCachedView大小的限制,在下一個被移出螢幕的position=2的頁面項嘗試進入時,會先按照先進先出的順序,先從mCachedView中移出position=0的頁面項,放入RecyclerPool中對應itemType的ArrayList容器中。
OffscreenPageLimit值為3

當OffscreenPageLimit值為3時,也即會在左右兩側各離屏載入3個頁面。

  1. 當滑動檢視初始化完成時,由於左側無更多的頁面項,因此只有position=0至position=3的頁面項會被新增至當前檢視層次結構中。
  2. 隨著我們往左滑動螢幕,position=4的頁面項會被新增至當前檢視層次結構中,而position=0的頁面項會繼續保留在當前檢視層次結構中,同時預拉取機制會開始工作,提前建立position=5的頁面項並放入mCachedView中。

  1. 再次向左滑動螢幕,滑動檢視會取出預拉取的position=5的頁面項新增至當前檢視層次結構中,而position=1的頁面項會繼續保留在當前檢視層次結構中,並開啟對position=6的頁面項的預拉取。

  1. 第三次向左滑動螢幕,滑動檢視會取出預拉取的position=6的頁面項新增至當前檢視層次結構中,而position=2的頁面項會繼續保留在當前檢視層次結構中。也即這個時候,所有的頁面項已經都被新增至當前檢視層次結構中了。

  1. 第四次向左滑動螢幕,由於超出了OffscreenPageLimit值,position=0的頁面項將隨著向左滑動的手勢被移出螢幕,並放入mCachedView中。

  1. 第五次向左滑動螢幕,此時,由於還未超過mCachedView大小的限制,下一個被移出螢幕的position=1的頁面項也將放入mCachedView中。

  1. 第六次向左滑動螢幕,但是,由於超過了mCachedView大小的限制,在下一個被移出螢幕的position=2的頁面項嘗試進入時,會先按照先進先出的順序,先從mCachedView中移出position=0的頁面項,放入RecyclerPool中對應itemType的ArrayList容器中。
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,提供點選標籤頁跳轉到指定頁面項的功能。

而當增加了這一種新的互動方式後,問題的維度再一次上升了,我們會發現離屏載入機制的行為邏輯又有所不同了,而這,就是下一篇的內容了。