本文原作者: 椎锋陷陈,原文发布于: 星际码仔
前言
本文我们将正式进入 ViewPager2 的篇章,并将辅以更加生动易懂的「动态示意图」来进行讲解。
ViewPager2 可讲的内容有很多,今天我们主要介绍 ViewPager2 的「离屏加载机制」,您可能第一次听说这个术语,但在实际开发中,肯定使用过它,因为它对应的配置入口,就是 ViewPager2 的 OffscreenPageLimit 属性。
OffscreenPageLimit
是什么?
OffscreenPageLimit,直译过来是「离屏页面限制值」的意思,该值代表的是在滑动视图中应保留在当前可见页面之外的任一方向上的页面数。
比如,当我们采用水平分页时,该值代表的便是在左右两侧应保留的页面数。
而当我们采用垂直分页时,该值代表的则是在上下两侧应保留的页面数。
保留页面的方式是通过扩展额外的布局空间实现的,以 LinearLayoutManager 为例,其最关键的步骤在于对 calculateExtraLayoutSpace 方法的重写:
/**
* 计算额外的布局空间
*/
@Override
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
@NonNull int[] extraLayoutSpace) {
int pageLimit = getOffscreenPageLimit();
if (pageLimit == OFFSCREEN_PAGE_LIMIT_DEFAULT) {
// 仅在需要时才对屏幕外页面进行自定义预取
super.calculateExtraLayoutSpace(state, extraLayoutSpace);
return;
}
// 计算多pageLimit*2个页面大小的空间
final int offscreenSpace = getPageSize() * pageLimit;
extraLayoutSpace[0] = offscreenSpace;
extraLayoutSpace[1] = offscreenSpace;
}
/**
* 获取单个页面大小
*/
int getPageSize() {
final RecyclerView rv = mRecyclerView;
// 水平分页时,取去除了左右内边距后的RecyclerView宽度
// 垂直分页时,取去除了上下内边距后的RecyclerView高度
return getOrientation() == ORIENTATION_HORIZONTAL
? rv.getWidth() - rv.getPaddingLeft() - rv.getPaddingRight()
: rv.getHeight() - rv.getPaddingTop() - rv.getPaddingBottom();
}
该方法会计算 LinearLayoutManager 应布置的额外空间量 (以像素为单位)。已知默认布置的空间量为单个页面大小,则额外布置的空间量应为 OffscreenPageLimit*2 个单页面大小,计算出来的结果会存储在 int 数组类型的 extraLayoutSpace 结构中,其中:
extraLayoutSpace[0] 应用于顶部或左侧的额外空间;
extraLayoutSpace[1] 应用于底部或右侧的额外空间。
虽然这部分额外创建的页面在当前屏幕上并不可见,但实际已经被添加至我们的视图层次结构中了。这么做可以减少切换分页时花费在视图创建与布局上的时间,从而提升 ViewPager2 滑动时的整体流畅度。
从缓存复用机制到预拉取机制再到现在的离屏加载机制,RecyclerView 与 ViewPager2 在提升滑动流畅度方面真的是做了非常多的努力。
区别在于:
缓存复用机制是通过缓存已创建的页面,以提供给新进入屏幕的页面重用来实现的。
预拉取机制是通过利用 UI 线程空闲的时机,提前创建并缓存下一个待进入屏幕的页面来实现的。
离屏加载机制则是通过扩展额外的布局空间,以提前创建并保留屏幕两侧的页面来实现的。
从调用方法流程上讲,离屏加载机制除了常规的 onCreateViewHolder、onBindViewHolder 方法之外,还会执行一个多 onViewAttachedToWindow 方法,以将页面提前添加至我们的视图层次结构中。
虽然我们一直强调的是 "ViewPager2 的离屏加载机制",但其实,离屏加载机制并不是 ViewPager2 才引入的新特性,作为 ViewPager 的改进版本,ViewPager2 也只是把早已存在于 ViewPager 中的这个特性照搬过来而已,二者的主要区别有以下几点:
对于 OffscreenPageLimit 默认值的设置
对于 OffscreenPageLimit 赋值条件的限制
OffscreenPageLimit 的
默认值设置与赋值条件限制
ViewPager 一直为人所诟病的一个点就是,其设置的 OffscreenPageLimit 默认值为 1,且不允许外部传入低于 1 的修改值,即会「强制开启离屏加载机制」。
// 默认的离屏加载限制值为1
private static final int DEFAULT_OFFSCREEN_PAGES = 1;
public void setOffscreenPageLimit(int limit) {
// 小于默认值的数会被强制设为默认值
if (limit < DEFAULT_OFFSCREEN_PAGES) {
Log.w(TAG, "Requested offscreen page limit " + limit + " too small; defaulting to "
+ DEFAULT_OFFSCREEN_PAGES);
limit = DEFAULT_OFFSCREEN_PAGES;
}
if (limit != mOffscreenPageLimit) {
mOffscreenPageLimit = limit;
populate();
}
}
这也就意味着,在使用 ViewPager 构建的滑动视图中,不管开发者需不需要,都至少会有 1~2 个页面会被离屏加载,而这会导致一系列依赖于 Fragment 生命周期的逻辑被异常执行,进而产生非预期的结果,需要开发者手动实现延迟加载机制。
相比较之下,ViewPager2 设置的 OffscreenPageLimit 默认值则为 -1,也即「默认不开启离屏加载机制」,且对于外部传入的修改值也只要求必须是大于 0 的正数或默认值。
// 默认的离屏加载限制值为-1
public static final int OFFSCREEN_PAGE_LIMIT_DEFAULT = -1;
public void setOffscreenPageLimit(@OffscreenPageLimit int limit) {
// 低于1且非默认值的传参会报异常
if (limit < 1 && limit != OFFSCREEN_PAGE_LIMIT_DEFAULT) {
throw new IllegalArgumentException(
"Offscreen page limit must be OFFSCREEN_PAGE_LIMIT_DEFAULT or a number > 0");
}
mOffscreenPageLimit = limit;
// 触发重新布局操作,以便通过getExtraLayoutSize()方法进行离屏加载
mRecyclerView.requestLayout();
}
另外,ViewPager2 是在 RecyclerView 的基础上构建而成的。因此,即使是默认不开启离屏加载机制,预拉取机制也会正常工作。
而我们前面又讲了,预拉取机制会提前创建并缓存下一个待进入屏幕的页面,但不会添加至我们的视图层次结构中,因此不会像 ViewPager 一样,导致一系列依赖于 Fragment 生命周期的逻辑被异常执行,相当于自动帮我们实现延迟加载机制了。
从以上 2 个默认数值我们可以看到,无论是 ViewPager 还是 ViewPager2,其对于 OffscreenPageLimit 默认值的设置都是比较克制的。实际上,在 setOffscreenPageLimit 方法的注释中,Android 也是建议我们将此限制值保持在较低水平,尤其是当我们的页面具有复杂的布局时。
但实际情况是,大部分的开发者为图方便,往往会将此值设为「页面总数 -1」,也即「默认会离屏加载所有的页面」。
这种做法无疑是很不规范的,为什么说不规范呢?这就引申出我们下一个问题了,即 OffscreenPageLimit 的不同赋值,会对 ViewPager2 产生什么样的影响呢?
不同的 OffscreenPageLimit 值产生的影响
行为表现
OffscreenPageLimit 值为 -1
当 OffscreenPageLimit 值为 -1 时,也即保持默认不开启离屏加载机制,这种情况下只有 RecyclerView 的缓存复用机制和预拉取机制会工作。
1. 当滑动视图初始化完成时,只有 position=0 的页面项会被添加至当前视图层次结构中。
2. 随着我们往左滑动屏幕,预拉取机制会开始工作,提前创建 position=2 的页面项并放入 mCachedView 中。
3. 同时,position=0 的页面项也将随着向左滑动的手势被移出屏幕,并放入 mCachedView 中。
4. 再次向左滑动屏幕,滑动视图会取出预拉取的 position=2 的页面项进行使用,同时开启对 position=3 的页面项的预拉取。
5. 此时,由于还未超过 mCachedView 大小的限制,下一个被移出屏幕的 position=1 的页面项也将放入 mCachedView 中。
6. 第三次向左滑动屏幕,同样,会取出预拉取的 position=3 的页面项进行使用,同时开启对 position=4 的页面项的预拉取。
7. 但是,由于超过了 mCachedView 大小的限制,在下一个被移出屏幕的 position=2 的页面项尝试进入时,会先按照先进先出的顺序,先从 mCachedView 中移出 position=0 的页面项,放入 RecyclerPool 中对应 itemType 的 ArrayList 容器中,然后 position=2 的页面项才顺利进入 mCachedView。
8. 之后的滑动同样遵循这个规律,不再赘述。
OffscreenPageLimit 值为 1
当 OffscreenPageLimit 值为 1 时,也即会在左右两侧各离屏加载 1 个页面。
1. 当滑动视图初始化完成时,由于左侧无更多的页面项,因此只有 position=0 及 position=1 的页面项会被添加至当前视图层次结构中。
2. 随着我们往左滑动屏幕,position=2 的页面项会被添加至当前视图层次结构中,而 position=0 的页面项会继续保留在当前视图层次结构中,同时预拉取机制会开始工作,提前创建 position=3 的页面项并放入 mCachedView 中。
3. 再次向左滑动屏幕,滑动视图会取出预拉取的 position=3 的页面项添加至当前视图层次结构中,而 position=1 的页面项会继续保留在当前视图层次结构中,并开启对 position=4 的页面项的预拉取。
4. 同时,position=0 的页面项也将随着向左滑动的手势被移出屏幕,并放入 mCachedView 中。
5. 第三次向左滑动屏幕,同样,会取出预拉取的 position=4 的页面项添加至当前视图层次结构中,并保留 position=2 的页面项在当前视图层次结构中,同时开启对 position=5 的页面项的预拉取。
6. 此时,由于还未超过 mCachedView 大小的限制,下一个被移出屏幕的 position=1 的页面项也将放入 mCachedView 中。
7. 第四次向左滑动屏幕,同样,会取出预拉取的 position=5 的页面项添加至当前视图层次结构中,并保留 position=3 的页面项在当前视图层次结构中,同时开启对 position=6 的页面项的预拉取。
8. 但是,由于超过了 mCachedView 大小的限制,在下一个被移出屏幕的 position=2 的页面项尝试进入时,会先按照先进先出的顺序,先从 mCachedView 中移出 position=0 的页面项,放入 RecyclerPool 中对应 itemType 的 ArrayList 容器中。
OffscreenPageLimit 值为 3
当 OffscreenPageLimit 值为 3 时,也即会在左右两侧各离屏加载 3 个页面。
1. 当滑动视图初始化完成时,由于左侧无更多的页面项,因此只有 position=0 至 position=3 的页面项会被添加至当前视图层次结构中。
2. 随着我们往左滑动屏幕,position=4 的页面项会被添加至当前视图层次结构中,而 position=0 的页面项会继续保留在当前视图层次结构中,同时预拉取机制会开始工作,提前创建 position=5 的页面项并放入 mCachedView 中。
3. 再次向左滑动屏幕,滑动视图会取出预拉取的 position=5 的页面项添加至当前视图层次结构中,而 position=1 的页面项会继续保留在当前视图层次结构中,并开启对 position=6 的页面项的预拉取。
4. 第三次向左滑动屏幕,滑动视图会取出预拉取的 position=6 的页面项添加至当前视图层次结构中,而 position=2 的页面项会继续保留在当前视图层次结构中。也即这个时候,所有的页面项已经都被添加至当前视图层次结构中了。
5. 第四次向左滑动屏幕,由于超出了 OffscreenPageLimit 值,position=0 的页面项将随着向左滑动的手势被移出屏幕,并放入 mCachedView 中。
△ limit 3 第五次滑动.gif
6. 第五次向左滑动屏幕,此时,由于还未超过 mCachedView 大小的限制,下一个被移出屏幕的 position=1 的页面项也将放入 mCachedView 中。
△ limit 3 第六次滑动.gif
7. 第六次向左滑动屏幕,但是,由于超过了 mCachedView 大小的限制,在下一个被移出屏幕的 position=2 的页面项尝试进入时,会先按照先进先出的顺序,先从 mCachedView 中移出 position=0 的页面项,放入 RecyclerPool 中对应 itemType 的 ArrayList 容器中 (勘误: 应仍放入 CachedView 中)。
OffscreenPageLimit 值为页面总数 -1
当 OffscreenPageLimit 值为页面总数 -1 时,也即在滑动视图初始化完成时就已经离屏加载所有的页面了,这种情况下 RecyclerView 的缓存复用机制和预拉取机制完全没有工作的机会。
虽然设置更高的 OffscreenPageLimit 值,可以更好地提升 ViewPager2 滑动时的流畅度,但由于需要在初始化阶段同时创建多个页面项,意味着将花费更久的创建时间,页面项内容也将更慢显示,同时,由于两侧有更多的页面项被保留而不走缓存复用流程,意味着应用会占用更多的内存,且这些问题将随着页面复杂度提升更加突出。
为了更直观地展示不同的 OffscreenPageLimit 值对应用的性能影响,我们将从白屏时间、流畅度、占用内存三个维度来进行横向对比:
性能影响
白屏时间
可以看到,随着 OffscreenPageLimit 值的增加,在滑动视图的初始化阶段,会有更多的页面项需要被创建并被添加至当前的视图层次结构中,白屏时间也随之延长。
流畅度
参考上一篇的做法,我们同样在 FragmentStateAdapter 中对 Fragment 的视图准备工作做了延迟,以在 GPU 渲染模式中展示更加清晰的柱状图:
OffscreenPageLimit 值为 1 时,虽然可以离屏加载下一个页面,但由于每次滑动还要执行预拉取的工作,因此对于流畅度的提升不是很明显。
OffscreenPageLimit 值为 3 时,即每次都会保留当前屏幕两侧的各 3 个页面项,在滑动到中间位置时,对于流畅度的提升是最大的,此时无论是往前滑还是往后滑,都无需再执行页面项的创建工作,即使滑到边界也可以利用缓存复用机制来重用视图。
OffscreenPageLimit 值为 6 时,也即在滑动视图初始化完成时就已经离屏加载所有的页面了,每次的滑动就相当于只是在当前的视图层次结构中进行位移,因此全程的流畅度都有极大的提升。
内存占用
可以看到,随着 OffscreenPageLimit 值的增加,在滑动视图的初始化阶段,会有更多的 Fragment 对象驻留在内存中。
同时,由于 OffscreenPageLimit 值会保留当前屏幕两侧的页面项,因此,滑动到中间位置时,OffscreenPageLimit 值为 1 的情况最多会保留 3 个 Fragment 对象,而 OffscreenPageLimit 值为 3 的情况最多会保留 7 个 Fragment 对象。
但在其他位置时,它会将超出 OffscreenPageLimit 值限制的页面将从视图层次结构中移除,并交由 RecyclerView 的缓存复用机制处理,同往常一样回收 ViewHolders 对象以供重用。
OffscreenPageLimit 值
取多大比较合适?
现在我们知道了,当 OffscreenPageLimit 值设得过大,比如页面总数 -1 时,会给应用带来比较大的内存压力,特别是在部分低端机型上。
而 OffscreenPageLimit 值设得过小,比如 1 时,又无法发挥出离屏加载机制提高页面滑动流畅度的优势。
一般来讲,同时保持 3-4 个页面项处于活动状态是一个比较合适的值,一方面,可以提高用户来回翻页时的流畅度,另一方面又不会给应用带来太大的内存压力。当然,还需要我们自己维护好 Fragment 重建以及视图回收/复用时的处理逻辑。
最好的情况下,还是希望能够根据应用当前的内存使用情况,对该值进行动态调整,在行为表现与性能影响上取一个平衡点。
但如果多个页面项之间存在互斥关系,同时处于活动状态可能影响业务的判断时,保持 OffscreenPageLimit 为默认值,也即默认关闭离屏加载机制,只让预拉取机制与缓存复用机制工作,也许是个更好的选择。
后记
讲到这里,相信您对 ViewPager2 的离屏加载机制已经有了一定的认识,但不知道您发现没有,我们全文讲的都是 ViewPager2 顺序依次翻页的情况,但在实际运用中,我们常常会搭配 TabLayout,提供点击标签页跳转到指定页面项的功能。
而当增加了这一种新的交互方式后,问题的维度再一次上升了,我们会发现离屏加载机制的行为逻辑又有所不同了。
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。
点击屏末 | 阅读原文 | 即刻报名参与 "开发者说·DTalk"