今天解决的一个bug很有意思,涉及View测量流程,故记之。
bug描述:
Launcher中,当用户点击任一全屏App返回后,GridLayout 中的 ItemView 大小测量不正常了。如下图所示:
Launcher的布局结构是一个ViewPager,每一页是一个GridLayout,以下是对应的代码(也可以忽略不看)。
1 | public class AppPagerAdapter extends PagerAdapter { |
bug原因
先给出Bug原因:创建GridLayout子项的布局参数中,我把 宽高==0 改为了 宽高==wrap_content。
追溯我写bug的起因
创建的 GridLayout 中的 ItemView 时,我将 ” itemView 的 LayoutParam 宽高设为了0“ 的两行代码注释掉了,如下:
1 | GridLayout.LayoutParams c_para = new GridLayout.LayoutParams(); |
回想我怎么干出这样的”蠢事”?
应该是想到了,一个属性值默认为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 | protected void onMeasure(int widthSpec, int heightSpec) { |
到这里,我们完成了从 LayoutParam的到 spec 的转变过程。
为了描述本Bug,分为了两种情况,
- 布局参数宽高设为 wrap_content, 即 GridLayout.LayoutParam 默认方法
- 布局参数宽高设为 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 | // View.java |
第二次进入时,看看是否会触发 needLayout ?首先specChanged必然是 ture 的,然后看三个标志:(sAlwaysRemeasureExactly || !isSpecExactly || !matchesSpecSize)
- sAlwaysRemeasureExactly 是否设置了强制重新测量,这里先忽略,正常请情况应该都是false的
- !isSpecExactly 测量模式不是 exactly
- !matchesSpecSize 已经测量的值 不等于 spec期望的值
三个标志位任一条件满足都会触发重新测量,但这么看人还是挺懵的,反过来看,什么时候会避免重新测量呢?
结合日常工作的常见的情况,什么时候,子View会避免重新测量呢?即 needLayout = false的情况
当父级 spec不变,即父级大小不变,自然不会要求孩子重测了
当 spec变了,即父级大小变化了,孩子仍能避免重新测量
第二种情况比较有趣,也是注释里面提到的优化点,即:当前子View已经测量出正确尺寸时,父级会传参exatcly,(避免无效测量),以达到优化布局目的。
此时,父级传来的 spec(mode + size ) 的size变化了,但是 mode 仍然是 exactly,可以判断不必测量自己View。
但若是 spec的mode是非 exatcly,即子View设置了wrap_content转换的 at_most,则会每次父级变化,自己本身也会要求重新测量。