前言
最近ViewPager2
釋出了1.0.0-alpha04
版本,新增offscreenPageLimit
功能,該功能在ViewPager
上並不友好,現在官方將此功能延續下來,這回是騾子是馬呢?趕緊拉出來溜溜;
閱讀指南:
- 基於ViewPager2
1.0.0-alpha04
版本講解,由於正式版還未釋出,如有功能變動還請讀者指點 - 本文主要針對ViewPager2的
offscreenPageLimit
特性和預載入
展開講解,包括Adapter的狀態和Fragment的生命週期
ViewPager頑疾
為什麼說offscreenPageLimit
在ViewPager
上十分不友好,可能是因為offscreenPageLimit
最小值只能是1吧;
上面是ViewPager預設情況下的載入示意圖,當切換到當前頁面時,會預設預載入左右兩側的佈局到ViewPager
中,儘管兩側的View並不可見的,我們稱這種情況叫預載入
;由於ViewPager
對offscreenPageLimit
設定了限制,頁面的預載入是不可避免;
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,返回需要新增ItemViewdestroyItem(iewGroup container, int position, Object object)
銷燬ItemView,移除指定的ItemViewisViewFromObject(View view, Object object)
View和Object是否對應setPrimaryItem(ViewGroup container, int position, Object object)
當前頁面的主ItemgetCount()
獲取Item個數
先說setPrimaryItem(ViewGroup container, int position, Object object)
,該方法表示當前頁面正在顯示主要Item
,何為主要Item
?如果預載入的ItemView已經劃入螢幕,當前的PrimaryItem
依然不會改變,除非新的ItemView完全劃入螢幕,且滑動已經停止才會判斷;
由於ViewPager
不可避免的進行佈局預載入,造成PagerAdapter
必須提前呼叫instantiateItem(ViewGroup container, int position)
方法,instantiateItem()
是建立ItemView的唯一入口方法,所以PagerAdapter
的實現類FragmentPagerAdapter
和FragmentStatePagerAdapter
必須抓住該方法進行Fragment
物件的建立;
碰巧的是,FragmentPagerAdapter
和FragmentStatePagerAdapter
一股腦的在instantiateItem()
中進行建立且進行add
或attach
操作,並沒有在setPrimaryItem()
方法中對Fragment
進行操作;
因此,預載入會導致不可見的Fragment
一股腦的呼叫onCreate
、onCreateView
、onResume
等方法,使用者只能通過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
本質上是重寫LinearLayoutManager
的calculateExtraLayoutSpace
方法,該方法是最新的recyclerView
包加入的功能;
calculateExtraLayoutSpace
方法定義了佈局額外的空間,何為佈局額外的控制元件,預設佈局的空間等於RecyclerView的空間,定義這個意在可以放大布局空間,其中引數extraLayoutSpace
是一個長度為2的int陣列,第一條資料接受左邊/上邊的額外空間,第二條資料接受右邊/下邊的額外空間,想知道更細節的邏輯都在LinearLayoutManager裡;
綜上程式碼,OffscreenPageLimit
可能就是放大了LinearLayoutManager
的佈局空間,我們下面看執行效果;
佈局對比
為了對比兩者載入佈局的效果,我準備了LinearLayout同時展示ViewPager和ViewPager2,設定相同的Item佈局和資料來源,然後用Android佈局分析工具抓取兩者的佈局結構,程式碼比較簡單,就不貼出來了;
預設offscreenPageLimit
從執行結果來看,ViewPager
會預設會預佈局
兩側各一個佈局,ViewPager2
預設不進行預佈局
,主要由各自的預設offscreenPageLimit
引數決定,ViewPager
預設為1且不允許小於1,ViewPager2
預設為0
設定offscreenPageLimit=2
分析執行結果,在設定相同的offscreenPageLimit
時,兩者都會預佈局左右(上下)兩者的offscreenPageLimit
個ItemView;
從對比結果上來看,ViewPager2
的offscreenPageLimit
和ViewPager
執行結果一樣,但是ViewPager2
最小offscreenPageLimit
可以設定為0;
ViewPager2預載入和快取
ViewPager2預載入
即RecyclerView
的預載入,程式碼在RecyclerView
的GapWorker
中,這個知識可能有些同學不是很瞭解,推薦先看這篇部落格medium.com/google-deve…;
在ViewPager2
上預設開啟預載入,表現形式是在拖動控制元件或者Fling
時,可能會預載入一條資料;下面是預載入的示意圖:
如何關閉預載入?
((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
:
ViewPager2
的Adapter
本質上是RecyclerView.Adapter
,下面列舉常用方法:
onCreateViewHolder(ViewGroup parent, int viewType)
建立ViewHolderonBindViewHolder(VH holder, int position)
繫結ViewHolderonViewRecycled(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支援
目前,ViewPager2
對Fragment
的支援只能使用FragmentStateAdapter
,使用起來也是非常簡單:
預設情況下,ViewPager2
是開啟預載入
關閉離屏載入
的,這種情況下,切換頁面對Fragment生命周如何?
問題一:關閉預載入對Fragment
的影響:
經過驗證,是否開啟預載入,對Fragment
的生命週期沒有影響,結果和預設上圖是一樣的;
問題二:開啟離屏載入對Fragment
的影響:
設定offscreenPageLimit=1時:
列印結果解讀:
備註:log日誌下標是從2開始的,標註的頁碼是從1開始,請自行矯正;
- 預設情況下,
ViewPager2
會快取兩條資料,所以滑動到第4頁,第1頁的Fragment才開始移除,這可以理解; - 設定offscreenPageLimit=1時,
ViewPager2
在第1頁會載入兩條資料,這可以理解,會把下一頁View提前載入進來;以後每滑一頁,會載入下一頁陣列,直到第5頁,會移除第1頁的Fragment
;第6頁會移除第2頁的Fragment
如何理解offscreenPageLimit
對Fragment
的影響,假設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_PARENT
的FrameLayout
,注意這裡並不像PagerAdapter
是Fragment
的rootView
;
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()
方法呼叫onViewAttachedToWindow
將Fragment
和hodler
繫結;
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小結:
- 目前
ViewPager2
對Fragment
支援只能用FragmentStateAdapter
,FragmentStateAdapter
在遇到預載入
時,只會建立Fragment
物件,不會把Fragment
真正的加入到佈局中,所以自帶懶載入效果; FragmentStateAdapter
不會一直保留Fragment
例項,回收的ItemView
也會移除Fragment
,所以得做好Fragment`重建後恢復資料的準備;FragmentStateAdapter
在遇到offscreenPageLimit>0時,處理離屏Fragment
和可見Fragment
沒有什麼區別,所以無法通過setUserVisibleHint
判斷顯示與否,這一點知得注意;
總結
這一次ViewPager2
更新,官方貌似要發力替換掉ViewPager
了,無論是高效的複用還是自帶懶載入的Adapter來看,都要比ViewPager
要強大,如果想用建議嘗試升級,但是謹慎使用Fragment
+offscreenPageLimit>0
的組合。