close

「預拉取(prefetch)機制」作為RecyclerView的重要特性之一,常常與緩存復用機制一起配合使用、共同協作,極大地提升了RecyclerView整體滑動的流暢度。

並且,這種特性在ViewPager2中同樣得以保留,對ViewPager2滑動效果的呈現也起着關鍵性的作用。因此,我們ViewPager2系列的第二篇,就是要來着重介紹RecyclerView的預拉取機制。

01預拉取是指什麼?

在計算機術語中,「預拉取」指的是在已知需要某部分數據的前提下,利用系統資源閒置的空檔,預先拉取這部分數據到本地,從而提高執行時的效率。

具體到RecyclerView預拉取的情境則是:

利用UI線程正好處於空閒狀態的時機
預先拉取待進入屏幕區域內的一部分列表項視圖並緩存起來
從而減少因視圖創建或數據綁定等耗時操作所引起的卡頓。
02預拉取是怎麼實現的?

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

接下來我們就循着這張思維導圖,來一一拆解預拉取的工作流程。

1.發起預拉取工作

通過查找對GapWorker對象的引用,我們可以梳理出3個發起預拉取工作的時機,分別是:

RecyclerView被拖動(Drag)時
@OverridepublicbooleanonTouchEvent(MotionEvente){...switch(action){...caseMotionEvent.ACTION_MOVE:{...if(mScrollState==SCROLL_STATE_DRAGGING){...//處於拖動狀態並且存在有效的拖動距離時if(mGapWorker!=null&&(dx!=0||dy!=0)){mGapWorker.postFromTraversal(this,dx,dy);}}}break;...}...returntrue;}
RecyclerView慣性滑動(Fling)時
classViewFlingerimplementsRunnable{...@Overridepublicvoidrun(){...if(!smoothScrollerPending&&doneScrolling){...}else{...if(mGapWorker!=null){mGapWorker.postFromTraversal(RecyclerView.this,consumedX,consumedY);}}}...}
RecyclerView嵌套滾動時
privatevoidnestedScrollByInternal(intx,inty,@NullableMotionEventmotionEvent,inttype){...if(mGapWorker!=null&&(x!=0||y!=0)){mGapWorker.postFromTraversal(this,x,y);}...}2.執行預拉取工作

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項數據是:

「待預拉取項的position值」——用於預加載項位置的確定
「待預拉取項與RecyclerView可見區域的距離」——用於預拉取任務的優先級排序

我們以最簡單的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個值相減得到:

getEndAfterPadding:指的是RecyclerView去除了Padding後的底部位置,並不完全等於RecyclerView的高度。
getDecoratedEnd:指的是由列表項的底部位置,加上列表項設立的外邊距,再加上列表項間隔的高度計算得到的值。

我們用一張圖來說明一下:

首先,圖中的①表示一個完整的屏幕可見區域,其中:

深灰色區域對應的是RecyclerView設立的上下內邊距,即Padding值。
中灰色區域對應的是RecyclerView的列表項分隔線,即Decoration。
淺灰色區域對應的是每一個列表項設立的外邊距,即Margin值。

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是負責「存儲預拉取任務數據」的實體類,其所包含屬性的含義分別是:

position:待預加載項的Position值
distanceToItem:待預加載項與RecyclerView可見區域的距離
viewVelocity:RecyclerView的滑動速度,其實就是滑動距離
immediate:是否立即執行,判斷依據是「與RecyclerView可見區域的距離小於滑動的速度」
view:RecyclerView本身

從第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結構中去。

繼續保持向上拖動,當position=6的列表項即將進入屏幕時,它會按照上一篇緩存復用機制的流程,從mCacheViews結構取出可復用的ViewHolder對象,無需再次經歷創建和綁定的過程,因此滑動的流暢度有了提升。
同時,隨着position=6的列表項進入屏幕,GapWorker也開始了對position=7的列表項的預加載
之後,隨着拖動距離的增大,position=0的列表項也將被移出屏幕,添加到mCachedViews結構中去。

上一篇文章我們講過,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的滑動展示究竟能有多大的性能提升?

關於這個問題,已經有人做過相關的測試驗證,這裡就不再大量貼圖了,只概括一下其方案的整體思路:

測量工具:開發者模式-GPU渲染模式
該工具以滾動顯示的直方圖形式,直觀地呈現渲染出界面窗口幀所需花費的時間
水平軸上的每個豎條即代表一個幀,其高度則表示渲染該幀所花的時間。
綠線表示的是16.67毫秒的基準線。若想維持每秒60幀的正常繪製,則需保證代表每個幀的豎條維持在此線以下。
耗時模擬:在onBindViewHolder方法中,使用Thread.sleep(time)來模擬頁面渲染的複雜度。複雜度的大小,通過time時間的長短來體現。時間越長,複雜度越高。
測試結果:對比同一複雜度下的RecyclerView滑動,未啟用預拉取機制的一側流暢度明顯更低,並且隨着複雜度的增加,在16ms內無法完成渲染的幀數進一步增多,延時更長,滑動卡頓更明顯。

最後總結一下:

預加載機制概念利用UI線程正好處於空閒狀態的時機,預先拉取一部分列表項視圖並緩存起來,從而減少因視圖創建或數據綁定等耗時操作所引起的卡頓。重要類GapWorker:綜合滑動方向、滑動速度、與可見區域的距離等要素,構建並調度預拉取任務列表。Recycler:獲取ViewHolder對象,如果緩存中找不到,則重新創建並綁定結構mCachedViews:順利獲取到了ViewHolder對象,且已完成數據的綁定時放入mRecyclerPool:順利獲取到了ViewHolder對象,但還未完成數據的綁定時放入發起時機被拖動(Drag)、慣性滑動(Fling)、嵌套滾動時完成期限下一個垂直同步信號發出之前

-- END --

推薦閱讀

RecyclerView 滑動刪除實現思路

一道面試題:ViewPager中的Fragment如何實現懶加載?

貝殼 APP 頁面預加載實現思路


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()