本文正在参加「金石计划」
ViewPager2系列:
在文章开始之前,有一个问题想要问你:
在一个由TabLayout + ViewPager2组合而成的滑动视图中,当我们点击标签页跳转到某个指定页面时,你是否想过,ViewPager2是怎么知道其要滑动到的坐标位置并实现流畅的滑动动画的呢?
如果你回答不了这个问题,那么当你遇到一些因滑动视图来回切换而产生的奇怪现象时,你可能会感到无从下手。
为了帮助你理解这种交互背后的行为逻辑,本文将结合源码分析与动图演示两种形式来讲解,让你对滑动视图流畅动画的巧妙设计有更深入的了解。
照例,先奉上思维导图一张,方便复习:
在上一篇文章的结尾部分,我们提到,当增加TabLayout这一种新的交互方式后,会发现ViewPager2离屏加载机制的行为逻辑又有所不同了。这里先总结出两者的主要不同点,再来逐一地进行解释和分析:
- 默认在滚动方向上离屏加载一页:当以点击标签页的方式跳转时,默认会在滑动方向上额外离屏加载多一个页面项
- 距离目标过远时会先预跳再长跳:当距离目标位置超过3页时,会先预跳到targetPos-3的位置,再执行平滑滚动的动画
默认在滚动方向上离屏加载1页
经过上一篇文章的讲解,我们已经知道,ViewPager2设置的OffscreenPageLimit
默认值为-1,也即默认不开启离屏加载机制。在按顺序依次切换这种交互场景下,每次都只会有一个页面项被添加至当前的视图层次结构中。
但是,在改用成了点击标签页跳转这种交互方式后,情况发生了变化。
至于是什么变化,让我们从源码中找到答案。
同样,以LinearLayoutManager
为例,让我们再次回顾ViewPager2对于calculateExtraLayoutSpace
方法的重写:
private class LinearLayoutManagerImpl extends LinearLayoutManager {
/**
* 计算额外的布局空间
*/
@Override
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
@NonNull int[] extraLayoutSpace) {
int pageLimit = getOffscreenPageLimit();
if (pageLimit == OFFSCREEN_PAGE_LIMIT_DEFAULT) {
// 当OffscreenPageLimit为默认值时,会调用回父类也即LinearLayoutManager的calculateExtraLayoutSpace方法
super.calculateExtraLayoutSpace(state, extraLayoutSpace);
return;
}
...
}
}
可以看到,当OffscreenPageLimit为默认值时,会调用回父类也即LinearLayoutManager的calculateExtraLayoutSpace方法:
public class LinearLayoutManager extends RecyclerView.LayoutManager implements
ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider {
/**
* 计算额外的布局空间
*/
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
@NonNull int[] extraLayoutSpace) {
int extraLayoutSpaceStart = 0;
int extraLayoutSpaceEnd = 0;
// 获取LayoutManager应布置的额外空间量
int extraScrollSpace = getExtraLayoutSpace(state);
...
}
}
在此方法中,首先会调用getExtraLayoutSpace
方法,获取LayoutManager应布置的额外空间量:
/**
* 获取应布置的额外空间量
*/
protected int getExtraLayoutSpace(RecyclerView.State state) {
if (state.hasTargetScrollPosition()) {
// 当前有要滚动到的目标位置,根据滚动的方向获取应布局的总空间量
return mOrientationHelper.getTotalSpace();
} else {
return 0;
}
}
此时,区别就在getExtraLayoutSpace这个方法中体现了:
hasTargetScrollPosition
这个方法返回true,表示当前有要滚动到的目标位置,点击标签页跳转就属于这种情况。
毫无疑问,它进入了第一个条件语句,接下来就是调用getTotalSpace
方法,根据滚动的方向获取应布局的总空间量了。这里我们只考虑水平滚动的情况,则应关注的是createHorizontalHelper
方法的重载实现:
public static OrientationHelper createHorizontalHelper(
RecyclerView.LayoutManager layoutManager) {
return new OrientationHelper(layoutManager) {
/**
* 根据滚动的方向获取应布局的总空间量
*/
@Override
public int getTotalSpace() {
return mLayoutManager.getWidth() - mLayoutManager.getPaddingLeft()
- mLayoutManager.getPaddingRight();
}
}
}
这里简单理解就是返回了正常一页的宽度。我们可以重载此方法以实现我们的自定义的加载策略,比如返回2页或3页的宽度。但是,布置不可见的元素通常会带来显着的性能成本,这个在我们上一篇文章里也有讲过。
接下来再次回到LinearLayoutManager的calculateExtraLayoutSpace方法:
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
@NonNull int[] extraLayoutSpace) {
...
// 根据布局的填充方向,决定将应布置的额外空间量赋值给哪一个变量
if (mLayoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
extraLayoutSpaceStart = extraScrollSpace;
} else {
extraLayoutSpaceEnd = extraScrollSpace;
}
extraLayoutSpace[0] = extraLayoutSpaceStart;
extraLayoutSpace[1] = extraLayoutSpaceEnd;
}
这里会根据布局的填充方向,决定将应布置的额外空间量是赋值给extraLayoutSpaceStart
还是extraLayoutSpaceEnd
。二者只能有一个被赋值,另外一个保持为0.
这就是我们所说的“默认会在滑动方向上额外离屏加载多一个页面项”。这么做有两个目的:
- 提前获知滚动目标坐标位置:额外布置的内容有助于LinearLayoutManager提前获知其距离要滚动到的目标的坐标位置还有多远,以实现尽早地平滑地减速。
- 连续滚动时动画更加平滑流畅:当滚动的动作是连续的时,额外布置的内容有助于LinearLayoutManager实现更加平滑而流畅的动画。
该怎么理解呢?这就又回到了我们开头提的那个问题了:
当我们点击标签页跳转到某个指定页面时,ViewPager2是怎么知道其要滑动到的坐标位置并实现流畅的滑动动画的呢?
答案,一言以蔽之:
车到山前必有路,柳暗花明又一村
用更加通俗易懂的语言来解释就是:
先设立一个“小目标”,然后滚动起来再说,等确定了要滚动到的坐标位置之后,再减速停下来。
是不是有点违反你的认知?听完我下面结合源码的分析,你就懂了。
设立“小目标”
首先,当我们以点击标签页这一动作为切入点开始源码分析,你会发现一个这么长的调用链:
这里我们只需要关注最核心的ViewFlinger#run
方法,这个方法是滑动视图中几项重要工作的发起点,包括布局、滚动以及预拉取。
在该方法内部,当SmoothScroller
(平滑滚动器)已启动但尚未收到第一个动画回调时,它会主动触发一个滚动距离为0的回调:
class ViewFlinger implements Runnable {
@Override
public void run() {
...
// 已启动但尚未收到第一个动画回调,主动触发一个回调
SmoothScroller smoothScroller = mLayout.mSmoothScroller;
if (smoothScroller != null && smoothScroller.isPendingInitialRun()) {
smoothScroller.onAnimation(0, 0);
}
...
}
其回调的方法onAnimation
会进入一个if块的执行,该if块会使 LinearLayoutManager以既定的方向滚动 1 个像素的距离,从而促使 LinearLayoutManager提前绘制后两个页面项的视图,为什么会是两个页面项前面已经解释过了。
public abstract static class SmoothScroller {
void onAnimation(int dx, int dy) {
...
if (mPendingInitialRun && mTargetView == null && mLayoutManager != null) {
PointF pointF = computeScrollVectorForPosition(mTargetPosition);
if (pointF != null && (pointF.x != 0 || pointF.y != 0)) {
// 使 LinearLayoutManager以既定的方向滚动 1 个像素的距离,从而促使 LinearLayoutManager提前绘制后两个页面项的视图
recyclerView.scrollStep(
(int) Math.signum(pointF.x),
(int) Math.signum(pointF.y),
null);
}
}
...
}
}
那又是基于什么原因,要提前绘制后两个页面项的视图呢?这是为了在下一步的初始预估滚动之前,尝试提前找到要滚动到的目标视图,从而确认要滚动的实际距离,防止初始滚动的距离超过视图本身。让我们继续往下看:
SmoothScroller的每次滚动都会回调onSeekTargetStep
方法,直到在布局中找到目标视图的位置才停止回调:
public abstract static class SmoothScroller {
void onAnimation(int dx, int dy) {
...
if (mRunning) {
// 每次滚动都会回调`onSeekTargetStep`方法,直到在布局中找到目标视图的位置才停止回调
onSeekTargetStep(dx, dy, recyclerView.mState, mRecyclingAction);
...
}
...
}
}
在此方法中,SmoothScroller会检查滚动的距离dx、dy,如果滚动的距离需要更改,则会提供一个新的RecyclerView.SmoothScroller.Action
对象以定义下一次滚动的行为:
public class LinearSmoothScroller extends RecyclerView.SmoothScroller {
/** 为了搜寻目标视图而触发的滚动距离,单位为像素 */
private static final int TARGET_SEEK_SCROLL_DISTANCE_PX = 10000;
/** 为了搜寻目标视图而触发的额外滚动比率 */
private static final float TARGET_SEEK_EXTRA_SCROLL_RATIO = 1.2f;
@Override
protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) {
...
mInterimTargetDx = clampApplyScroll(mInterimTargetDx, dx);
mInterimTargetDy = clampApplyScroll(mInterimTargetDy, dy);
// 检查滚动的距离dx、dy,看滚动的距离是否需要更改
if (mInterimTargetDx == 0 && mInterimTargetDy == 0) {
updateActionForInterimTarget(action);
}
...
}
protected void updateActionForInterimTarget(Action action) {
...
mInterimTargetDx = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.x);
mInterimTargetDy = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.y);
final int time = calculateTimeForScrolling(TARGET_SEEK_SCROLL_DISTANCE_PX);
// 提供一个新的`RecyclerView.SmoothScroller.Action`对象以定义下一次滚动的行为
action.update((int) (mInterimTargetDx * TARGET_SEEK_EXTRA_SCROLL_RATIO),
(int) (mInterimTargetDy * TARGET_SEEK_EXTRA_SCROLL_RATIO),
(int) (time * TARGET_SEEK_EXTRA_SCROLL_RATIO), mLinearInterpolator);
}
}
为了搜寻要滚动到的目标视图,SmoothScroller会触发一个比实际目标更远的滚动距离,以防止滚动过程的UI卡顿。
如果按源码里的算法,则在前面的初始阶段因那1个像素触发的预估滚动距离应是:
TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.x * TARGET_SEEK_EXTRA_SCROLL_RATIO = 10000 * 1px * 1.2f = 12000px
算出来的这个数值有点夸张,在一个1920x1080分辨率的手机上,都足以让ViewPager2滑动超过11个页面项的距离了。但莫要担心,接下来会我们持续跟踪行进的距离,并且当搜寻到目标视图后,就会对这个滚动的距离进行修正。
滚动起来
计算出预估的滚动距离后,我们就会调用Action#runIfNecessary
,进而调用ViewFlinger#smoothScrollBy
方法来实际执行平滑滚动的动画了,并在随后post一个Runnable再次执行ViewFlinger#run
方法。
public abstract static class SmoothScroller {
void onAnimation(int dx, int dy) {
...
if (mRunning) {
mRecyclingAction.runIfNecessary(recyclerView);;
...
}
...
}
}
public static class Action {
void runIfNecessary(RecyclerView recyclerView) {
if (mChanged) {
recyclerView.mViewFlinger.smoothScrollBy(mDx, mDy, mDuration, mInterpolator);
...
mChanged = false;
}
}
}
class ViewFlinger implements Runnable {
public void smoothScrollBy(int dx, int dy, int duration,
@Nullable Interpolator interpolator) {
...
// 实际执行平滑滚动的动画
mOverScroller.startScroll(0, 0, dx, dy, duration);
...
// post一个Runnable再次执行`ViewFlinger#run`方法
postOnAnimation();
}
}
确定滚动位置
这里假设我们想跳转到的是页面1,则由于在上一轮我们已经提前绘制了后两个页面项(即页面1,页面2)的视图,也即我们已经搜寻到了目标视图,因此在这一轮的onAnimation方法回调中我们会进入这样一个if块:
public abstract static class SmoothScroller {
void onAnimation(int dx, int dy) {
...
// 搜寻到目标视图
if (mTargetView != null) {
// 验证目标位置
if (getChildPosition(mTargetView) == mTargetPosition) {
onTargetFound(mTargetView, recyclerView.mState, mRecyclingAction);
...
} else {
...
}
}
...
}
如果验证目标位置正确,则将执行onTargetFound
回调,正是在这个回调里修正实际应滚动的距离。
public class LinearSmoothScroller extends RecyclerView.SmoothScroller {
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
...
if (time > 0) {
// 修正实际应滚动的距离
action.update(-dx, -dy, time, mDecelerateInterpolator);
}
}
}
减速停止
随后,会用修正后的距离,继续执行平滑滚动的动画。并在最后重置mTargetPosition、清除对LayoutManager和RecyclerView引用以避免潜在的内存泄漏、通知各个注册的动画回调SmoothScroller滚动已停止。
public abstract static class SmoothScroller {
void onAnimation(int dx, int dy) {
...
if (mTargetView != null) {
if (getChildPosition(mTargetView) == mTargetPosition) {
...
// 执行平滑滚动的动画
mRecyclingAction.runIfNecessary(recyclerView);
// 停止
stop();
} else {
...
}
}
...
}
}
protected final void stop() {
if (!mRunning) {
return;
}
mRunning = false;
onStop();
mRecyclerView.mState.mTargetPosition = RecyclerView.NO_POSITION;
mTargetView = null;
// 重置mTargetPosition
mTargetPosition = RecyclerView.NO_POSITION;
mPendingInitialRun = false;
// 通知各个注册的动画回调SmoothScroller滚动已停止
mLayoutManager.onSmoothScrollerStopped(this);
// 清除引用以避免潜在的内存泄漏 smooth scroller
mLayoutManager = null;
mRecyclerView = null;
}
下面让我们通过动图演示来完整还原这整个流程:
- 当滑动视图初始化完成时,只有页面0会被添加至当前视图层次结构中。
- 随着我们点击标签页,在滚动开始的初始阶段,会先在滚动方向上移动1个像素的距离,这会促使页面1被提前加载出来,同时额外离屏加载多一个页面2。
- 由于暂时不确定目标视图的具体位置,因此,滑动视图会先触发一个比实际目标更远的预估滚动距离,随后开始执行平滑滚动的动画。
- 接下来,由于我们已经提前加载了页面1,目标视图的具体位置已可以确定,因而我们会修正实际应滚动的距离,随后继续执行平滑滚动的动画,最后减速停止。
-
在此过程中,预拉取的工作也会正常进行,按我们在系列第二篇分析的预拉取流程,此时预拉取的应是页面3。
-
同时,页面0也将跟随向左的平滑滚动动画被移出屏幕,并放入mCachedView中。
——先不忙着结束,假设一开始我们想跳转到的是页面3,则情况又会有什么不同呢?首先,前三个步骤几乎完全相同,主要区别就出现在步骤4:
接下来,由于目标视图(即页面3)仍未被加载出来,因此滚动不会停止,mTargetPosition不会被重置,hasTargetScrollPosition方法仍返回true,因此,页面0和页面1会随着滑动继续进行被回收,页面3也会随着滑动继续进行被离屏加载出来。
之后,才又衔接上了上面的步骤4,确定了目标视图的位置,修正实际应滚动的距离,随后执行平滑滚动,最后减速停止,并预加载页面4及回收页面2。
距离目标过远时会先预跳再长跳
透过以上流程,你可能会发现,虽然缓存复用机制、预拉取机制、离屏加载机制都在此流程中各司其职,但其中的大部分工作都只能算是执行平滑滚动动画过程中的副产物,我们真正想要加载并展现的其实只是页面3。
这种情况在总页面数比较少时还问题不大,一旦总页数多了起来,问题也随之暴露:一方面,大量不必要的工作会额外消耗资源,另一方面,动画的展现效果也将不符合预期。
思考一下,假设动画平均时长不变,随着页面变多,总动画时长也将变长,动画过久的话体验肯定不好;而假设动画总时长不变,随着页面变多,动画平均时长将变短,动画过快的话体验也不好。
于是,为了防止这种情况,ViewPager2设计了一种预跳机制,也即为了保证滑动动画的整体效果,会先预跳到附近的项目再进行长跳:
public final class ViewPager2 extends ViewGroup {
void setCurrentItemInternal(int item, boolean smoothScroll) {
...
// 为了平滑滚动,会先预跳到附近的项目再进行长跳
if (Math.abs(item - previousItem) > 3) {
mRecyclerView.scrollToPosition(item > previousItem ? item - 3 : item + 3);
mRecyclerView.post(new SmoothScrollToPosition(item, mRecyclerView));
} else {
mRecyclerView.smoothScrollToPosition(item);
}
}
}
其余的流程则与上一节的区别不大,但为了清晰还原预跳机制及之后的整个流程,我们同样会以动图形式来演示。
假设我们要跳转到的是页面5:
- 当滑动视图初始化完成时,只有页面0会被添加至当前视图层次结构中。
- 由于页面5距离页面1超过3页,因此会先预跳到页面2的位置,页面2因此被添加至当前视图层次结构中。随后,页面0被回收,滑动视图准备执行平滑滚动的动画。
-
在滚动开始的初始阶段,会先在滚动方向上移动1个像素的距离,这会促使页面3被提前加载出来,同时额外离屏加载多一个页面4。
-
由于暂时不确定目标视图的具体位置,滑动视图会先触发一个比实际目标更远的预估滚动距离,随后开始执行平滑滚动的动画。
-
在此过程中,预拉取的工作也会正常进行,此时预拉取的应是页面5。
- 接下来,由于发现页面5仍未被加载出来,因此滚动不会停止,随着滑动的继续进行,页面2会被回收,页面5也会取之前预拉取好的内容并被离屏加载出来。
-
然后,随着页面4完整出现在屏幕中,页面3也会被回收,但由于超过了mCachedView大小的限制,页面3尝试进入时,会先按照先进先出的顺序,先从mCachedView中移出页面0,放入RecyclerPool中对应itemType的ArrayList容器中。
-
在此过程中,预拉取的工作也会正常进行,此时预拉取的应是页面6。
-
而随着页面5被离屏加载出来,目标视图的具体位置已可以确定,因此我们会修正实际应滚动的距离,随后继续执行平滑滚动的动画,最后减速停止。
- 同时,页面4也将跟随向左的平滑滑动动画被移出屏幕,并且,同样由于超过了mCachedView大小的限制,会先移除页面2再放入页面4。
关闭了平滑滚动动画的情况
在实际的项目开发中,有时候并不需要开启平滑滚动的动画效果,这种情况常出现在首页的多页面视图中。
要关闭平滑滚动的动画效果,只需要使用TabLayoutMediator
的另一个带smoothScroll
参数的构造函数并传入false即可:
public TabLayoutMediator(
@NonNull TabLayout tabLayout,
@NonNull ViewPager2 viewPager,
boolean autoRefresh,
boolean smoothScroll,
@NonNull TabConfigurationStrategy tabConfigurationStrategy) {
...
}
而既然关闭了平滑滚动的动画效果,以上提到的那些问题也将不复存在,流程将得到极大的简化,3大机制中只有缓存复用机制会继续工作,如图:
总结
好了,以上就是ViewPager2离屏加载机制的全部内容了,照例,我们结合上篇内容来最后总结一下:
离屏加载机制 | |
---|---|
目的 | 减少切换分页时花费在视图创建与布局上的时间,从而提升ViewPager2滑动时的整体流畅度 |
方式 | 扩展额外的布局空间,以提前创建并保留屏幕两侧的页面来实现的 |
关键参数 | mOffscreenPageLimit,默认值为-1,也即默认不开启离屏加载机制。 |
性能影响 | 白屏时间、流畅度、内存占用等 |
搭配TabLayout | 1. 默认在滑动方向上离屏加载多一页;2. 距离目标过远时会先预跳再长跳 |
建议点 | 1. 根据应用当前的内存使用情况,对mOffscreenPageLimit值进行动态调整,在行为表现与性能影响上取一个平衡点。2. 需要维护好Fragment重建以及视图回收/复用时的处理逻辑。 |
少侠,请留步!若本文对你有所帮助或启发,还请:
- 点赞👍🏻,让更多的人能看到!
- 收藏⭐️,好文值得反复品味!
- 关注➕,不错过每一次更文!
===> 技术号:「星际码仔」💪
你的支持是我继续创作的动力,感谢!🙏