文章

Android 进阶 - 测量与布局

宏观流程

顾名思义,测量与布局是两个分开的流程。首先从根 View 开始递归调用子 View 的 measure() 方法来测量。然后再从根 View 开始递归逐级调用子 View 的 layout() 方法来布局。此时会把测量的结果,即子 View 的位置与尺寸传递过去,子 View 需要保存这些属性。

为什么要把测量与布局分开?

因为测量不是可以一次就完成的,部分情况下需要两次甚至更多。例如 LinearLayout 中若所有子 View 都是 match_parent 宽度,则第一次测量得到的全部是 0,表明无固定宽度。第二次进行自由测量,得出各自任性的尺寸。最后一次取最大尺寸的一个作为硬性要求进行测量,得到所有子 View 的具体尺寸。测量与布局分开可以避免没有意义的布局计算, 提供性能。

具体来讲,对于一个 View Child,处于父 View Parent 中,会经历以下过程:

  1. 开发者在 xml 中指明对 Child 的布局要求:layout_xxx

  2. Parent 在自己的 onMeasure() 中根据 xml 里对 Child 的要求以及自己的可用空间,得出对 Child 新的尺寸要求。

  3. Child 在自己的 onMeasure() 中根据 Parent 的要求以及自己的实际情况,计算出期望尺寸。 如果 Child 是 ViewGroup 那么此时要调用每个子 View 的 measure() 进行测量。

    一般情况下 Child 自己测出的期望尺寸就是最终的实际尺寸。但如果不按照 Parent 的要求来测量,Parent 可以(但不一定)强行修改这个结果,导致实际尺寸与期望的不一致。

  4. Parent 根据 Child 的期望尺寸,确定 Child 实际尺寸与位置。(此时可以修改 Child 自己测得的结果)

  5. 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 用来盛放标签,把它们横向依次排列。如果排不下则另起一行。如图(灰色的是参考线,不需要绘制):

宏观的实现流程如下:

  1. 测量子 View
  2. 根据子 View 的大小计算自己的大小
  3. 布局子 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 自身的 marginpadding,以及子 View 的 marginpadding。其中 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 细节处理不到位。