ViewPager2重大更新,支援offscreenPageLimit

HitenDev發表於2019-05-14

前言

最近ViewPager2釋出了1.0.0-alpha04版本,新增offscreenPageLimit功能,該功能在ViewPager上並不友好,現在官方將此功能延續下來,這回是騾子是馬呢?趕緊拉出來溜溜;

ViewPager2重大更新,支援offscreenPageLimit

閱讀指南:

  • 基於ViewPager21.0.0-alpha04版本講解,由於正式版還未釋出,如有功能變動還請讀者指點
  • 本文主要針對ViewPager2的offscreenPageLimit特性和預載入展開講解,包括Adapter的狀態和Fragment的生命週期

ViewPager頑疾

為什麼說offscreenPageLimitViewPager上十分不友好,可能是因為offscreenPageLimit最小值只能是1吧;

ViewPager2重大更新,支援offscreenPageLimit

上面是ViewPager預設情況下的載入示意圖,當切換到當前頁面時,會預設預載入左右兩側的佈局到ViewPager中,儘管兩側的View並不可見的,我們稱這種情況叫預載入;由於ViewPageroffscreenPageLimit設定了限制,頁面的預載入是不可避免;

ViewPager

private static final int DEFAULT_OFFSCREEN_PAGES = 1;

public void setOffscreenPageLimit(int limit) {
    if (limit < DEFAULT_OFFSCREEN_PAGES) {//不允許小於1
        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強制預載入的邏輯在Fragment配合ViewPager使用時依然存在

Fragment懶載入前因後果

先說PagerAdapter

PagerAdapter常用方法如下:

  • instantiateItem(ViewGroup container, int position)初始化ItemView,返回需要新增ItemView
  • destroyItem(iewGroup container, int position, Object object)銷燬ItemView,移除指定的ItemView
  • isViewFromObject(View view, Object object)View和Object是否對應
  • setPrimaryItem(ViewGroup container, int position, Object object) 當前頁面的主Item
  • getCount()獲取Item個數

先說setPrimaryItem(ViewGroup container, int position, Object object),該方法表示當前頁面正在顯示主要Item,何為主要Item?如果預載入的ItemView已經劃入螢幕,當前的PrimaryItem依然不會改變,除非新的ItemView完全劃入螢幕,且滑動已經停止才會判斷;

ViewPager2重大更新,支援offscreenPageLimit

由於ViewPager不可避免的進行佈局預載入,造成PagerAdapter必須提前呼叫instantiateItem(ViewGroup container, int position)方法,instantiateItem()是建立ItemView的唯一入口方法,所以PagerAdapter的實現類FragmentPagerAdapterFragmentStatePagerAdapter必須抓住該方法進行Fragment物件的建立;

ViewPager2重大更新,支援offscreenPageLimit

碰巧的是,FragmentPagerAdapterFragmentStatePagerAdapter一股腦的在instantiateItem()中進行建立且進行addattach操作,並沒有在setPrimaryItem()方法中對Fragment進行操作;

因此,預載入會導致不可見的Fragment一股腦的呼叫onCreateonCreateViewonResume等方法,使用者只能通過Fragment.setUserVisibleHint()方法進行識別;

大多數的懶載入都是對Fragment做手腳,結合生命週期方法和setUserVisibleHint狀態,控制資料延遲載入,而佈局只能提前進入;

ViewPager2基本使用

  • build.gradle引入
implementation 'androidx.viewpager2:viewpager2:1.0.0-alpha04'
複製程式碼
  • 佈局檔案新增
<androidx.viewpager2.widget.ViewPager2
    android:id="@+id/view_pager"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1" />
複製程式碼
  • 設定ViewHolder+Adapter
ViewPager2 viewPager = findViewById(R.id.view_pager2);
viewPager.setAdapter(new RecyclerView.Adapter<ViewHolder>() {
        @Override
        public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
            View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_card_layout, parent, false);
            ViewHolder viewHolder = new ViewHolder(itemView);
            return viewHolder;
        }
        @Override
        public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
            holder.labelCenter.setText(String.valueOf(position));
        }
        @Override
        public int getItemCount() {
            return SIZE;
        }
    }));

static class ViewHolder extends RecyclerView.ViewHolder{
    private final TextView labelCenter;
    public ViewHolder(@NonNull View itemView) {
        super(itemView);
        labelCenter = itemView.findViewById(R.id.label_center);
    }
}
複製程式碼
  • 設定Fragment+Adapter
viewPager.setAdapter(new FragmentStateAdapter(this) {
        @NonNull
        @Override
        public Fragment getItem(int position) {
            return new VSFragment();
        }

        @Override
        public int getItemCount() {
            return SIZE;
        }
    });
複製程式碼

ViewPager2的使用非常簡單,甚至比ViewPager還要簡單,只要熟悉RecyclerView的童鞋肯定會寫ViewPager2

ViewPager2常用方法如下:

  • setAdapter() 設定介面卡
  • setOrientation() 設定佈局方向
  • setCurrentItem() 設定當前Item下標
  • beginFakeDrag() 開始模擬拖拽
  • fakeDragBy() 模擬拖拽中
  • endFakeDrag() 模擬拖拽結束
  • setUserInputEnabled() 設定是否允許使用者輸入/觸控
  • setOffscreenPageLimit() 設定螢幕外載入頁面數量
  • registerOnPageChangeCallback() 註冊頁面改變回撥
  • setPageTransformer() 設定頁面滑動時的變換效果

很多好看好玩的效果,請讀者自行執行官方的DEMO(github.com/googlesampl…);

重要申明

在上文說ViewPager預載入時,我就在想offscreenPageLimit能不能稱之為預載入,如果在ViewPager上可以,那麼在ViewPager2上可能就要混淆了,因為ViewPager2擁有RecyclerView的一整套快取策略,包括RecyclerView的預載入;為了避免混淆,在下面的文章中我把offscreenPageLimit定義為離屏載入預載入只代表RecyclerView的預載入;

ViewPager2離屏載入

1.0.0-alpha04版本中,ViewPager2提供了離屏載入功能,該功能和ViewPager的預載入存的的意義似乎是一樣的;

ViewPager2

public static final int OFFSCREEN_PAGE_LIMIT_DEFAULT = 0;

public void setOffscreenPageLimit(int limit) {
    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;
    // Trigger layout so prefetch happens through getExtraLayoutSize()
    mRecyclerView.requestLayout();
}
複製程式碼

從程式碼可以看出,ViewPager2的離屏載入最小可以為0,僅僅從這一步開始,我大膽的猜測ViewPager2支援所謂的懶載入,帶著好奇,看一眼OffscreenPageLimit實現原理;

ViewPager2.LinearLayoutManagerImpl

@Override
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
        @NonNull int[] extraLayoutSpace) {
    int pageLimit = getOffscreenPageLimit();
    if (pageLimit == OFFSCREEN_PAGE_LIMIT_DEFAULT) {//如果等於預設值(0),呼叫基類的方法
        // Only do custom prefetching of offscreen pages if requested
        super.calculateExtraLayoutSpace(state, extraLayoutSpace);
        return;
    }
    //返回offscreenSpace
    final int offscreenSpace = getPageSize() * pageLimit;
    extraLayoutSpace[0] = offscreenSpace;
    extraLayoutSpace[1] = offscreenSpace;
}
複製程式碼

OffscreenPageLimit本質上是重寫LinearLayoutManagercalculateExtraLayoutSpace方法,該方法是最新的recyclerView包加入的功能;

calculateExtraLayoutSpace方法定義了佈局額外的空間,何為佈局額外的控制元件,預設佈局的空間等於RecyclerView的空間,定義這個意在可以放大布局空間,其中引數extraLayoutSpace是一個長度為2的int陣列,第一條資料接受左邊/上邊的額外空間,第二條資料接受右邊/下邊的額外空間,想知道更細節的邏輯都在LinearLayoutManager裡;

綜上程式碼,OffscreenPageLimit可能就是放大了LinearLayoutManager的佈局空間,我們下面看執行效果;

佈局對比

為了對比兩者載入佈局的效果,我準備了LinearLayout同時展示ViewPager和ViewPager2,設定相同的Item佈局和資料來源,然後用Android佈局分析工具抓取兩者的佈局結構,程式碼比較簡單,就不貼出來了;

預設offscreenPageLimit

ViewPager2重大更新,支援offscreenPageLimit

從執行結果來看,ViewPager會預設會預佈局兩側各一個佈局,ViewPager2預設不進行預佈局,主要由各自的預設offscreenPageLimit引數決定,ViewPager預設為1且不允許小於1,ViewPager2預設為0

設定offscreenPageLimit=2

ViewPager2重大更新,支援offscreenPageLimit

分析執行結果,在設定相同的offscreenPageLimit時,兩者都會預佈局左右(上下)兩者的offscreenPageLimit個ItemView;

從對比結果上來看,ViewPager2offscreenPageLimitViewPager執行結果一樣,但是ViewPager2最小offscreenPageLimit可以設定為0;

ViewPager2預載入和快取

ViewPager2預載入RecyclerView的預載入,程式碼在RecyclerViewGapWorker中,這個知識可能有些同學不是很瞭解,推薦先看這篇部落格medium.com/google-deve…

ViewPager2上預設開啟預載入,表現形式是在拖動控制元件或者Fling時,可能會預載入一條資料;下面是預載入的示意圖:

ViewPager2重大更新,支援offscreenPageLimit

如何關閉預載入?

((RecyclerView)viewPager.getChildAt(0)).getLayoutManager().setItemPrefetchEnabled(false);
複製程式碼

預載入的開關在LayoutManager上,只需要獲取LayoutManager並呼叫setItemPrefetchEnabled()即可控制開關;

ViewPager2預設會快取2條ItemView,而且在最新的RecyclerView中可以自定義快取Item的個數;

RecyclerView

public void setItemViewCacheSize(int size) {
    mRecycler.setViewCacheSize(size);
}
複製程式碼

小結: 預載入快取View層面沒有本質的區別,都是已經準備了佈局,但是沒有載入到parent上; 預載入離屏載入View層面有本質的區別,離屏載入的View已經新增到parent上;

提前載入對Adapter影響

所謂的提前載入,是指當前position不可見但載入了佈局,包括上面說的預載入離屏載入,下面先介紹一下Adapter:

ViewPager2Adapter本質上是RecyclerView.Adapter,下面列舉常用方法:

  • onCreateViewHolder(ViewGroup parent, int viewType)建立ViewHolder
  • onBindViewHolder(VH holder, int position)繫結ViewHolder
  • onViewRecycled(VH holder)當View被回收
  • onViewAttachedToWindow(VH holder)當前View載入到視窗
  • onViewDetachedFromWindow(VH holder)當前View從視窗移除
  • getItemCount()//獲取Item個數

下面主要針對ItemView的建立來說,暫不討論回收的情況;

  • onBindViewHolder 預載入和離屏載入都會呼叫
  • onViewAttachedToWindow 離屏載入ItemView會呼叫,可見ItemView會呼叫
  • onViewDetachedFromWindow 從可見到不可見的ItemView(除離屏中)必定呼叫

小結: 預載入快取Adapter層面沒有區別,都會呼叫onBindViewHolder方法; 預載入離屏載入Adapter層面有本質的區別,離屏載入的View會呼叫onViewAttachedToWindow

ViewPager2對Fragment支援

目前,ViewPager2Fragment的支援只能使用FragmentStateAdapter,使用起來也是非常簡單:

ViewPager2重大更新,支援offscreenPageLimit

預設情況下,ViewPager2是開啟預載入關閉離屏載入的,這種情況下,切換頁面對Fragment生命周如何?

ViewPager2重大更新,支援offscreenPageLimit

問題一:關閉預載入對Fragment的影響: 經過驗證,是否開啟預載入,對Fragment的生命週期沒有影響,結果和預設上圖是一樣的;

問題二:開啟離屏載入對Fragment的影響: 設定offscreenPageLimit=1時:

ViewPager2重大更新,支援offscreenPageLimit

列印結果解讀:

備註:log日誌下標是從2開始的,標註的頁碼是從1開始,請自行矯正;

  • 預設情況下,ViewPager2會快取兩條資料,所以滑動到第4頁,第1頁的Fragment才開始移除,這可以理解;
  • 設定offscreenPageLimit=1時,ViewPager2在第1頁會載入兩條資料,這可以理解,會把下一頁View提前載入進來;以後每滑一頁,會載入下一頁陣列,直到第5頁,會移除第1頁的Fragment;第6頁會移除第2頁的Fragment

如何理解offscreenPageLimitFragment的影響,假設offscreenPageLimit=1,這樣ViewPager2最多可以承託3個ItemView,再加上2個快取的ItemView,就是5個,由於offscreenPageLimit會在ViewPager2兩邊放置一個,所以向前最多承載4個,向後最多能承載1個(預載入對Fragment沒有影響,所以不計算),這樣很自然就是第5個時候,回收第1個;

FragmentStateAdapter原始碼簡單解讀

onCreateViewHolder()方法

public final FragmentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    return FragmentViewHolder.create(parent);
}
static FragmentViewHolder create(ViewGroup parent) {
    FrameLayout container = new FrameLayout(parent.getContext());
    container.setLayoutParams(
            new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT));
    container.setId(ViewCompat.generateViewId());
    container.setSaveEnabled(false);
    return new FragmentViewHolder(container);
}
複製程式碼

onCreateViewHolder()建立一個寬高都MATCH_PARENTFrameLayout,注意這裡並不像PagerAdapterFragmentrootView

onBindViewHolder()

public final void onBindViewHolder(final @NonNull FragmentViewHolder holder, int position) {
    final long itemId = holder.getItemId();
    final int viewHolderId = holder.getContainer().getId();
    final Long boundItemId = itemForViewHolder(viewHolderId); // item currently bound to the VH
    if (boundItemId != null && boundItemId != itemId) {
        removeFragment(boundItemId);
        mItemIdToViewHolder.remove(boundItemId);
    }
    mItemIdToViewHolder.put(itemId, viewHolderId); // this might overwrite an existing entry
    //保證目標Fragment不為空,意思是可以提前建立
    ensureFragment(position);
    /** Special case when {@link RecyclerView} decides to keep the {@link container}
     * attached to the window, but not to the view hierarchy (i.e. parent is null) */
    final FrameLayout container = holder.getContainer();
    //如果ItemView已經在新增到Window中,且parent不等於null,會觸發繫結viewHoder操作;
    if (ViewCompat.isAttachedToWindow(container)) {
        if (container.getParent() != null) {
            throw new IllegalStateException("Design assumption violated.");
        }
        container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
            @Override
            public void onLayoutChange(View v, int left, int top, int right, int bottom,
                    int oldLeft, int oldTop, int oldRight, int oldBottom) {
                if (container.getParent() != null) {
                    container.removeOnLayoutChangeListener(this);
                    //將Fragment和ViewHolder繫結
                    placeFragmentInViewHolder(holder);
                }
            }
        });
    }
    //回收垃圾Fragments
    gcFragments();
}
複製程式碼
  • onBindViewHolder()首先會獲取當前position對應的Fragment,這意味著預載入的Fragment物件會提前建立;
  • 如果當前的holder.itemView已經新增到螢幕且已經佈局且parent不等於空,就會將Fragment繫結到ViewHodler;
  • 每次呼叫都會gc一次,主要的避免使用者修改資料來源造成垃圾物件;

onViewAttachedToWindow()

public final void onViewAttachedToWindow(@NonNull final FragmentViewHolder holder) {
    placeFragmentInViewHolder(holder);
    gcFragments();
}
複製程式碼

onViewAttachedToWindow()方法呼叫onViewAttachedToWindowFragmenthodler繫結;

onViewRecycled()

public final void onViewRecycled(@NonNull FragmentViewHolder holder) {
    final int viewHolderId = holder.getContainer().getId();
    final Long boundItemId = itemForViewHolder(viewHolderId); // item currently bound to the VH
    if (boundItemId != null) {
        removeFragment(boundItemId);
        mItemIdToViewHolder.remove(boundItemId);
    }
}
複製程式碼

onViewRecycled()時才會觸發Fragment移除;

核心新增操作:

 //將Fragment.rootView新增到FrameLayout;
 scheduleViewAttach(fragment, container);//將rootI
 mFragmentManager.beginTransaction().add(fragment, "f" + holder.getItemId()).commitNow();
 
 //主要是監聽onFragmentViewCreated方法,獲取rootView然後新增到container
 private void scheduleViewAttach(final Fragment fragment, final FrameLayout container) {
    // After a config change, Fragments that were in FragmentManager will be recreated. Since
    // ViewHolder container ids are dynamically generated, we opted to manually handle
    // attaching Fragment views to containers. For consistency, we use the same mechanism for
    // all Fragment views.
    mFragmentManager.registerFragmentLifecycleCallbacks(
            new FragmentManager.FragmentLifecycleCallbacks() {
                @Override
                public void onFragmentViewCreated(@NonNull FragmentManager fm,
                        @NonNull Fragment f, @NonNull View v,
                        @Nullable Bundle savedInstanceState) {
                    if (f == fragment) {
                        fm.unregisterFragmentLifecycleCallbacks(this);
                        addViewToContainer(v, container);
                    }
                }
            }, false);
}
複製程式碼

更詳細的FragmentStateAdapter原始碼解讀盡請期待;

but!!!

Fragment中監聽不到setUserVisibleHint

在設定offscreenPageLimit>0時,Fragment中是監聽不到setUserVisibleHint呼叫的,我查了原始碼沒有呼叫,而且該方法被標記過時,所以,適用於ViewPager那一套懶載入Fragment在這裡恐怕是不行了;

話又說回來,既然想玩懶載入,為啥還要設定offscreenPageLimit>0呢,offscreenPageLimit=0就自帶懶載入效果;

Adapter小結:

  • 目前ViewPager2Fragment支援只能用FragmentStateAdapterFragmentStateAdapter在遇到預載入時,只會建立Fragment物件,不會把Fragment真正的加入到佈局中,所以自帶懶載入效果;
  • FragmentStateAdapter不會一直保留Fragment例項,回收的ItemView也會移除Fragment,所以得做好Fragment`重建後恢復資料的準備;
  • FragmentStateAdapter在遇到offscreenPageLimit>0時,處理離屏Fragment和可見Fragment沒有什麼區別,所以無法通過setUserVisibleHint判斷顯示與否,這一點知得注意;

總結

這一次ViewPager2更新,官方貌似要發力替換掉ViewPager了,無論是高效的複用還是自帶懶載入的Adapter來看,都要比ViewPager要強大,如果想用建議嘗試升級,但是謹慎使用Fragment+offscreenPageLimit>0的組合。

相關文章