误删两行代码引发的测量View问题

今天解决的一个bug很有意思,涉及View测量流程,故记之。

bug描述:

Launcher中,当用户点击任一全屏App返回后,GridLayout 中的 ItemView 大小测量不正常了。如下图所示:

Launcher的布局结构是一个ViewPager,每一页是一个GridLayout,以下是对应的代码(也可以忽略不看)。

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class AppPagerAdapter extends PagerAdapter {
...

// 刷新每一个页的内容
// 一个页面是简单的 FrameLayout > GridLayout 结构
private void refreshItemLayout(int position, ViewGroup c_item_layout) {
AppGridItemView c_app_view;
GridLayout c_grid_layout = (GridLayout) c_item_layout.getChildAt(0);
for (int c_page_index = 0; c_page_index < m_page_count_per_page; c_page_index++) {
// 根据在第几页、第几项确定对应的app
int c_app_index = (position * m_page_count_per_page) + c_page_index;
View view = c_grid_layout.getChildAt(c_page_index);
if (view == null || !(view instanceof AppGridItemView)) {
// 第一个创建 GridLayout 中的 ItemView
c_app_view = (AppGridItemView) View.inflate(this.m_context, R.layout.app_item_layout, (ViewGroup) null);
c_app_view.setShortCutClickListener(this.m_listener);
GridLayout.LayoutParams c_para = new GridLayout.LayoutParams();
// 关键是这里,误删掉了这两行,导致出现了Bug
// c_para.width = 0;
// c_para.height = 0;
c_para.columnSpec = GridLayout.spec(c_page_index % m_page_colum_num, 1.0f);
c_para.rowSpec = GridLayout.spec(c_page_index / m_page_colum_num, 1.0f);
c_app_view.setLayoutParams(c_para);
c_grid_layout.addView(c_app_view);
} else {
c_app_view = (AppGridItemView) view;
}
if (c_app_index < this.m_app_list.size()) {
c_app_view.setAppInfo(this.m_app_list.get(c_app_index));
} else {
c_app_view.setAppInfo((AppInfo) null);
}
}
}

// ViewPager中创建一个页面
public Object instantiateItem(@NonNull ViewGroup container, int position) {
// 复用整个页面View,其结构(FrameLayout > GridLayout)
FrameLayout c_item_layout = getFreeLayout();
// 针对每一页创建内容
refreshItemLayout(position, c_item_layout);

this.m_use_layout_list.add(c_item_layout);
container.addView(c_item_layout);
c_item_layout.setTag(Integer.valueOf(position));
return c_item_layout;
}
}

bug原因

先给出Bug原因:创建GridLayout子项的布局参数中,我把 宽高==0 改为了 宽高==wrap_content。

追溯我写bug的起因

创建的 GridLayout 中的 ItemView 时,我将 ” itemView 的 LayoutParam 宽高设为了0“ 的两行代码注释掉了,如下:

1
2
3
4
                GridLayout.LayoutParams c_para = new GridLayout.LayoutParams();
// 关键是这里,我误删掉了这两行,导致出现了Bug
// c_para.width = 0;
// c_para.height = 0;

回想我怎么干出这样的”蠢事”?

应该是想到了,一个属性值默认为0,就不需要额外设置了呀。

一般情况下, ViewGroup.LayoutParams 无参构造方法中,默认的宽高的是0。

但是,GridLayout.LayoutPrams 的无参构造方法中,会将默认的宽高设为wrap_content

这样,因为我的想当然,将布局参数由 0 改为了 wrap_content

bug 的分析 布局参数 0 和 wrap_content的不同

直接给出结论:

0 时,对应的测量模式是exactly,导致父容器大小变化时,不会重新测量自己,而wrap_content会在每次父容器大小变化时测量自己。

具体针对本bug,则是在全屏应用切换时,导致 gridLayout的大小发生了变化,进而触发子项view重新测量自己。当从全屏应用返回后,触发了一次重新测量的流程,若是参数为0时,则itemView不会重新测量。

其实深究这个问题,还是存在不清楚的地方:

为什么进入其他全屏应用时会触发重新测量,而返回Launcher时没有触发测量?

或是说,仅是从全屏应用返回后,Launcher触发了重新测量,但是测量时状态栏仍是处于隐藏的状态,故而导致此情况。

bug延伸,源码追踪,为什么0和 wrap_content不同

从GridLayout的 onMeasure() 开始追踪

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
   protected void onMeasure(int widthSpec, int heightSpec) {
...

// 第一次测量,(1)
measureChildrenWithMargins(widthSpecSansPadding, heightSpecSansPadding, true);
// 第二次测量
measureChildrenWithMargins(widthSpecSansPadding, heightSpecSansPadding, false);

// 确定当前 GridLayout 的大小
setMeasuredDimension(
resolveSizeAndState(measuredWidth, widthSpec, 0),
resolveSizeAndState(measuredHeight, heightSpec, 0));
}


private void measureChildrenWithMargins(int widthSpec, int heightSpec, boolean firstPass) {
for (int i = 0, N = getChildCount(); i < N; i++) {
View c = getChildAt(i);
// 这里,就是使用布局参数的地方
LayoutParams lp = getLayoutParams(c);
if (firstPass) {
// 结合父级的spec和子View的想要的大小,来确定子View的大小 (2)
measureChildWithMargins2(c, widthSpec, heightSpec, lp.width, lp.height);
} else {
boolean horizontal = (mOrientation == HORIZONTAL);
Spec spec = horizontal ? lp.columnSpec : lp.rowSpec;
if (spec.getAbsoluteAlignment(horizontal) == FILL) {
Interval span = spec.span;
Axis axis = horizontal ? mHorizontalAxis : mVerticalAxis;
int[] locations = axis.getLocations();
int cellSize = locations[span.max] - locations[span.min];
int viewSize = cellSize - getTotalMargin(c, horizontal);
if (horizontal) {
// 若是水平的,即GridLayout限制了水平方向有固定N个,将父级width平分N分后,做为itemView的宽
// 而在垂直方向,仍尊重孩子自己申请的大小
measureChildWithMargins2(c, widthSpec, heightSpec, viewSize, lp.height);
} else {
measureChildWithMargins2(c, widthSpec, heightSpec, lp.width, viewSize);
}
}
}
}
}

private void measureChildWithMargins2(View child, int parentWidthSpec, int parentHeightSpec,
int childWidth, int childHeight) {
// itemView 测量自身使用 measureSpec 的确定过程,
int childWidthSpec = getChildMeasureSpec(parentWidthSpec,
getTotalMargin(child, true), childWidth);
int childHeightSpec = getChildMeasureSpec(parentHeightSpec,
getTotalMargin(child, false), childHeight);
// itemView 得到 spec 后来测量自身
child.measure(childWidthSpec, childHeightSpec);
}

// ViewGroup.java
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
// spec 的拆分,前2位来存储mode, 后30位存储期望的值
int specMode = MeasureSpec.getMode(spec);
int specSize = MeasureSpec.getSize(spec);

int resultSize = 0;
int resultMode = 0;

switch (specMode) {
// Parent has imposed an exact size on us
// 正常情况,GridLayout 传下来的是 exactly, 即gridLayout本身确定了自己的大小,不期望收到孩子的影响
case MeasureSpec.EXACTLY:
// 这里,孩子的布局参数设置为0时,即导致孩子使用的spec为 (exactly + 0) 组成
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size. So be it.
resultSize = size;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// 当我们使用默认的 wrap_content时,孩子会使用的 spec 为 (at_most + size)
// 注意这个size为GridLayout已经确定好的size
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;

return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

到这里,我们完成了从 LayoutParam的到 spec 的转变过程。

为了描述本Bug,分为了两种情况,

  1. 布局参数宽高设为 wrap_content, 即 GridLayout.LayoutParam 默认方法
  2. 布局参数宽高设为 0,即我们解决bug的情况

第一种 spec 为 (at_most + size),即孩子的大小未确定,只是限制了最大值为size,即父级大小减去一些padding. 第二种 spec 为 (exatly + 0),当孩子使用这个 spec时,会确定自己大小为 0 ?

开始追到这里会有一些疑惑,当使用 spec 为 (exactly + 0 )去调用 child.measure()时,那么ItemView得大小不是应该为 0吗?实际上,就是这样。但这只是初步的结果,回顾发现,会GridLayout区分了第一次测量和第二次测量,此时,第一次测量结果就是孩子得大小都为 0。

这里,我们可以做一个初步的总结,GridLayout测量孩子时,会分为两次测量,第一次会询问孩子想要的大小,

若是wrap_content,那么孩子会根据自身出发先报出自己想要的大小,作为暂定值。

若是 >=0的值,那么也暂定大小为当前申请的值,比如我们报的值0.

当然还有一种额外的情况,就是 match_parent,暂定大小与父级容忍的最大值相同。

无论布局参数参数是哪种,都会先得到一个暂定值。

接下来,我们进入GridLayout 的第二次测量的过程。

回头看代码,firstPass == false的情况,

讨论GridLayout是水平的,即GridLayout限制了水平方向有固定N个itemView,将父级的宽度平分N分后,做为itemView的宽度,而在垂直方向,仍尊重孩子自己申请的大小。

1
measureChildWithMargins2(c, widthSpec, heightSpec, viewSize, lp.height);	// viewSize即划分父级为N份的大小

接下来再次得到一个 spec 后,进入 child.measure(childWidthSpec, childHeightSpec),我们这里追踪进入

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
30
31
32
33
34
35
36
37
38
  // View.java

public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
// 传统的cache流程,忽略
// Suppress sign extension for the low bytes
long key = (long) widthMeasureSpec << 32 | (long) heightMeasureSpec & 0xffffffffL;
if (mMeasureCache == null) mMeasureCache = new LongSparseLongArray(2);

// 当前View是否申请有强制布局,即某个地方已经判断该View是脏的
final boolean forceLayout = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;

// 翻译:当前子View已经测量出正确尺寸时,父级会传参exatcly,(避免无效测量),以达到优化布局目的
// Optimize layout by avoiding an extra EXACTLY pass when the view is
// already measured as the correct size. In API 23 and below, this
// extra pass is required to make LinearLayout re-distribute weight.
final boolean specChanged = widthMeasureSpec != mOldWidthMeasureSpec
|| heightMeasureSpec != mOldHeightMeasureSpec;
final boolean isSpecExactly = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY
&& MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY;
final boolean matchesSpecSize = getMeasuredWidth() == MeasureSpec.getSize(widthMeasureSpec)
&& getMeasuredHeight() == MeasureSpec.getSize(heightMeasureSpec);

// 是否需要重新布局(当然包括测量了)?
// spec是否已经变化了?
// 与第一次传进来的的spec相比,当然是变化了。
// 即使变化了,也不一定需要重新
final boolean needsLayout = specChanged
&& (sAlwaysRemeasureExactly || !isSpecExactly || !matchesSpecSize);

if (forceLayout || needsLayout) {

// 测量本身
onMeasure(widthMeasureSpec, heightMeasureSpec);

// 测量后,标记需要进入layout流程。
mPrivateFlags |= PFLAG_LAYOUT_REQUIRED;
}
}

第二次进入时,看看是否会触发 needLayout ?首先specChanged必然是 ture 的,然后看三个标志:(sAlwaysRemeasureExactly || !isSpecExactly || !matchesSpecSize)

  1. sAlwaysRemeasureExactly 是否设置了强制重新测量,这里先忽略,正常请情况应该都是false的
  2. !isSpecExactly 测量模式不是 exactly
  3. !matchesSpecSize 已经测量的值 不等于 spec期望的值

三个标志位任一条件满足都会触发重新测量,但这么看人还是挺懵的,反过来看,什么时候会避免重新测量呢?

结合日常工作的常见的情况,什么时候,子View会避免重新测量呢?即 needLayout = false的情况

  1. 当父级 spec不变,即父级大小不变,自然不会要求孩子重测了

  2. 当 spec变了,即父级大小变化了,孩子仍能避免重新测量

第二种情况比较有趣,也是注释里面提到的优化点,即:当前子View已经测量出正确尺寸时,父级会传参exatcly,(避免无效测量),以达到优化布局目的。

此时,父级传来的 spec(mode + size ) 的size变化了,但是 mode 仍然是 exactly,可以判断不必测量自己View。

但若是 spec的mode是非 exatcly,即子View设置了wrap_content转换的 at_most,则会每次父级变化,自己本身也会要求重新测量。