前言
最近我在关注ViewPager2
的使用,期间一直基于官方的Demo调试android-viewpager2,今天遇到一个奇葩的问题,捉摸了半天最终找到原因,原来是Demo中布局的问题,事后感觉有必要分享一下这个过程,一来可以巩固View测量的知识,二来希望大家能避开这个坑;
阅读指南
- 代码基于android-viewpager2,看官老爷最好能下载源码亲身体会;
入坑现场
为了观察Fragment
的生命周期,我事先在CardFragment
类中,对生命周期方法进行埋点Log;
异常发生的操作步骤:
横屏进入CardFragmentActivity
或者CardFragmentActivity
竖屏切到横屏,控制台瞬间打印多个Fragment的生命周期Log,场面让人惊呆;
CardFragmentActivity横屏下布局
控制台Log输出
由于Log太长,一屏根本截不完,反正就是很多个Fragment
经历了onCreate
->onDestory
的所有过程;
操作前,只有Fragment2
创建并显示,理论上旋转屏幕之后,只有Fragment2
销毁并重建,不会调用其他Fragment
;现在问题发生在了,旋转之后有一堆Fragment
创建并且销毁,最终保留的也只有Fragment2
,这肯定是个Bug,虽然发生在一行代码都没有改的官方Demo上;
初步原因MATCH_PARENT计算失效
ViewPager2
目前只支持ItemView
的布局参数是MATCH_PARENT
,就是填充父布局的效果;由于ViewPager2
是基于RecyclerView
,理论上每个ItemView
一定会是MATCH_PARENT
,控制一屏只加载一个Item
,但是一旦MATCH_PARENT
计算失效,那么ViewPager2
基本上就是RecyclerView
的效果,瞬间多个Fragment
是可以解释通的;
ViewPager2
测量流程
ViewPager21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//测量mRecyclerView
measureChild(mRecyclerView, widthMeasureSpec, heightMeasureSpec);
int width = mRecyclerView.getMeasuredWidth();
int height = mRecyclerView.getMeasuredHeight();
int childState = mRecyclerView.getMeasuredState();
//宽高计算
width += getPaddingLeft() + getPaddingRight();
height += getPaddingTop() + getPaddingBottom();
//宽高约束
width = Math.max(width, getSuggestedMinimumWidth());
height = Math.max(height, getSuggestedMinimumHeight());
//设置自身高度
setMeasuredDimension(resolveSizeAndState(width, widthMeasureSpec, childState),
resolveSizeAndState(height, heightMeasureSpec,
childState << MEASURED_HEIGHT_STATE_SHIFT));
}
ViewPager2.onMeasure()
优先计算mRecyclerView
的尺寸,所以关注的重点转移到RecyclerView.onMeasure()
上,RecyclerView
对子View
的计算和布局逻辑在LayoutManager
中,所以本例子重要看LinearLayoutManager
,LayoutManager
对子View计算的方法是measureChildWithMargins()
,下面看一下measureChildWithMargins()
方法的调用栈;
主要分析measureChildWithMargins()
代码:
RecyclerView.LayoutManager1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
//获取当前View的Decor(传统理解的分割线)尺寸
final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
widthUsed += insets.left + insets.right;
heightUsed += insets.top + insets.bottom;
//获取宽测量信息
final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
getPaddingLeft() + getPaddingRight()
+ lp.leftMargin + lp.rightMargin + widthUsed, lp.width,
canScrollHorizontally());
//获取高测量信息
final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
getPaddingTop() + getPaddingBottom()
+ lp.topMargin + lp.bottomMargin + heightUsed, lp.height,
canScrollVertically());
//如果需要测量,调用child的测量方法
if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
child.measure(widthSpec, heightSpec);
}
}
获取宽高测量信息的代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30public static int getChildMeasureSpec(int parentSize, int parentMode, int padding,
int childDimension, boolean canScroll) {
int size = Math.max(0, parentSize - padding);
int resultSize = 0;
int resultMode = 0;
if (canScroll) {
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
switch (parentMode) {
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
resultSize = size;
resultMode = parentMode;
break;
case MeasureSpec.UNSPECIFIED:
resultSize = 0;
resultMode = MeasureSpec.UNSPECIFIED;
break;
}
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
resultSize = 0;
resultMode = MeasureSpec.UNSPECIFIED;
}
} else {
//省略
}
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
分析getChildMeasureSpec()
方法,由于ViewPager2
强制设置MATCH_PARENT
,所以childDimension
肯定是MATCH_PARENT
,那么resultMode
是什么呢,通过断点打印输出,这里的parentMode
是MeasureSpec.UNSPECIFIED
和MeasureSpec.EXACTLY
交替出现;
刚开始一直在关注子View计算流程,发现MeasureSpecMode
异常,总是出现MeasureSpec.UNSPECIFIED
和MeasureSpec.EXACTLY
交替,最后直接打印RecyclerView
的onMeasure
输出;
RecyclerView.onMeasure输出日志
在竖屏时,widthMeasureMode
一直都是1073741824(MATCH_PARENT
),但是横屏状态下,widthMeasureMode
在0(UNSPECIFIED
)和MATCH_PARENT
中徘徊;对比差别就是MeasureMode = UNSPECIFIED
,所以问题应该出在MeasureMode = UNSPECIFIED
上;
如何产生的UNSPECIFIED
?
1 | <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
整体布局是LinearLayout
,在布局里面,ViewPager2 layout_width="0dp" layout_weight="1"
,可能是width=0dp && weight=1
造成,扒一扒LinearLayout
测量代码逻辑;
LinearLayout1
2
3
4
5
6
7
8@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mOrientation == VERTICAL) {
measureVertical(widthMeasureSpec, heightMeasureSpec);
} else {
measureHorizontal(widthMeasureSpec, heightMeasureSpec);
}
}
LinearLayout
的onMeasure()
方法分为竖直方向和水平方向,我们这里选择measureHorizontal()
入手;
measureHorizontal()
方法中通过判断lp.width == 0 && lp.weight > 0
断定是否需要过渡加载useExcessSpace
,下面的过渡加载就是采用UNSPECIFIED
方式测量;
为何还要执行一次MATCH_PARENT
测量
这是由于LinearLayout
的measureHorizontal()
针对过渡加载useExcessSpace
的布局,会进行两次测量,第二次就会传递实际的测量模式;
为何UNSPECIFIED
模式下,MATCH_PARENT
会失效
我们暂时只讨论FrameLayout
的情况,如果FrameLayout
的父布局给该FrameLayout
的测量模式是UNSPECIFIED
,尺寸是自身的具体宽高,而且该FrameLayout
的LayoutParams
是MATCH_PARENT
,试问FrameLayout
能测量出准确的MATCH_PARENT
尺寸吗?
FrameLayout
FrameLayout
会测量所有可见View
的尺寸,然后算出最大的尺寸maxWidth
和maxHeight
,自身尺寸的测量调用setMeasuredDimension()
方法,每个Dimension
的设置调用resolveSizeAndState(maxWidth, widthMeasureSpec, childState)
方法;
resolveSizeAndState()1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
//来自父布局建议的模式和尺寸
final int specMode = MeasureSpec.getMode(measureSpec);
final int specSize = MeasureSpec.getSize(measureSpec);
final int result;
switch (specMode) {//父布局建议的模式
case MeasureSpec.AT_MOST:
if (specSize < size) {
result = specSize | MEASURED_STATE_TOO_SMALL;
} else {
result = size;
}
break;
case MeasureSpec.EXACTLY:
result = specSize;
break;
case MeasureSpec.UNSPECIFIED://在这里
default:
result = size;//这个size就是传入的size
}
return result | (childMeasuredState & MEASURED_STATE_MASK);
}
分析resolveSizeAndState()
,如果measureSpec
的specMode=UNSPECIFIED
,结果返回传入的size
,在FrameLayout
中是maxWidth
和maxHeight
,而并不是parent给予的specSize;
为何整体会测量两遍
这是由于FrameLayout
针对MATCH_PARENT
的布局,会进行二次测量,第一次测量为了找到最大尺寸maxsize
,二次测量把用maxsize
从新计算MATCH_PARENT
的子View;
避免入坑
上诉讲解就是为了说明,UNSPECIFIED
会影响MATCH_PARENT
的测量,至少在FrameLayout
上是影响的,FrameLayout
会采取子View的最大尺寸,一旦失去MATCH_PARENT
的意义,ViewPager2
就失去了ItemView
一屏显示一个的特性,所以会出现开头说的瞬间暴增多个Fragment
现象;
由于ViewPager2
配合Fragment
使用时,根布局是FrameLayout
这个无法改变,解决办法就是不允许出现跟滑动方向相同的维度测量上,出现UNSPECIFIED
;
如果父布局是LinearLayout
,横向滑动时要避免layout_width="0dp"和layout_weight="1"
,纵向滑动时要避免layout_height="0dp"和layout_weight="1"
,代码的解决方案很简单,去掉layout_weight="1"
,吧layout_width
设置成match_parent
;
总结
注意ViewPager2
配合Fragment
使用时,一旦发现Fragment
瞬间暴增的情况,可能是Item尺寸测量的不对,造成这个原因要优先想到UNSPECIFIED
,·如果用的LinearLayout
可能是layout_weight="1"
的原因,同理,RecyclerView
+PagerSnapHelper
+match_parent
实现一屏一个Item的方案,也存在这个风险;