实际上,预拉取(prefetch)机制作为RecyclerView的重要特性之一,常常与缓存复用机制一起配合使用、共同协作,极大地提升了RecyclerView整体滑动的流畅度。
并且,这种特性在ViewPager2中同样得以保留,对ViewPager2滑动效果的呈现也起着关键性的作用。因此,我们ViewPager2系列的第二篇,就是要来着重介绍RecyclerView的预拉取机制。
在计算机术语中,预拉取指的是在已知需要某部分数据的前提下,利用系统资源闲置的空档,预先拉取这部分数据到本地,从而提高执行时的效率。
具体到RecyclerView预拉取的情境则是:
正如把缓存复用的实际工作委托给了其内部的Recycler
类一样,RecyclerView也把预拉取的实际工作委托给了一个名为GapWorker
的类,其内部的工作流程,可以用以下这张思维导图来概括:
接下来我们就循着这张思维导图,来一一拆解预拉取的工作流程。
通过查找对GapWorker对象的引用,我们可以梳理出3个发起预拉取工作的时机,分别是:
@Overridepublic boolean onTouchEvent(MotionEvent e) {...switch (action) {...case MotionEvent.ACTION_MOVE: {...if (mScrollState == SCROLL_STATE_DRAGGING) {...// 处于拖动状态并且存在有效的拖动距离时if (mGapWorker != null && (dx != 0 || dy != 0)) {mGapWorker.postFromTraversal(this, dx, dy);}}}break;...}...return true;}
class ViewFlinger implements Runnable {...@Overridepublic void run() {...if (!smoothScrollerPending && doneScrolling) {...} else {...if (mGapWorker != null) {mGapWorker.postFromTraversal(RecyclerView.this, consumedX, consumedY);}}}...}
private void nestedScrollByInternal(int x, int y, @Nullable MotionEvent motionEvent, int type) {...if (mGapWorker != null && (x != 0 || y != 0)) {mGapWorker.postFromTraversal(this, x, y);}...}
GapWorker
是Runnable接口的一个实现类,意味着其执行工作的入口必然是在run方法。
final class GapWorker implements Runnable {@Overridepublic void run() {...prefetch(nextFrameNs);...}
}
在run方法内部我们可以看到其调用了一个prefetch
方法,在进入该方法之前,我们先来分析传入该方法的参数。
// 查询最近一个垂直同步信号发出的时间,以便我们可以预测下一个final int size = mRecyclerViews.size();long latestFrameVsyncMs = 0;for (int i = 0; i < size; i++) {RecyclerView view = mRecyclerViews.get(i);if (view.getWindowVisibility() == View.VISIBLE) {latestFrameVsyncMs = Math.max(view.getDrawingTime(), latestFrameVsyncMs);}}...// 预测下一个垂直同步信号发出的时间long nextFrameNs = TimeUnit.MILLISECONDS.toNanos(latestFrameVsyncMs) + mFrameIntervalNs;prefetch(nextFrameNs);
由该方法的实参命名nextFrameNs
可知,传入的是下一帧开始绘制的时间。
了解过Android屏幕刷新机制的人都知道,当GPU渲染完图形数据并放入图像缓冲区(buffer)之后,显示屏(Display)会等待垂直同步信号(Vsync)发出,随即交换缓冲区并取出缓冲数据,从而开始对新的一帧的绘制。
所以,这个实参同时也表示下一个垂直同步信号(Vsync)发出的时间,这是个预测值,单位为纳秒。由最近一个垂直同步信号发出的时间(latestFrameVsyncMs
),加上每一帧刷新的间隔时间(mFrameIntervalNs
)计算而成。
其中,每一帧刷新的间隔时间是这样子计算得到的:
// 如果取自显示屏的刷新率数据有效,则不采用默认的60fps// 注意:此查询我们只静态地执行一次,因为它非常昂贵(>1ms)Display display = ViewCompat.getDisplay(this);float refreshRate = 60.0f; // 默认的刷新率为60fpsif (!isInEditMode() && display != null) {float displayRefreshRate = display.getRefreshRate();if (displayRefreshRate >= 30.0f) {refreshRate = displayRefreshRate;}}mGapWorker.mFrameIntervalNs = (long) (1000000000 / refreshRate); // 1000000000纳秒=1秒
也即假定在默认60fps的刷新率下,每一帧刷新的间隔时间应为16.67ms。
再由该方法的形参命名deadlineNs
可知,传入的参数表示的是预抓取工作完成的最后期限:
void prefetch(long deadlineNs) {...}
综合一下就是,预抓取的工作必须在下一个垂直同步信号发出之前,也即下一帧开始绘制之前完成。
什么意思呢?
这是由于从Android 5.0(API等级21)开始,出于提高UI渲染效率的考虑,Android系统引入了RenderThread机制,即渲染线程。这个机制负责接管原先主线程中繁重的UI渲染工作,使得主线程可以更加专注于与用户的交互,从而大幅提高页面的流畅度。
但这里有一个问题。
当UI线程提前完成工作,并将一个帧传递给RenderThread渲染之后,就会进入所谓的休眠状态,出现了大量的空闲时间,直至下一帧开始绘制之前。如图所示:
一方面,这些UI线程上的空闲时间并没有被利用起来,相当于珍贵的线程资源被白白浪费掉;
另一方面,新的列表项进入屏幕时,又需要在UI线程的输入阶段(Input)就完成视图创建与数据绑定的工作,这会推迟UI线程及RenderThread上的其他工作,如果这些被推迟的工作无法在下一帧开始绘制之前完成,就有可能造成界面上的丢帧卡顿。
GapWorker正是选择在此时间窗口内安排预拉取的工作,也即把创建和绑定的耗时操作,移到UI线程的空闲时间内完成,与原先的RenderThread并行执行。
但这个预拉取的工作同样必须在下一帧开始绘制之前完成,否则预拉取的列表项视图还是会无法被及时地绘制出来,进而导致丢帧卡顿,于是才有了前面表示最后期限的传入参数。
了解完这个参数的含义后,让我们继续往下阅读源码。
void prefetch(long deadlineNs) {buildTaskList();...}
进入prefetch方法后可以看到,预拉取的第一个动作就是先构建预拉取的任务列表,其内部又可分为以下3个事项:
private void buildTaskList() {// 1.收集预拉取的列表项数据final int viewCount = mRecyclerViews.size();int totalTaskCount = 0;for (int i = 0; i < viewCount; i++) {RecyclerView view = mRecyclerViews.get(i);// 仅对当前可见的RecyclerView收集数据if (view.getWindowVisibility() == View.VISIBLE) {view.mPrefetchRegistry.collectPrefetchPositionsFromView(view, false);totalTaskCount += view.mPrefetchRegistry.mCount;}}...}
static class LayoutPrefetchRegistryImplimplements RecyclerView.LayoutManager.LayoutPrefetchRegistry {...void collectPrefetchPositionsFromView(RecyclerView view, boolean nested) {...// 启用了预拉取机制if (view.mAdapter != null&& layout != null&& layout.isItemPrefetchEnabled()) {if (nested) {...} else {// 基于移动量进行预拉取if (!view.hasPendingAdapterUpdates()) {layout.collectAdjacentPrefetchPositions(mPrefetchDx, mPrefetchDy,view.mState, this);}}...}}}
public class LinearLayoutManager extends RecyclerView.LayoutManager implementsItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider {public void collectAdjacentPrefetchPositions(int dx, int dy, RecyclerView.State state,LayoutPrefetchRegistry layoutPrefetchRegistry) {// 根据布局方向取水平方向的移动量dx或垂直方向的移动量dy int delta = (mOrientation == HORIZONTAL) ? dx : dy;...ensureLayoutState();// 根据移动量正负值判断移动方向final int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;final int absDelta = Math.abs(delta);// 收集与预拉取相关的重要数据,并存储到LayoutStateupdateLayoutState(layoutDirection, absDelta, true, state);collectPrefetchPositionsForLayoutState(state, mLayoutState, layoutPrefetchRegistry);}}
这一事项主要是依据RecyclerView滚动的方向,收集即将进入屏幕的、待预拉取的列表项数据,其中,最关键的2项数据是:
我们以最简单的LinearLayoutManager
为例,看一下这2项数据是怎样收集的,其最关键的实现就在于前面的updateLayoutState
方法。
假定此时我们的手势是向上滑动的,则其进入的是layoutToEnd == true的判断:
private void updateLayoutState(int layoutDirection, int requiredSpace,boolean canUseExistingSpace, RecyclerView.State state) {...if (layoutToEnd) {...// 步骤1,获取滚动方向上的第一个项final View child = 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的实际可见区域,是由虚线a和虚线b所包围的区域,即去除了上下内边距之后的区域。getEndAfterPadding方法返回的值,即是虚线b所在的位置。
图中的②是对RecyclerView底部不可见区域的透视图,假定现在position=2的列表项的底部正好贴合到RecyclerView可见区域的底部,则getDecoratedEnd方法返回的值,即是虚线c所在的位置。
接下来,如果按前面的步骤4进行计算,即用虚线c所在的位置减去的虚线b所在的位置,得到的就是图中的③,即刚好是列表项的外边距加上分隔线的高度。
这个结果就是待预拉取列表项与RecyclerView可见区域的距离。随着向上滑动的手势这个距离值逐渐变小,直到正好进入RecyclerView的可见区域时变为0,随后开始预加载下一项。
这2项数据收集到之后,就会调用GapWorker的addPosition
方法,以交错的形式存放到一个int数组类型的mPrefetchArray
结构中去:
@Overridepublic void addPosition(int layoutPosition, int pixelDistance) {...// 根据实际需要分配新的数组,或以2的倍数扩展数组大小final int storagePosition = mCount * 2;if (mPrefetchArray == null) {mPrefetchArray = new int[4];Arrays.fill(mPrefetchArray, -1);} else if (storagePosition >= mPrefetchArray.length) {final int[] oldArray = mPrefetchArray;mPrefetchArray = new int[storagePosition * 2];System.arraycopy(oldArray, 0, mPrefetchArray, 0, oldArray.length);}// 交错存放position值与距离mPrefetchArray[storagePosition] = layoutPosition;mPrefetchArray[storagePosition + 1] = pixelDistance;mCount++;}
需要注意的是,RecyclerView每次的预拉取并不限于单个列表项,实际上,它可以一次获取多个列表项,比如使用了GridLayoutManager的情况。
private void buildTaskList() {...// 2.根据预拉取的数据填充任务列表int totalTaskIndex = 0;for (int i = 0; i < viewCount; i++) {RecyclerView view = mRecyclerViews.get(i);...LayoutPrefetchRegistryImpl prefetchRegistry = view.mPrefetchRegistry;final int viewVelocity = Math.abs(prefetchRegistry.mPrefetchDx)+ Math.abs(prefetchRegistry.mPrefetchDy);// 以2为偏移量进行遍历,从mPrefetchArray中分别取出前面存储的position值与距离 for (int j = 0; j < prefetchRegistry.mCount * 2; j += 2) {final Task task;if (totalTaskIndex >= mTasks.size()) {task = new Task();mTasks.add(task);} else {task = mTasks.get(totalTaskIndex);}final int distanceToItem = 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值与距离的。
填充任务列表完毕后,还要依据实际情况对任务进行优先级排序,其遵循的基本原则就是:越可能快进入RecyclerView可见区域的列表项,其预加载的优先级越高。
private void buildTaskList() {...// 3.对任务列表进行优先级排序Collections.sort(mTasks, sTaskComparator);}
static Comparator sTaskComparator = new Comparator() {@Overridepublic int compare(Task lhs, Task rhs) {// 首先,优先处理未清除的任务if ((lhs.view == null) != (rhs.view == null)) {return lhs.view == null ? 1 : -1;}// 然后考虑需要立即执行的任务if (lhs.immediate != rhs.immediate) {return lhs.immediate ? -1 : 1;}// 然后考虑滑动速度更快的int deltaViewVelocity = rhs.viewVelocity - lhs.viewVelocity;if (deltaViewVelocity != 0) return deltaViewVelocity;// 最后考虑与RecyclerView可见区域距离最短的int deltaDistanceToItem = lhs.distanceToItem - rhs.distanceToItem;if (deltaDistanceToItem != 0) return deltaDistanceToItem;return 0;}};
void prefetch(long deadlineNs) {...flushTasksWithDeadline(deadlineNs);}
预拉取的第二个动作,则是将前面填充并排序好的任务列表依次调度执行:
private void flushTasksWithDeadline(long deadlineNs) {for (int i = 0; i < mTasks.size(); i++) {final Task task = mTasks.get(i);if (task.view == null) {break; // 任务已完成}flushTaskWithDeadline(task, deadlineNs);task.clear();}}
private void flushTaskWithDeadline(Task task, long deadlineNs) {long taskDeadlineNs = task.immediate ? RecyclerView.FOREVER_NS : deadlineNs;RecyclerView.ViewHolder holder = prefetchPositionWithDeadline(task.view,task.position, taskDeadlineNs);...}
进入prefetchPositionWithDeadline
方法后,我们终于再次见到了上一篇的老朋友——Recycler,以及熟悉的成员方法tryGetViewHolderForPositionByDeadline
:
private RecyclerView.ViewHolder prefetchPositionWithDeadline(RecyclerView view,int position, long deadlineNs) {...RecyclerView.Recycler recycler = view.mRecycler;RecyclerView.ViewHolder holder;try {...holder = recycler.tryGetViewHolderForPositionByDeadline(position, false, deadlineNs);...}
这个方法我们在上一篇文章有介绍过,作用是尝试根据position获取指定的ViewHolder对象,如果从缓存中查找不到,就会重新创建并绑定。
private RecyclerView.ViewHolder prefetchPositionWithDeadline(RecyclerView view,int position, long deadlineNs) {...if (holder != null) {if (holder.isBound() && !holder.isInvalid()) {// 如果绑定成功,则将该视图进入缓存recycler.recycleView(holder.itemView);} else {//没有绑定,所以我们不能缓存视图,但它会保留在池中直到下一次预取/遍历。recycler.addViewHolderToRecycledViewPool(holder, false);}}...return holder;}
接下来,如果顺利地获取到了ViewHolder对象,且该ViewHolder对象已经完成数据的绑定,则下一步就该立即回收该ViewHolder对象,缓存到mCacheViews
结构中以供重用。
而如果该ViewHolder对象还未完成数据的绑定,意味着我们没能在设定的最后期限之前完成预拉取的操作,列表项数据不完整,因而我们不能将其缓存到mCacheViews结构中,但它会保留在mRecyclerViewPool结构中,以供下一次预拉取或重用。
既然是与缓存复用机制共用相同的缓存结构,那么势必会对缓存复用机制的流程产生一定的影响,同样,让我们用几张流程示意图来演示一下:
假定现在position=5的列表项的底部正好贴合到RecyclerView可见区域的底部,即还要滑动超过该列表项的外边距+分隔线高度的距离,下一个列表项才可见。
随着向上拖动的手势,GapWorker开始发起预加载的工作,根据前面梳理的流程,它会提前创建并绑定position=6的列表项的ViewHolder对象,并将其缓存到mCacheViews结构中去。
上一篇文章我们讲过,mCachedViews结构的默认大小限制为2,从这里就可以看出,其这样设计是想刚好能缓存一个被移出屏幕的可复用ViewHolder对象+一个待进入屏幕的预拉取ViewHolder对象的。
不知道你们注意到没有,在步骤5的示意图中,可复用ViewHolder对象是添加到预拉取ViewHolder对象前面的,之所以这样子画是遵循了源码中的实现:
// 添加之前,先移除最老的一个ViewHolder对象int cachedViewSize = mCachedViews.size();if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) { // 当前已经放满recycleCachedViewAt(0); // 移除mCachedView结构中的第1个cachedViewSize--; // 总数减1}// 默认从尾部添加int targetCacheIndex = cachedViewSize;// 处理预拉取的情况if (ALLOW_THREAD_GAP_WORK&& cachedViewSize > 0&& !mPrefetchRegistry.lastPrefetchIncludedPosition(holder.mPosition)) {// 从最后一个开始,跳过所有最近预拉取的对象排在其前面int cacheIndex = cachedViewSize - 1;while (cacheIndex >= 0) {int cachedPos = 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结构中:
boolean lastPrefetchIncludedPosition(int position) {if (mPrefetchArray != null) {final int count = mCount * 2;for (int i = 0; i < count; i += 2) {if (mPrefetchArray[i] == position) return true;}}return false;}
4.如果是,则跳过这一项继续遍历,直到找到最近一个非预拉取的对象,将该对象的索引+1,即targetCacheIndex = cacheIndex + 1,得到确认放入的位置。
5.虽然二者是分组存放的,但二者内部仍是有序的,即按照加入的顺序正序排列。
最后,我们还剩下一个问题,即预拉取机制启用之后,对于RecyclerView的滑动展示究竟能有多大的性能提升?
关于这个问题,已经有人做过相关的测试验证 ,这里就不再大量贴图了,只概括一下其方案的整体思路:
* 该工具以滚动显示的直方图形式,直观地呈现渲染出界面窗口帧所需花费的时间
* 水平轴上的每个竖条即代表一个帧,其高度则表示渲染该帧所花的时间。
* 绿线表示的是16.67毫秒的基准线。若想维持每秒60帧的正常绘制,则需保证代表每个帧的竖条维持在此线以下。
预加载机制 | |
---|---|
概念 | 利用UI线程正好处于空闲状态的时机,预先拉取一部分列表项视图并缓存起来,从而减少因视图创建或数据绑定等耗时操作所引起的卡顿。 |
重要类 | GapWorker:综合滑动方向、滑动速度、与可见区域的距离等要素,构建并调度预拉取任务列表。 |
Recycler:获取ViewHolder对象,如果缓存中找不到,则重新创建并绑定 | |
结构 | mCachedViews:顺利获取到了ViewHolder对象,且已完成数据的绑定时放入 |
mRecyclerPool:顺利获取到了ViewHolder对象,但还未完成数据的绑定时放入 | |
发起时机 | 被拖动(Drag)、惯性滑动(Fling)、嵌套滚动时 |
完成期限 | 下一个垂直同步信号发出之前 |
Android 性能调优系列:https://0a.fit/dNHYY
Android 车载学习指南:https://0a.fit/jdVoy
Android Framework核心知识点笔记:https://0a.fit/acnLL
Android 音视频学习笔记:https://0a.fit/BzPVh
Jetpack全家桶(含Compose):https://0a.fit/GQJSl
Kotlin 入门到精进:https://0a.fit/kdfWR
Flutter 基础到进阶实战:https://0a.fit/xvcHV
Android 八大知识体系:https://0a.fit/mieWJ
Android 中高级面试题锦:https://0a.fit/YXwVq
上一篇:Codeforces Round #841(A-D)
下一篇:云服务未来的发展