宏观流程
顾名思义,测量与布局是两个分开的流程。首先从根 View 开始递归调用子 View 的 measure()
方法来测量。然后再从根 View 开始递归逐级调用子 View 的 layout()
方法来布局。此时会把测量的结果,即子 View 的位置与尺寸传递过去,子 View 需要保存这些属性。
为什么要把测量与布局分开?
因为测量不是可以一次就完成的,部分情况下需要两次甚至更多。例如 LinearLayout
中若所有子 View 都是 match_parent
宽度,则第一次测量得到的全部是 0
,表明无固定宽度。第二次进行自由测量,得出各自任性的尺寸。最后一次取最大尺寸的一个作为硬性要求进行测量,得到所有子 View 的具体尺寸。测量与布局分开可以避免没有意义的布局计算, 提供性能。
具体来讲,对于一个 View Child
,处于父 View Parent
中,会经历以下过程:
开发者在 xml 中指明对 Child 的布局要求:layout_xxx
Parent 在自己的 onMeasure()
中根据 xml 里对 Child 的要求以及自己的可用空间,得出对 Child 新的尺寸要求。
Child 在自己的 onMeasure()
中根据 Parent 的要求以及自己的实际情况,计算出期望尺寸。
如果 Child 是 ViewGroup
那么此时要调用每个子 View 的 measure()
进行测量。
一般情况下 Child 自己测出的期望尺寸就是最终的实际尺寸。但如果不按照 Parent 的要求来测量,Parent 可以(但不一定)强行修改这个结果,导致实际尺寸与期望的不一致。
Parent 根据 Child 的期望尺寸,确定 Child 实际尺寸与位置。(此时可以修改 Child 自己测得的结果)
Child 在自己的 layout()
方法中保存 Parent 传递的最终尺寸与位置。
如果是 ViewGroup
,应该在 onLayout()
里调用每个子 View 的 layout()
传递尺寸位置。
此时 Child 有机会再次无视 Parent 的决定放飞自我,并且 Parent 不会修正了。但尽量别这么做。因为这种方式不会通知父 View,可能影响其他子 View 的布局。例如 LinearLayout 中会出现子 View 的空隙或重叠。
为什么 xml 里对 Child 的要求由 Parent 处理?
因为 xml 里的要求可能与 Parent 的状态有关,Child 无法自己处理。比如 android:layout_height
属性就只有 Parent 结合自己的尺寸以及其他子 View 的权重才能计算出来。
相关方法
了解了宏观原理,一些比较容易弄混的方法与属性应该就比较清晰了。
方法 | 注释 |
---|
getMeasuredWidth() | 获取 View 自己测量的结果,即期望尺寸。来自于 setMeasuredDimension() 。 |
getWidth() | 获取 View 实际尺寸。 |
onMeasure()
执行自定义的测量逻辑,结果通过 setMeasuredDimension()
保存。
需要通过 MeasureSpec.getMode()
与 MeasureSpec.getSize()
从参数中提取父 View 的要求,有三种可能的限制:限制最大/精准要求/无限制。通常情况下可以直接调用 resolveSize()
,它是一个便捷函数用于计算在限制下的最佳尺寸,具体算法是:若没有限制则使用期望尺寸,若有最大限制则取较小的一个值,若有精准限制则使用这个值。还有一个兄弟函数叫 resolveSizeAndState()
,它在父 View 最大限制不够的时候可以添加额外标记,父 View 「可能」会重新安排测量。
由于 Google 开了个坏头,resolveSizeAndState()
几乎事实上废弃。现在当父 View 需要试探性测量时直接给无限制空间,通过测量结果来决定要不要优化布局。
如果当前 View 是 ViewGroup
(layout) 那么在 onMeasure()
中要先测量所有子 View,得出并保存它们的位置与尺寸。某些子 View 可能需要多次测量。
onLayout()
只有自定义 ViewGroup
(layout) 有必要重写这个方法。应遍历所有子 View,调用它们的 layout()
并传递位置与尺寸。
Demo - TagLayout
了解了原理,来小试牛刀一下:自定义一个 Layout 用来盛放标签,把它们横向依次排列。如果排不下则另起一行。如图(灰色的是参考线,不需要绘制):

宏观的实现流程如下:
- 测量子 View
- 根据子 View 的大小计算自己的大小
- 布局子 View
布局
虽然我们在写自定义布局,但布局方法是最简单的,它只要把测量的结果传递过去就行了。
1
2
3
4
5
6
7
8
9
10
11
12
13
| class MyTagLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
// 储存测量结果
private val childrenBounds = mutableListOf<Rect>()
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for ((index, child) in children.withIndex()) {
val bounds = childrenBounds[index];
child.layout(bounds.left, bounds.top, bounds.right, bounds.bottom)
}
}
}
|
测量子 View
测量子 View 是最麻烦的一步,因为不仅要考虑开发者对子 View 的期望,还需要考虑父 View 对自己的限制,以及已经完成测量的其它子 View。一般来说,传递给子 View 的限制遵循下面的算法:
- 子 View 为
match_parent
- 父 View 给出精准要求:那就把这个要求传给子 View,就能正好填充满。
- 父 View 给出最大限制:把这个限制传递给子 View。
此时有另一种写法,就是作为精准要求给子 View 以确保填满,根据实际情况选择使用。
- 父 View 无限制:对子 View 也无限制。
- 子 View 为
warp_content
- 父 View 给出精准要求:作为最大限制传递给子 View。
- 父 View 给出最大限制:同上。
- 父 View 无限制:对子 View 也无限制。
- 子 View 为精准值
- 无论父 View 限制如何,都把这个期望值作为精准要求传递。注意此时不用考虑父 View,如果空间不够出现了重叠等问题,那也是 App 开发者自己造成的,Layout 要做的就是听从开发者的要求。
上面除了「子 View 为精准值」外,其他情况要记得从父 View 的限制中减去其他子 View 已经占用的大小再传递。
这套算法比较麻烦但固定,况且宽度和高度都要写一遍。所以 Android 也封装好了方法给我们 measureChildWithMargins()
它还会自动把 layout_margin
系列属性计算进去。不过有时候子 View 可能没有这些属性而导致类型强转失败,所以有必要重写我们布局的 generateLayoutParams()
方法,视情况包装一层 MarginLayoutParams
返回。
measureChildWithMargins()
只是在测量子 View 时考虑边距,当计算它们位置以及自己的大小时得记得手动把这东西加上。
核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
var lineMaxHeight = 0
var widthUsed = 0
for ((index, child) in children.withIndex()) {
measureChildWithMargins(child, widthMeasureSpec, widthUsed, heightMeasureSpec, 0)
if (index >= childrenBounds.size)
childrenBounds.add(Rect())
val bounds = childrenBounds[index]
bounds.set(widthUsed, 0, widthUsed + child.measuredWidth, child.measuredHeight)
widthUsed += child.measuredWidth
lineMaxHeight = max(lineMaxHeight, child.measuredHeight)
}
setMeasuredDimension(widthUsed, lineMaxHeight)
}
override fun generateLayoutParams(attrs: AttributeSet): LayoutParams {
return MarginLayoutParams(context, attrs)
}
|

折行的处理
前面说过,这个场景应该读取 resolveSizeAndState()
的标记来处理。但实际上这个方式没人使用,所以得手动进行试探性测量。
折行的原因是剩余宽度不够布置子 View,但目前的测量模式下,子 View 会委曲求全尽可能缩小自己。为了让它不畏首畏尾,显示出所期望的样子,测量时不再考虑已用大小,给它们充分空间施展拳脚。然后对比子 View 的期望宽度与实际剩余宽度。如果剩余宽度足够就这么布局,否则换行后布局。
如果决定换行,就得再测量一次,因为剩余高度变了,对子 View 的高度要求也会改变。
支持折行后,每一行的高度是此行所有子 View 最高的一个。TagLayout 整体的宽度是最宽的一行的宽度。
修改后的代码如下:
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
| override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
var lineMaxHeight = 0
var lineWidthUsed = 0
var heightUsed = 0
var maxWidth = 0
val specWidthMode = MeasureSpec.getMode(widthMeasureSpec)
val specWidthSize = MeasureSpec.getSize(widthMeasureSpec)
for ((index, child) in children.withIndex()) {
// 先不考虑已用的宽度,给子 View 发挥的空间
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed)
if (specWidthMode != MeasureSpec.UNSPECIFIED &&
child.measuredWidth > specWidthSize - lineWidthUsed) {
// 如果子 View 的期望宽度超过剩余宽度,则换行
maxWidth = max(maxWidth, lineWidthUsed) // 记录最宽的一行作为 Layout 自身宽度
lineWidthUsed = 0
heightUsed += lineMaxHeight // 叠加本行最高的一个子 View 的高度,计算 Layout 自身高度
lineMaxHeight = 0
// 二次测量,因为换行后剩余可用高度变了
measureChildWithMargins(child, widthMeasureSpec, lineWidthUsed, heightMeasureSpec, heightUsed)
}
if (index >= childrenBounds.size) childrenBounds.add(Rect())
val bounds = childrenBounds[index]
bounds.set(lineWidthUsed, heightUsed, lineWidthUsed + child.measuredWidth, heightUsed + child.measuredHeight)
lineWidthUsed += child.measuredWidth
lineMaxHeight = max(lineMaxHeight, child.measuredHeight) // 记录最高的一个子 View 作为行高
}
setMeasuredDimension(maxWidth, heightUsed) // 设置 Layout 自身的大小
}
|

适配边距
具体来讲,边距有四种:TagLayout 自身的 margin
与 padding
,以及子 View 的 margin
与 padding
。其中 TagLayout 自身的 margin 不用关心,父 View 会处理好这些,把计算后的结果作为 MeasureSpec 传进来。子 View 的 padding 也不用关心,这是它自己的事情。所以要处理的只有自身的 padding 和子 View 的 margin。
measureChildWithMargins()
已经减去了 Layout 的 padding 和子 View 的 margin,所以传递给测量方法的 widthUsed 与 heightUsed 不能再加上 padding/margin 了。但是在计算位置的时候要手动偏移一下。
如果 Layout 本身就比较复杂,那么加上 margin/padding 的处理会非常恶心🤮。一个小技巧是记住 View 的大小不包括边距,而计算位置时要考虑边距。
修改后代码如下:
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
| override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
var lineMaxHeight = 0
var lineWidthUsed = 0 // 不包括 padding,包括子 View 的 margin
var heightUsed = 0 // 包括 padding,包括子 View 的 margin
var maxWidth = 0
val specWidthMode = MeasureSpec.getMode(widthMeasureSpec)
val specWidthSize = MeasureSpec.getSize(widthMeasureSpec)
for ((index, child) in children.withIndex()) {
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed)
if (specWidthMode != MeasureSpec.UNSPECIFIED &&
child.measuredWidth > specWidthSize - lineWidthUsed - paddingLeft - paddingRight - child.marginLeft - child.marginRight) {
// ↑ 和位置有关的要加上边距
maxWidth = max(maxWidth, lineWidthUsed)
lineWidthUsed = 0
heightUsed += lineMaxHeight
lineMaxHeight = 0
measureChildWithMargins(child, widthMeasureSpec, lineWidthUsed, heightMeasureSpec, heightUsed)
}
if (index >= childrenBounds.size) childrenBounds.add(Rect())
val bounds = childrenBounds[index]
// ↓ 和位置有关的要加上边距
bounds.set(
lineWidthUsed + paddingLeft + child.marginLeft,
heightUsed + paddingTop + child.marginTop,
// View 大小不包括边距,因此 right-left=measuredWidth
lineWidthUsed + child.measuredWidth + paddingLeft + child.marginLeft,
heightUsed + child.measuredHeight + paddingTop + child.marginTop
)
// View 大小不包括边距,所以计算占用大小用来布局时,应该把边距加上
lineWidthUsed += child.measuredWidth + child.marginLeft + child.marginRight
lineMaxHeight = max(lineMaxHeight, child.measuredHeight + child.marginTop + child.marginBottom)
}
setMeasuredDimension(
maxWidth + paddingLeft + paddingRight,
heightUsed + paddingTop + paddingBottom
)
}
|
完整代码就不放了,一个简单的 demo 细节处理不到位。