「預拉取(prefetch)機制」作為RecyclerView的重要特性之一,常常與緩存復用機制一起配合使用、共同協作,極大地提升了RecyclerView整體滑動的流暢度。
並且,這種特性在ViewPager2中同樣得以保留,對ViewPager2滑動效果的呈現也起着關鍵性的作用。因此,我們ViewPager2系列的第二篇,就是要來着重介紹RecyclerView的預拉取機制。
在計算機術語中,「預拉取」指的是在已知需要某部分數據的前提下,利用系統資源閒置的空檔,預先拉取這部分數據到本地,從而提高執行時的效率。
具體到RecyclerView預拉取的情境則是:



正如把緩存復用的實際工作委託給了其內部的Recycler類一樣,RecyclerView也把預拉取的實際工作委託給了一個名為GapWorker的類,其內部的工作流程,可以用以下這張思維導圖來概括:

接下來我們就循着這張思維導圖,來一一拆解預拉取的工作流程。
1.發起預拉取工作通過查找對GapWorker對象的引用,我們可以梳理出3個發起預拉取工作的時機,分別是:


GapWorker是Runnable接口的一個實現類,意味着其執行工作的入口必然是在run方法。
finalclassGapWorkerimplementsRunnable{@Overridepublicvoidrun(){...prefetch(nextFrameNs);...}}在run方法內部我們可以看到其調用了一個prefetch方法,在進入該方法之前,我們先來分析傳入該方法的參數。
//查詢最近一個垂直同步信號發出的時間,以便我們可以預測下一個finalintsize=mRecyclerViews.size();longlatestFrameVsyncMs=0;for(inti=0;i<size;i++){RecyclerViewview=mRecyclerViews.get(i);if(view.getWindowVisibility()==View.VISIBLE){latestFrameVsyncMs=Math.max(view.getDrawingTime(),latestFrameVsyncMs);}}...//預測下一個垂直同步信號發出的時間longnextFrameNs=TimeUnit.MILLISECONDS.toNanos(latestFrameVsyncMs)+mFrameIntervalNs;prefetch(nextFrameNs);由該方法的實參命名nextFrameNs可知,傳入的是「下一幀開始繪製的時間」。
了解過Android屏幕刷新機制的人都知道,當GPU渲染完圖形數據並放入圖像緩衝區(buffer)之後,顯示屏(Display)會等待垂直同步信號(Vsync)發出,隨即交換緩衝區並取出緩衝數據,從而開始對新的一幀的繪製。
所以,這個實參同時也表示「下一個垂直同步信號(Vsync)發出的時間」,這是個預測值,單位為納秒。由最近一個垂直同步信號發出的時間(latestFrameVsyncMs),加上每一幀刷新的間隔時間(mFrameIntervalNs)計算而成。
其中,「每一幀刷新的間隔時間」是這樣子計算得到的:
//如果取自顯示屏的刷新率數據有效,則不採用默認的60fps//注意:此查詢我們只靜態地執行一次,因為它非常昂貴(>1ms)Displaydisplay=ViewCompat.getDisplay(this);floatrefreshRate=60.0f;//默認的刷新率為60fpsif(!isInEditMode()&&display!=null){floatdisplayRefreshRate=display.getRefreshRate();if(displayRefreshRate>=30.0f){refreshRate=displayRefreshRate;}}mGapWorker.mFrameIntervalNs=(long)(1000000000/refreshRate);//1000000000納秒=1秒也即假定在默認60fps的刷新率下,每一幀刷新的間隔時間應為16.67ms。
再由該方法的形參命名deadlineNs可知,傳入的參數表示的是「預抓取工作完成的最後期限」:
voidprefetch(longdeadlineNs){...}綜合一下就是,預抓取的工作必須在下一個垂直同步信號發出之前,也即下一幀開始繪製之前完成。
什麼意思呢?
這是由於從Android 5.0(API等級21)開始,出於提高UI渲染效率的考慮,Android系統引入了RenderThread機制,即「渲染線程」。這個機制負責接管原先主線程中繁重的UI渲染工作,使得主線程可以更加專注於與用戶的交互,從而大幅提高頁面的流暢度。
但這裡有一個問題。
當UI線程提前完成工作,並將一個幀傳遞給RenderThread渲染之後,就會進入所謂的「休眠狀態」,出現了大量的空閒時間,直至下一幀開始繪製之前。如圖所示:
一方面,這些UI線程上的空閒時間並沒有被利用起來,相當於珍貴的線程資源被白白浪費掉;
另一方面,新的列表項進入屏幕時,又需要在UI線程的輸入階段(Input)就完成視圖創建與數據綁定的工作,這會推遲UI線程及RenderThread上的其他工作,如果這些被推遲的工作無法在下一幀開始繪製之前完成,就有可能造成界面上的丟幀卡頓。
GapWorker正是選擇在此時間窗口內安排預拉取的工作,也即把創建和綁定的耗時操作,移到UI線程的空閒時間內完成,與原先的RenderThread並行執行。
但這個預拉取的工作同樣必須在下一幀開始繪製之前完成,否則預拉取的列表項視圖還是會無法被及時地繪製出來,進而導致丟幀卡頓,於是才有了前面表示「最後期限」的傳入參數。
了解完這個參數的含義後,讓我們繼續往下閱讀源碼。
2.1 構建預拉取任務列表voidprefetch(longdeadlineNs){buildTaskList();...}進入prefetch方法後可以看到,預拉取的第一個動作就是先構建預拉取的任務列表,其內部又可分為以下3個事項:
2.1.1 收集預拉取的列表項數據privatevoidbuildTaskList(){//1.收集預拉取的列表項數據finalintviewCount=mRecyclerViews.size();inttotalTaskCount=0;for(inti=0;i<viewCount;i++){RecyclerViewview=mRecyclerViews.get(i);//僅對當前可見的RecyclerView收集數據if(view.getWindowVisibility()==View.VISIBLE){view.mPrefetchRegistry.collectPrefetchPositionsFromView(view,false);totalTaskCount+=view.mPrefetchRegistry.mCount;}}...}staticclassLayoutPrefetchRegistryImplimplementsRecyclerView.LayoutManager.LayoutPrefetchRegistry{...voidcollectPrefetchPositionsFromView(RecyclerViewview,booleannested){...//啟用了預拉取機制if(view.mAdapter!=null&&layout!=null&&layout.isItemPrefetchEnabled()){if(nested){...}else{//基於移動量進行預拉取if(!view.hasPendingAdapterUpdates()){layout.collectAdjacentPrefetchPositions(mPrefetchDx,mPrefetchDy,view.mState,this);}}...}}}publicclassLinearLayoutManagerextendsRecyclerView.LayoutManagerimplementsItemTouchHelper.ViewDropHandler,RecyclerView.SmoothScroller.ScrollVectorProvider{publicvoidcollectAdjacentPrefetchPositions(intdx,intdy,RecyclerView.Statestate,LayoutPrefetchRegistrylayoutPrefetchRegistry){//根據布局方向取水平方向的移動量dx或垂直方向的移動量dyintdelta=(mOrientation==HORIZONTAL)?dx:dy;...ensureLayoutState();//根據移動量正負值判斷移動方向finalintlayoutDirection=delta>0?LayoutState.LAYOUT_END:LayoutState.LAYOUT_START;finalintabsDelta=Math.abs(delta);//收集與預拉取相關的重要數據,並存儲到LayoutStateupdateLayoutState(layoutDirection,absDelta,true,state);collectPrefetchPositionsForLayoutState(state,mLayoutState,layoutPrefetchRegistry);}}這一事項主要是依據RecyclerView滾動的方向,收集即將進入屏幕的、待預拉取的列表項數據,其中,最關鍵的2項數據是:
我們以最簡單的LinearLayoutManager為例,看一下這2項數據是怎樣收集的,其最關鍵的實現就在於前面的updateLayoutState方法。
假定此時我們的手勢是向上滑動的,則其進入的是layoutToEnd == true的判斷:
privatevoidupdateLayoutState(intlayoutDirection,intrequiredSpace,booleancanUseExistingSpace,RecyclerView.Statestate){...if(layoutToEnd){...//步驟1,獲取滾動方向上的第一個項finalViewchild=getChildClosestToEnd();//步驟2,確定待預拉取項的方向mLayoutState.mItemDirection=mShouldReverseLayout?LayoutState.ITEM_DIRECTION_HEAD:LayoutState.ITEM_DIRECTION_TAIL;//步驟3,確認待預拉取項的positionmLayoutState.mCurrentPosition=getPosition(child)+mLayoutState.mItemDirection;mLayoutState.mOffset=mOrientationHelper.getDecoratedEnd(child);//步驟4,確認待預拉取項與RecyclerView可見區域的距離scrollingOffset=mOrientationHelper.getDecoratedEnd(child)-mOrientationHelper.getEndAfterPadding();}else{...}...mLayoutState.mScrollingOffset=scrollingOffset;}
步驟1,獲取RecyclerView滾動方向上的第一項,如圖中①所示:
步驟2,確定待預拉取項的方向。不用反轉布局的情況下是ITEM_DIRECTION_TAIL,該值等於1,如圖中②所示:
步驟3,確認待預拉取項的position值。由滾動方向上的第一項的position值加上步驟2確定的方向值相加得到,對應的是RecyclerView待進入屏幕區域的下一個項,如圖中③所示:
步驟4,確認待預拉取項與RecyclerView可見區域的距離,該值由以下2個值相減得到:
我們用一張圖來說明一下:

首先,圖中的①表示一個完整的屏幕可見區域,其中:
RecyclerView的實際可見區域,是由虛線a和虛線b所包圍的區域,即去除了上下內邊距之後的區域。getEndAfterPadding方法返回的值,即是虛線b所在的位置。
圖中的②是對RecyclerView底部不可見區域的透視圖,假定現在position=2的列表項的底部正好貼合到RecyclerView可見區域的底部,則getDecoratedEnd方法返回的值,即是虛線c所在的位置。
接下來,如果按前面的步驟4進行計算,即用虛線c所在的位置減去的虛線b所在的位置,得到的就是圖中的③,即剛好是列表項的外邊距加上分隔線的高度。
這個結果就是待預拉取列表項與RecyclerView可見區域的距離。隨着向上滑動的手勢這個距離值逐漸變小,直到正好進入RecyclerView的可見區域時變為0,隨後開始預加載下一項。
這2項數據收集到之後,就會調用GapWorker的addPosition方法,以交錯的形式存放到一個int數組類型的mPrefetchArray結構中去:
@OverridepublicvoidaddPosition(intlayoutPosition,intpixelDistance){...//根據實際需要分配新的數組,或以2的倍數擴展數組大小finalintstoragePosition=mCount*2;if(mPrefetchArray==null){mPrefetchArray=newint[4];Arrays.fill(mPrefetchArray,-1);}elseif(storagePosition>=mPrefetchArray.length){finalint[]oldArray=mPrefetchArray;mPrefetchArray=newint[storagePosition*2];System.arraycopy(oldArray,0,mPrefetchArray,0,oldArray.length);}//交錯存放position值與距離mPrefetchArray[storagePosition]=layoutPosition;mPrefetchArray[storagePosition+1]=pixelDistance;mCount++;}
需要注意的是,RecyclerView每次的預拉取並不限於單個列表項,實際上,它可以一次獲取多個列表項,比如使用了GridLayoutManager的情況。
2.1.2 根據預拉取的數據填充任務列表privatevoidbuildTaskList(){...//2.根據預拉取的數據填充任務列表inttotalTaskIndex=0;for(inti=0;i<viewCount;i++){RecyclerViewview=mRecyclerViews.get(i);...LayoutPrefetchRegistryImplprefetchRegistry=view.mPrefetchRegistry;finalintviewVelocity=Math.abs(prefetchRegistry.mPrefetchDx)+Math.abs(prefetchRegistry.mPrefetchDy);//以2為偏移量進行遍歷,從mPrefetchArray中分別取出前面存儲的position值與距離for(intj=0;j<prefetchRegistry.mCount*2;j+=2){finalTasktask;if(totalTaskIndex>=mTasks.size()){task=newTask();mTasks.add(task);}else{task=mTasks.get(totalTaskIndex);}finalintdistanceToItem=prefetchRegistry.mPrefetchArray[j+1];//與RecyclerView可見區域的距離小於滑動的速度,該列表項必定可見,任務需要立即執行task.immediate=distanceToItem<=viewVelocity;task.viewVelocity=viewVelocity;task.distanceToItem=distanceToItem;task.view=view;task.position=prefetchRegistry.mPrefetchArray[j];totalTaskIndex++;}}...}Task是負責「存儲預拉取任務數據」的實體類,其所包含屬性的含義分別是:
從第2個for循環可以看到,其是以2為偏移量進行遍歷,從mPrefetchArray中分別取出前面存儲的position值與距離的。
2.1.3 對任務列表進行優先級排序填充任務列表完畢後,還要依據實際情況對任務進行優先級排序,其遵循的基本原則就是:越可能快進入RecyclerView可見區域的列表項,其預加載的優先級越高。
privatevoidbuildTaskList(){...//3.對任務列表進行優先級排序Collections.sort(mTasks,sTaskComparator);}staticComparator<Task>sTaskComparator=newComparator<Task>(){@Overridepublicintcompare(Tasklhs,Taskrhs){//首先,優先處理未清除的任務if((lhs.view==null)!=(rhs.view==null)){returnlhs.view==null?1:-1;}//然後考慮需要立即執行的任務if(lhs.immediate!=rhs.immediate){returnlhs.immediate?-1:1;}//然後考慮滑動速度更快的intdeltaViewVelocity=rhs.viewVelocity-lhs.viewVelocity;if(deltaViewVelocity!=0)returndeltaViewVelocity;//最後考慮與RecyclerView可見區域距離最短的intdeltaDistanceToItem=lhs.distanceToItem-rhs.distanceToItem;if(deltaDistanceToItem!=0)returndeltaDistanceToItem;return0;}};2.2 調度預拉取任務voidprefetch(longdeadlineNs){...flushTasksWithDeadline(deadlineNs);}預拉取的第二個動作,則是將前面填充並排序好的任務列表依次調度執行:
privatevoidflushTasksWithDeadline(longdeadlineNs){for(inti=0;i<mTasks.size();i++){finalTasktask=mTasks.get(i);if(task.view==null){break;//任務已完成}flushTaskWithDeadline(task,deadlineNs);task.clear();}}privatevoidflushTaskWithDeadline(Tasktask,longdeadlineNs){longtaskDeadlineNs=task.immediate?RecyclerView.FOREVER_NS:deadlineNs;RecyclerView.ViewHolderholder=prefetchPositionWithDeadline(task.view,task.position,taskDeadlineNs);...}2.2.1 嘗試根據position獲取ViewHolder對象進入prefetchPositionWithDeadline方法後,我們終於再次見到了上一篇的老朋友——Recycler,以及熟悉的成員方法tryGetViewHolderForPositionByDeadline:
privateRecyclerView.ViewHolderprefetchPositionWithDeadline(RecyclerViewview,intposition,longdeadlineNs){...RecyclerView.Recyclerrecycler=view.mRecycler;RecyclerView.ViewHolderholder;try{...holder=recycler.tryGetViewHolderForPositionByDeadline(position,false,deadlineNs);...}這個方法我們在上一篇文章有介紹過,作用是嘗試根據position獲取指定的ViewHolder對象,如果從緩存中查找不到,就會重新創建並綁定。
2.2.2 根據綁定成功與否添加到mCacheViews或RecyclerViewPoolprivateRecyclerView.ViewHolderprefetchPositionWithDeadline(RecyclerViewview,intposition,longdeadlineNs){...if(holder!=null){if(holder.isBound()&&!holder.isInvalid()){//如果綁定成功,則將該視圖進入緩存recycler.recycleView(holder.itemView);}else{//沒有綁定,所以我們不能緩存視圖,但它會保留在池中直到下一次預取/遍歷。recycler.addViewHolderToRecycledViewPool(holder,false);}}...returnholder;}接下來,如果「順利地獲取到了ViewHolder對象,且該ViewHolder對象已經完成數據的綁定」,則下一步就該立即回收該ViewHolder對象,緩存到mCacheViews結構中以供重用。
而如果「該ViewHolder對象還未完成數據的綁定,意味着我們沒能在設定的最後期限之前完成預拉取的操作,列表項數據不完整」,因而我們不能將其緩存到mCacheViews結構中,但它會保留在mRecyclerViewPool結構中,以供下一次預拉取或重用。
03預拉取機制與緩存復用機制的怎麼協作的?既然是與緩存復用機制共用相同的緩存結構,那麼勢必會對緩存復用機制的流程產生一定的影響,同樣,讓我們用幾張流程示意圖來演示一下:
假定現在position=5的列表項的底部正好貼合到RecyclerView可見區域的底部,即還要滑動超過「該列表項的外邊距」+「分隔線高度」的距離,下一個列表項才可見。
隨着向上拖動的手勢,GapWorker開始發起預加載的工作,根據前面梳理的流程,它會提前創建並綁定position=6的列表項的ViewHolder對象,並將其緩存到mCacheViews結構中去。




上一篇文章我們講過,mCachedViews結構的默認大小限制為2,考慮上預拉取的情況則還要+1,也即總共能緩存「兩個被移出屏幕的可復用ViewHolder對象」+「一個待進入屏幕的預拉取ViewHolder對象」的。
不知道你們注意到沒有,在步驟5的示意圖中,「可復用ViewHolder對象」是添加到「預拉取ViewHolder對象」前面的,之所以這樣子畫是遵循了源碼中的實現:
//添加之前,先移除最老的一個ViewHolder對象intcachedViewSize=mCachedViews.size();if(cachedViewSize>=mViewCacheMax&&cachedViewSize>0){//當前已經放滿recycleCachedViewAt(0);//移除mCachedView結構中的第1個cachedViewSize--;//總數減1}//默認從尾部添加inttargetCacheIndex=cachedViewSize;//處理預拉取的情況if(ALLOW_THREAD_GAP_WORK&&cachedViewSize>0&&!mPrefetchRegistry.lastPrefetchIncludedPosition(holder.mPosition)){//從最後一個開始,跳過所有最近預拉取的對象排在其前面intcacheIndex=cachedViewSize-1;while(cacheIndex>=0){intcachedPos=mCachedViews.get(cacheIndex).mPosition;//添加到最近一個非預拉取的對象後面if(!mPrefetchRegistry.lastPrefetchIncludedPosition(cachedPos)){break;}cacheIndex--;}targetCacheIndex=cacheIndex+1;}mCachedViews.add(targetCacheIndex,holder);也就是說,雖然緩存復用的對象和預拉取的對象共用同一個mCachedViews結構,但二者是分組存放的,且緩存復用的對象是排在預拉取的對象前面的。這麼說或許還是很難理解,我們用幾張示意圖來演示一下就懂了:
1.假定現在mCachedViews中同時有2種類型的ViewHolder對象,黑色的代表緩存復用的對象,白色的代表預拉取的對象;
2.現在,有另外一個緩存復用的對象想要放到mCachedViews中,按源碼的做法,默認會從尾部添加,即targetCacheIndex = 3:

3.隨後,需要進一步確認放入的位置,它會從尾部開始逐個遍歷,判斷是否是預拉取的ViewHolder對象,判斷的依據是該ViewHolder對象的position值是否存在mPrefetchArray結構中:
booleanlastPrefetchIncludedPosition(intposition){if(mPrefetchArray!=null){finalintcount=mCount*2;for(inti=0;i<count;i+=2){if(mPrefetchArray[i]==position)returntrue;}}returnfalse;}
4.如果是,則跳過這一項繼續遍歷,直到找到最近一個非預拉取的對象,將該對象的索引+1,即targetCacheIndex = cacheIndex + 1,得到確認放入的位置。

5.雖然二者是分組存放的,但二者內部仍是有序的,即按照加入的順序正序排列。
04開啟預拉取機制後的實際效果如何?最後,我們還剩下一個問題,即預拉取機制啟用之後,對於RecyclerView的滑動展示究竟能有多大的性能提升?
關於這個問題,已經有人做過相關的測試驗證,這裡就不再大量貼圖了,只概括一下其方案的整體思路:


最後總結一下:
-- END --
推薦閱讀
RecyclerView 滑動刪除實現思路
一道面試題:ViewPager中的Fragment如何實現懶加載?
貝殼 APP 頁面預加載實現思路