Android自定义view(1) -- 测量与布局

259 阅读5分钟

Demo apk

Github

需要实现自适应屏幕宽度的流式布局,具备以下特性:

  1. 自动换行:每行尽可能多排列子View
  2. 整体居中:每行内容水平居中显示

在Android开发中,虽然可以通过TextView结合父容器实现基础标签布局,但若直接使用XML静态布局存在明显适配缺陷。

由于不同设备的屏幕尺寸和分辨率存在差异,在开发者设备上能完美单行显示的三个标签,在用户设备上可能因屏幕过窄导致布局错乱。

为保障跨设备的完美呈现,我们需要采用自定义ViewGroup方案:

在自定义容器中覆写onMeasure()和onLayout()方法,实时计算每个子View的尺寸,根据可用宽度进行流式布局,动态确定每个子View的位置坐标和实际占位尺寸,从而实现真正的响应式布局效果。

Android视图系统的布局流程可分为测量(Measure)和布局(Layout)两个核心阶段:

测量阶段

  1. 自顶向下触发测量链:根View通过measure()方法启动递归测量

  2. 父子尺寸协商:父View通过MeasureSpec向子View传递尺寸约束(包括最大可用空间和测量模式)

  3. 子View自测量:在onMeasure()中根据父View的约束条件,结合自身内容特性(如文本长度/图片尺寸)计算期望尺寸

  4. 容器测量决策:ViewGroup类型控件在onMeasure()中完成:

    1. 遍历调用所有子View的measure()方法
    2. 综合子View测量结果与自身布局规则(如LinearLayout的权重机制)
    3. 最终确定自身和所有子View的实际测量尺寸

布局阶段

  1. 测量结果传递:根View通过layout()方法触发布局流程

  2. 坐标分配:父View在onLayout()中根据测量阶段得到的尺寸数据,为每个子View计算具体坐标区域(left/top/right/bottom)

  3. 子视图定位:通过子View.layout()传递最终显示区域参数,子View将参数存储于自身坐标系中

  4. 布局级联:ViewGroup在onLayout()中遍历触发所有子View的layout()方法,形成递归布局树

tips:

  • 父View拥有最终决策权:子View的测量结果仅作为参考,父View可根据布局规则修改最终尺寸
  • 测量分离机制:measure()计算的尺寸数据不会直接影响绘制,直到layout()阶段才确定物理坐标

MeasureSpec结构解析

measure(int widthMeasureSpec, int heightMeasureSpec)

每个MeasureSpec参数(32位整型)包含双重语义:

  • 尺寸模式(高2位):通过MeasureSpec.getMode()获取,决定尺寸的约束类型

    • EXACTLY:精确尺寸(如match_parent或具体数值)
    • AT_MOST:最大限制(如wrap_content)
    • UNSPECIFIED:无限制(多用于滚动容器)
  • 尺寸值(低30位):通过MeasureSpec.getSize()获取,数值语义由模式决定

子View MeasureSpec生成逻辑

子View的测量参数由以下因素共同决定:

  1. 父View的约束条件:父View自身的MeasureSpec及已用空间
  2. 子View布局参数:XML中定义的layout_width/layout_height
  3. 容器特性:父View的布局规则(如LinearLayout与RelativeLayout的不同处理)

测量参数计算的核心逻辑:

// 以宽度测量为例的典型处理逻辑
when (child.layoutParams.width) {
    MATCH_PARENT -> {  // 子View期望填满父容器
        when (parentMode) {
            EXACTLY, AT_MOST -> {
                // 继承父容器的剩余空间,采用精确模式
                childSize = parentSize - widthUsed
                childMode = EXACTLY
            }
            UNSPECIFIED -> {  // 父容器无约束时保持自适应
                childSize = 0
                childMode = UNSPECIFIED
            }
        }
    }
    WRAP_CONTENT -> {  // 子视图根据内容自适应
        when (parentMode) {
            EXACTLY, AT_MOST -> {
                // 继承父容器剩余空间,但采用最大限制模式
                childSize = parentSize - widthUsed
                childMode = AT_MOST
            }
            UNSPECIFIED -> {
                childSize = 0
                childMode = UNSPECIFIED
            }
        }
    }
    else -> {  // 明确指定具体数值
        childSize = child.layoutParams.width
        childMode = EXACTLY
    }
}

MeasureSpec的计算非常公式,Android框架提供标准化方法:ViewGroup.measureChildWithMargins() 封装了以下功能:

  1. 自动计算子View的margin值

  2. 处理父容器的padding和已用空间

  3. 根据子View的LayoutParams生成正确的MeasureSpec

  4. 自动触发子View的measure()方法

CenterLayout

测量阶段 - onMeasure() 实现解析

测量过程包含三个核心任务:

  1. 子视图测量:计算每个子View的尺寸
  2. 布局空间分配:动态计算折行逻辑
  3. 容器尺寸确定:综合所有子View测量结果确定自身尺寸

关键参数处理

val width = MeasureSpec.getSize(widthMeasureSpec)  // 容器可用宽度
var heightUsed = 0  // 累计高度消耗(最终容器高度)
var currentWidth = 0  // 当前行已用宽度
var currentHeight = 0  // 当前行基准高度

子View测量流程

for (child in children) {
    measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0)
    ... 处理折行
 }

测量后通过child.measuredWidth/Height获取测量的尺寸

代码中通过自定义属性widthUsed/heightUsed 或者测量宽高与margin的合

例如:

val View.heightUsed: Int
    get() {
         return marginTop + marginBottom + measuredHeight
    }

折行决策逻辑

if (currentWidth + child.measuredWidth > width) {  // 宽度溢出判断
    heightUsed += rowSpacing       // 增加行间距
    currentHeight = heightUsed     // 重置行基准高度
    currentWidth = 0               // 重置行宽度计数器
}

坐标记录系统

coordinates.getOrPut(child) { ViewCoordinate() }.set(currentWidth, currentHeight) // 记录子view的坐标
lineWidthUsed[currentHeight] = currentWidth  // 记录行总宽度
  • ViewCoordinate:存储子View的初始布局坐标
  • lineWidthUsed:记录每行最终占用的实际宽度(用于后续居中计算)

容器尺寸确定

setMeasuredDimension(width, resolveSize(heightUsed, heightMeasureSpec))
  • 宽度:严格遵循父容器约束尺寸
  • 高度:通过resolveSize()处理AT_MOST/EXACTLY模式差异

布局阶段 - onLayout() 实现解析

核心任务

根据测量阶段记录的坐标数据,实现每行子View的水平居中布局

布局参数计算

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    val w = r - l
    children.forEach { view->
val coordinate = coordinates[view] ?: return
        val usedWidth = lineWidthUsed.getOrDefault(coordinate.y, null) ?: return
        val offset = (w - usedWidth) / 2 + view.marginStart
view.layout(coordinate.x + offset, coordinate.y)
    }
}

关键计算步骤

  1. 获取坐标记录:从coordinates中读取子View的原始坐标

  2. 行宽度查询:通过lineWidthUsed获取该行的总占用宽度

  3. 居中偏移计算

    1. (w - usedWidth)/2:计算行内容左右边距
    2. + view.marginStart:叠加子View的起始margin

代码中的layout为自定义函数

fun View.layout(
    x: Int,
    y: Int,
) {
    layout(x, y, x + measuredWidth, y + measuredHeight)
}

最终运行效果如下:

因为文本是随机生成的,每次进入退出后的标签都不一样,且点击后标签文本变长,当上居中与自动折行的逻辑不变