本文正在参与「金石计划」
ViewPager2系列:
- 图解RecyclerView缓存复用机制
- 图解RecyclerView预拉取机制
- 图解ViewPager2离屏加载机制(上)
- 图解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
算出来的这个数值有点夸张,在一个1920×1080分辨率的手机上,都足以让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重建以及视图收回/复用时的处理逻辑。 |