Android 进阶 - 测量与布局
宏观流程
顾名思义,测量与布局是两个分开的流程。首先从根 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
布局
虽然我们在写自定义布局,但布局方法是最简单的,它只要把测量的结果传递过去就行了。
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 时考虑边距,当计算它们位置以及自己的大小时得记得手动把这东西加上。
核心代码如下:
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 整体的宽度是最宽的一行的宽度。
修改后的代码如下:
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 的大小不包括边距,而计算位置时要考虑边距。
修改后代码如下:
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 细节处理不到位。