RelativeLayout测量优化失效
RelativeLayout为什么会测量优化失效,影响性能。
1.什么是 measure 优化?
Android 的 View.measure() 方法中有一段优化逻辑:
if (oldMeasureSpec == newMeasureSpec) {
// 👇 说明父容器传给我的宽高没有变
// 我就不重新测量了,直接复用上次的结果,节省性能
return;
}
🔧 所以:只要父容器每次给我的 MeasureSpec 是一样的,我就能跳过测量。
这就是 Android 布局系统的一种 缓存机制(跳过 measure),提高性能非常关键。
2. LinearLayout 是怎么测量的?(简单直接)
以垂直方向为例,它会:
- 从上往下一个一个测量子 View;
- 子 View 的高度用 wrap_content → LinearLayout 传入的是 AT_MOST;
- 每次测量后累加总高度;
- 所有子 View 都测完后,确定自己的总高度。
💡 重点:只测一次,每个子 View 的 MeasureSpec 都稳定(父宽固定,高是 AT_MOST),所以几乎所有子 View 的测量都可以缓存(不会重复 measure)。
3. RelativeLayout 是怎么测量的?(横纵分离)
由于 RelativeLayout 的子 View 可能依赖于彼此的位置(比如 A 在 B 右边、B 在 A 下方),所以:
它需要两轮测量:
第一轮:横向测量
- 确定所有子 View 的左右坐标
- 但此时还没确定每个 View 的高度 → 只能先“临时用一个高度”去测
第二轮:纵向测量
- 重新测量子 View 的实际高度
- 此时才能算出 RelativeLayout 的高度
4. 问题出在“第一次传进去的高度是假的
比如:
applyHorizontalSizeRules(params, myWidth, rules);
measureChildHorizontal(child, params, myWidth, myHeight); // ⚠️ 这里 myHeight 是假的
- myHeight 是父 View 当前的高度(比如 wrap_content,还没定好);
- 如果子 View 设置了 marginBottom 等属性,它的测量高度就会 = myHeight - marginBottom;
- 第二轮再测一次,传进去的新 MeasureSpec ≠ 上一次的;
- ➤ measure 优化就失效了,子 View 被迫重新测量!
举个类比更直观:
想象你在量窗帘的宽度:
- 你第一次预估窗子高 2 米;
- 把窗帘量了一遍,觉得布料要剪 1.8 米;
- 后来你发现窗子其实有 2.2 米高,你得重新量布料;
- ➤ 白白多测了一遍,因为第一次“假的高度”造成了浪费。
所以关键点是:
问题 | 原因 |
---|---|
RelativeLayout 子 View 被重复测量 | 第一次测量用的是不准确的高度 |
优化失效 | MeasureSpec 不一致,不能跳过 measure |
与 margin 有关系 | marginBottom、marginTop 会影响计算,造成误差 |
LinearLayout 没这问题 | 顺序测量,MeasureSpec 是真实的,不跳过也不会错 |
建议用 padding 替代 margin | padding 不影响测量,margin 是 layout param,参与 measureSpec 的计算逻辑 |
总结
RelativeLayout 因为要分两轮测量(横 + 纵),第一次传入子 View 的高度是假的,如果子 View 设置了 margin,就会导致第二轮测量时发现 MeasureSpec 变了,无法复用第一次结果,measure 优化失效,从而影响性能。
如何避免
- 避免 RelativeLayout 嵌套太深;
- 尽量减少使用 margin,特别是 layout_marginBottom、layout_marginTop;
- 尽量用 padding 实现留白;
- 如果用的是新项目,建议使用 ConstraintLayout(一次测量,无重复);
理解
在RelativeLayout 的第一次横向测量中,系统会调用:
measureChildHorizontal(child, params, myWidth, myHeight);
此时传入的 myHeight 是一个临时值,尚未准确计算,也未考虑子 View 的 marginTop、marginBottom。
接下来:
在第二轮纵向测量中,会调用:
measureChild(child, params, myWidth, myHeight);
这一次系统已经知道该考虑 margin,于是:
• 计算:childHeight = myHeight - params.topMargin - params.bottomMargin
• 然后调用:
getChildMeasureSpec(parentHeightSpec, margin, LayoutParams.WRAP_CONTENT)
此时生成的 MeasureSpec 和第一次不同。
于是问题来了:
• 子 View 收到 不同的 MeasureSpec(虽然 mode 是 AT_MOST,但 size 变了);
• 系统为了安全起见,会重新调用 onMeasure();
• ➤ 这就打破了原本的 measure 优化逻辑!
第一次传递进去的是 myHeight(没减 margin)
第二次传进去的是 myHeight - margin
➤ 因此可能导致系统判定 MeasureSpec 改变 → 重新触发子 View 测量(measure)
所以为什么 LinearLayout 没这个问题?
因为它 只有一次测量,从一开始就把 margin 计入了:
childHeight = parentHeight - usedHeightSoFar - lp.topMargin - lp.bottomMargin
没有“中途修正”的概念,所以 measureSpec 更稳定,能更好复用测量结果。
measureChildHorizontal()
详细分析 RelativeLayout.measureChildHorizontal() 方法,看它如何在第一次横向测量中处理子View的测量逻辑,以及它为何会传入一个“未考虑 margin 的 myHeight”。
函数背景
private void measureChildHorizontal(View child, LayoutParams params, int myWidth, int myHeight)
这是 RelativeLayout 专门用于第一轮“横向测量”的内部方法,调用发生在:
// 横向第一轮测量(width)
applyHorizontalSizeRules(params, myWidth, rules);
measureChildHorizontal(child, params, myWidth, myHeight);
作用
这一步不是为了测量高度,而是为了测量宽度和设置左右位置(mLeft、mRight)。
但由于 View.measure(widthSpec, heightSpec) 必须同时传入宽高规格(即使暂时不关心某一项),所以这里仍然要传入一个 heightSpec。
问题就在于传入的 height 是 myHeight,并没有考虑 topMargin 和 bottomMargin,也不是最终的高度。
实际流程(核心步骤)
我们基于 Android 源码简化整理(省略对 RTL 的处理):
1. 构造 MeasureSpec
final int childWidthMeasureSpec = getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(myWidth, MeasureSpec.EXACTLY),
paddingLeft + paddingRight + params.leftMargin + params.rightMargin,
params.width);
final int childHeightMeasureSpec = getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(myHeight, MeasureSpec.EXACTLY), // ⚠️注意:未减 margin
paddingTop + paddingBottom, // 也没有加入 topMargin/bottomMargin
params.height);
2. 调用子 View 的 measure
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
这就完成了“第一轮”对该子 View 的测量。
但此时高度的 MeasureSpec 是基于“父容器的 myHeight”,而不是“真实剩余高度”,也未考虑子 View 的 top/bottomMargin。
问题点在哪?
当 RelativeLayout 第二轮开始纵向测量时,它会这样处理:
applyVerticalSizeRules(params, myHeight);
measureChild(child, params, myWidth, myHeight); // ⚠️再次测量
这时候:
- myHeight 可能更精确(比如 wrap_content 计算后的高度);
- getChildMeasureSpec() 也会考虑 params.topMargin 和 params.bottomMargin;
- ➤ 构造出的 MeasureSpec 与第一次不同,系统判定为“必须重新测量”。
为什么 LinearLayout 没这个问题?
LinearLayout 没有 child 之间的依赖,也没有“分轮测量”的需求,所以:
- 只测一次;
- 测量时就会传入正确的 height(含 margin);
- ➤ 系统能稳定命中缓存,性能更好。
总结:
measureChildHorizontal()
的问题本质
特点 | 描述 |
---|---|
功能 | 为子 View 测量宽度 & 设置横向位置(mLeft、mRight) |
高度传入 | myHeight,未考虑 margin,不是准确值 |
问题 | 第二次测量时 MeasureSpec 不一致,导致子 View 被再次 measure |
性能影响 | measure 优化失效,增加 layout pass 开销 |
measureChild(child, params, myWidth, myHeight);
这是用于 纵向测量 的阶段,重点是测量子 View 的高度,并在此基础上计算其纵向布局位置(如 top、bottom),从而得出整个 RelativeLayout 的真实高度。
这个方法在哪定义?
你引用的 measureChild(…) 是 RelativeLayout 中的私有方法,不是 ViewGroup.measureChild() 的那个通用版本。
下面我们基于 Android AOSP 源码来拆解这个私有版本的实现逻辑(核心代码):
函数目的
为每个子 View 生成合适的 MeasureSpec(宽高),然后调用它的 measure() 方法,让子 View 知道自己该有多大。
源码结构(伪代码简化)
private void measureChild(View child, LayoutParams params, int myWidth, int myHeight) {
final int childWidthMeasureSpec = getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(myWidth, MeasureSpec.EXACTLY),
mPaddingLeft + mPaddingRight + params.leftMargin + params.rightMargin,
params.width);
final int childHeightMeasureSpec = getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(myHeight, MeasureSpec.EXACTLY),
mPaddingTop + mPaddingBottom + params.topMargin + params.bottomMargin,
params.height);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
每个参数的意义
参数 | 说明 |
---|---|
myWidth | 父容器(RelativeLayout)的宽度,已确定 |
myHeight | 父容器最终确定的高度,用来推断子 View 的可用高度 |
params.width | 子 View 的 layout_width,如 match_parent/wrap_content/具体dp |
params.height | 子 View 的 layout_height,同上 |
getChildMeasureSpec(…) | 核心函数,生成传给子 View 的 MeasureSpec(mode + size) |
*和measureChildHorizontal()的关键差异:
比较项 | measureChildHorizontal | measureChild |
---|---|---|
目的 | 测量宽度,位置是 mLeft/mRight | 测量高度,位置是 mTop/mBottom |
高度参数 | 传入的是 myHeight(临时值),未考虑 margin | 传入的是最终 myHeight,含 margin |
高度是否准确 | ❌ 否,临时/错误 | ✅ 是,真实 |
是否可能引发 measure 重进 | ✅ 可能 | ❌ 通常不会 |
性能陷阱出现在哪?
因为在 measureChildHorizontal() 中子 View 早就被测了一次:
child.measure(widthSpec, fakeHeightSpec); // 用的错误的 myHeight
等到第二轮调用 measureChild(…):
- 系统再测一次;
- 因为 heightSpec 不一样了(现在是准确高度、考虑了 margin);
- ➤ 系统无法命中缓存,只能 重新走一遍 onMeasure()。
这就破坏了系统对 View 的 测量优化策略,影响性能,尤其在嵌套复杂布局时(如嵌套 ScrollView、图片、大文本等)。
最终小结
阶段 | 方法 | 高度准确性 | 是否优化命中 | 说明 |
---|---|---|---|---|
第一次横向测量 | measureChildHorizontal() | ❌ 不准确 | ❌ 不能命中 | 用于先确定左右位置,传入的是临时 myHeight |
第二次纵向测量 | measureChild() | ✅ 准确 | ❌(和上次不同) | 传入的是完整 myHeight + margin,重新测量 |