布局优化的那些事(一)

一、RelativeLayout的性能损耗

前面在讨论动画优化时,有提到低效率的动画实现往往都让代码充满了不确定性,比如requestLayout往往会造成一些意想不到的性能损耗。刚好最近在修改一个页面时,发现了一个跟预期不一样的bug,感觉可以跟大家分享一下。

我们都知道RelativeLayout在measure上是有性能损耗的,由于需要分别计算子View在横、竖两个方向上的尺寸,在每次measure过程中,子View需要被measure两次,所以在使用RelativeLayout时,一定要控制布局的深度,减少嵌套的情况;但关于这个问题,我个人的认识一直停留在——由于View的measure是有重入保护的,对于在RelativeLayout中没有requestLayout的子View,即使重复调用measure也会直接返回才是。但在处理这个bug的过程中,发现实际情况跟想象中有所不同。

这个问题的场景是这样的,在某个列表页面,需要在顶部显示一个item的描述,当描述过长时,需要使用marquee效果来滚动显示;而底部有一个计数,随着item数量变化,数字会随之增加或减少。

写一个demo的话,效果和对应的布局文件如下,

图1 demo 布局效果图和对应的布局文件

这里为了方便讨论,把不必要的View都删掉了,最少元素就是在RelativeLayout中有两个TextView,一个大小固定,可以marquee(我们叫它headerText);另一个会通过setText改变自己的内容,且是wrap_content的(叫它changedText)。

而这个bug的现象如下,在headerText marquee的过程中,如果changedText中的内容发生变化,headerText的marquee就会被打断。

由于在实际页面上,右下角还有一个button,点击button才会触发changedText中计数变化,所以看到这个问题的第一反应是可能点击按钮时触发了焦点变换,所以导致marquee停止了。

但实际调试了一下(堆栈如图2),发现跟预期的调用并不相符——直接原因是headerText触发了measure,才重置了marquee的状态。

图2 changedText marquee停止的堆栈

由于changedText的width是wrap_content,那么修改changedText的内容会触发requestLayout并不奇怪;但对于headerText,它在布局中也没有依赖changedText,为什么也会跟着受到影响呢?

看到这里,我们可能需要复习一下View System measure相关的一些源码,

首先是measure的重入规则,我们知道在一个View Tree上,如果某一个View调用了requestLayout,并不会导致全部View都重新measure,一般是requestLayout所在链路上的View和在同一个ViewGroup中受到影响的View会被重新measure,实现上述逻辑的,主要是在View.java measure方法中的一段防重入的代码。

图3 measure中的重入判断逻辑

从图3可以看出,重入条件的主要是forceLayout和needsLayout两个bool,按逻辑可以拆分成下面三个逻辑分支:

  • View调用requestLayout或forceLayout过,这种情况肯定需要重新measure;
  • targetSdk不高于M,此次的MeasureSpec和上次不同(specChanged),这种情况肯定也需要重新measure;
  • targetSdk高于M时,这里新增了一个优化,如果View的MeasureSpec是EXACTLY的,且View的大小已经跟MesureSpec请求的相同了,那即使MeasureSpec发生了变化,也可以不用重新measure。

这里先跑题一下,关于第三点优化的逻辑,刚看到这段逻辑的时候有点不太理解, 后来找到了这段代码的原始提交:

https://android.googlesource.com/platform/frameworks/base/+/9cefbda11ee5308145d58b0b99ced0f66a0b1cf9%5E%21/#F0android.googlesource.com

以及后来为什么要加targetSdk高于M这段判断的原因:

https://android.googlesource.com/platform/frameworks/base/+/9d8a230fbd71ac57ef806326f15fa133ba125083android.googlesource.com
图4 相关的ASOP提交记录

从提交记录可以看出,这里对应的场景应该是,在某些Layout布局中,我们可能需要先探测一下子View的大小,然后再决定View的真正大小(这种场景我们应该都不陌生)。那么此时就可能需要对同一个子View measure两次,第一次measure mode是AT_MOST或UNSPECIFIED,第二次当真正确定了View的大小时,才是EXACTLY。虽然两次MeasureSpec的mode不同,但size有可能是一样的。有了这段代码的支持,第二次measure可以直接返回,从而减少了一次measure。

这是一个非常细节的优化点,我们看一眼首次提交时间,嗯,是在4年前,向谷歌大佬低头,惹不起惹不起。


跑题结束回到之前的讨论,不考虑特殊情况,括重入逻辑就是——只要View自己没有requestLayout或者再次measure时,MeasureSpec没变,就不需要重新measure。

看到这里答案已经呼之欲出,应该是RelativeLayout在两次measure时,使用的MeasureSpec不同,打破了View measure的重入条件,导致在measure时,不相关的View也会被重复measure。

我们可以观察一下源码,首先是RelativeLayout横向measure的逻辑,

图5 RelativeLayout横向measure child的逻辑

可以看到这里对于子View的height MeasureSpec的处理比较奇怪,为什么除了MATCH_PARENT,都处理成AT_MOST了?

纵向measure的MesureSpec计算逻辑如下,组装MeasureSpec逻辑基本就跟我们熟悉的逻辑差不多了。

图6 RelativeLayout纵向measure child的逻辑

而导致这种差异的原因是,在第一次measure的时候,我们并没有掌握child在纵向上的任意数据(也就是没有调用applyVerticalSizeRules这个函数,关于RelativeLayout measure的整个逻辑,这里就不再赘述了),所以在measureChildHorizontal的过程中,实际是不关心vertical方向的尺寸的。

换句话说,图5标注部分的逻辑实际也没有什么意义,但如果拼装出的MeasureSpec恰好能跟图6逻辑拼装出的保持一致,那么那些在纵向上没有依赖的View就可能可以减少一次measure(后续requestLayout触发的时候,也不会被重复measure)。

我们可以找到RelativeLayout这段逻辑比较原始的提交,

https://android.googlesource.com/platform/frameworks/base/+/f782e60efc09f210643432f31b4c18026d7716d6%5E%21/#F0android.googlesource.com
图7 RelativeLayout比较早期的逻辑

从最初的逻辑可以看到,第一次measure确实是不必关心vertical方向的尺寸的,在早期的逻辑中,对height MeasureSpec会一视同仁,全部设置成UNSPECIFIED。只能说,可能最初的设计就是这样,不排除google也并没有考虑减少重复measure的问题;另外,从上面的提交我们也可以看出,RelativeLayout第一次measure,对vertical方向的measure也不是完全没有作用,上面的提交就是基于修改bug才引入的(这里想象不出来对应的场景是什么,也不排除是早期Android版本才有的问题)。

不过至少从我们的分析,结合View.java measure()中的重入保护逻辑可以看出,如果在第一次measure是vertical方向的MeasureSpec能恰好跟第二次measure一致,那么就可以减少不必要的measure,特别对于那些在布局中不存在依赖的child,实际ConstraintLayout就是用的这种思路。而现存的这种逻辑,就会导致RelativeLayout measure的过程有一定的随机性,就像这个问题一样,一旦子View的MeasureSpec定义的不合适,就会产生被重复measure的问题。

所以我们还是抱着试一试的心态,建了一个google issue(issue id:117577891,欢迎跟帖版聊),万一谷歌大佬回复了呢(虽然没有搜索到类似的issue,不过应该早就是已知问题了)。

二、问题的修复

在源码的海洋中带薪遨游了一番,我们还是得回归现实继续修bug。从前面分析的结论可以看到,这里主要是headerText在measure过程中,重入条件被打破了,导致每次有其他child requestLayout,headerText都会跟着一起重新measure,触发onMeasure,从而重置了marquee的状态。所以修改方法主要是保证headerText在被measure时,重入条件不要被随便打破就可以了。

主要改法有两种,一种是调整一下headerText的height参数来配合RelativeLayout的源码,比如把headerText的height改为wrap_content就可以规避重复measure的问题。

但这种改法并不是很理想,首先它依赖了RelativeLayout内部的逻辑,如果google后续调整了RelativeLayout生成MeasureSpec的逻辑,那该问题很可能故态复萌;其次wrap_content也不太符合我们的需求,我们可能还需要增加一些View和逻辑,来处理视觉上的差异;最后这样可维护性也很差,后续其他同事修改这个页面的时候,很可能修改了headerText的参数,那问题又会复现。

而第二种改法是,我们可以嵌套一层ViewGroup,来保证headerText在布局上的稳定。比如在headerText上包一层FrameLayout(如图8)。

图8 调整后的布局

这种改法的原理也不复杂。调整布局之后,RelativeLayout布局依然不稳定,FrameLayout还是有可能会被measure两次,但是在FrameLayout继续measure headerText的时候,只要保证它传递的MeasureSpec是稳定的,headerText就可以触发重入保护,onMeasure不会被调用,marquee也就不会被打断了。

而如何保证FrameLayout传递的MeasureSpec是稳定的呢?因为FrameLayout拼装MeasureSpec的逻辑是直接使用的ViewGroup.java中经典的拼装方式——静态函数getChildMeasureSpec(),拼装规则如下。

表1 ViewGroup默认的MeasureSpec拼装逻辑

从上图可以看出,只要保证子View是EXACTLY,无论ViewGroup本身的MeasureSpec是什么,都不会影响到子View——通过这种方式,我们就可以利用FrameLayout给headerText创造一个稳定的布局了。

唯一比较伤心的是,这样修改会导致布局增加一层,我们也只能安慰自己——FrameLayout不能算布局嵌套,FrameLayout的事,能算嵌套么?

当然,还有更多可能的修改方式,比如我们可以自己重载headerText的onMeasure,在其中插入控制重入的逻辑,防止marquee被打断;或者在父ViewGroup中重载requestLayout,适时的打断requestLayout的调用链。更多的改法可能就要根据实际应用场景来选择了,但整体的思路基本都是类似的——把布局中不稳定的部分隔离开,从而减少measure和layout带来的损耗。

三、稳定的布局

从前文的分析可以发现,不稳定的布局除了容易导致一些出乎意料的视觉bug,更多的时候会引入性能问题,而后者往往是很难察觉的。以前文讨论的布局为例,如果我们在实时刷新changedText时,RelativeLayout中同时存在一些动画的元素,那么丢帧几乎不可避免。

构建一个稳定的布局,大致也有两个方向,一方面就是像本文讨论的一样,尽可能构造一个相对稳定的布局,通过合理的布局方式,把动态、不稳定的内容和静态、稳定的内容隔离开,减少每次measure、layout带来的性能损耗,这一点在之前动画优化的文章中也有所提及;而另一方面就是从源头出发,减少或合理控制布局中动态的内容,减少触发measure、layout的次数,常规来说基本就是减少requestLayout的次数。

关于第二点,回到本文讨论的问题,为什么setText会触发requestLayout?这里毫无疑问是由于changedText的width是wrap_ content。那么如果changedText的width不是wrap_content就一定可以避免触发requestLayout么?还是直接从源码中寻找答案吧。

图9 TextView setText相关的源码

从源码中可以看出,除了要求width不能是wrap_content,对height的变化也有要求,同时Ellipsize不能是marquee。

width不能是wrap_content比较好理解,如果width是EXACTLY或者AT_MOST,那么在第一次measure之后,TextView的宽度实际就是固定的了,后续调用setText,实际只是根据available width结合Ellipsize对文字进行截取就可以了。另外,也可以注意到,这里有另一个条件mMaxWidth == mMinWidth,在这种情况下,如果设置了maxWidth和minWidth,且两者相等,TextView的宽度实际也是固定的。

高度不能发生变化,在这里应该是对应富文本的情况,如果在富文本中包含了影响高度的Span,且当前height是wrap_content,那肯定也是需要重新requestLayout的。同时也可以看到,setText对高度是不敏感的(毕竟文字是横向展示的)。

setText作为一个最常见的会隐式触发requestLayout的API,对布局稳定充满了不可预知的影响。而在实际使用场景中,很多时候我们都可以用match_parent来替代wrap_content(因为一般我们都会有个container来承载TextView),所以又回到了前面的观点,优化布局往往只是举手之劳,谨慎使用TextView wrap_content。

四、最后的总结

首先,requestLayout果然会造成很多意想不到的性能损耗,这个问题如果不是影响到了TextView的marquee,真的很容易被忽略。同时,理论上参考表1的逻辑,View的MeasureSpec如果都是EXACTLY,理论上应该是稳定的,但实际在很多布局中都是不能保证的。因此我们也借机分析了一下RelativeLayout的源码,明确了RelativeLayout在measure时会产生性能损耗的原因。

其次,我们在写自定义ViewGroup或者相对复杂的布局时,除了完成需求,也可以考虑一下布局稳定的问题,优化布局性能可能就在举手之劳间;同时,我们分析了一个会隐式触发requestLayout的API,我们会倾向于对TextView width设置match_parent,而不是wrap_content,来增加布局的稳定性。

另外,利用好ASOP的开源特性,毕竟google对于源码的认识,一般是要超前于普通开发者数年的。从源码和源码的提交历史中追溯逻辑,会让我们事半功倍。

最后,从前面讨论的题外话我们可以看出,google对代码细节的把控和优化是超乎想象的,既然我们都已经“站在巨人的肩膀上”了,为何不能跟着更进一步呢。

发布于 2018-12-14