2026-06-14

Android 自定义View的测量 — onMeasure() 实践指南

从 SquareImageView 到 CircleView,掌握两种自定义测量模式:微调现有View vs 完全自主计算

onMeasure SquareImageView CircleView resolveSize MeasureSpec setMeasuredDimension

目录导航

一、为什么要自定义测量?

1默认测量行为不够用

Android 自带的 View(ImageView、TextView 等)有自己默认的测量逻辑,但在以下场景就不够用了:

场景默认行为你真正想要的
正方形头像 ImageView 按图片原始比例显示,宽高可能不一致 无论源图比例如何,最终显示为正方形,取较长边对齐
自定义圆形 View 没有现成的 CircleView,需要自己从 View 派生 根据圆的半径 + padding 决定 View 尺寸,不受父容器内容影响
核心问题:你需要介入测量流程,告诉父容器"我的期望尺寸是多少"——这就是 onMeasure()要做的事。
2两种自定义测量模式

根据你的起点不同,自定义测量分为两大类:

模式一:继承已有View,微调尺寸

继承 AppCompatImageViewAppCompatTextView等现有组件,在父类测量之后对结果做修正。

代表案例:SquareImageView(正方形 ImageView)

核心操作:super.onMeasure()→ 获取结果 → 取 max → setMeasuredDimension(max, max)

模式二:完全自定义View,自主计算

直接从 View派生,不调用 super,根据内部图形的尺寸自主计算期望宽高。

代表案例:CircleView(自定义圆形 View)

核心操作:计算内容尺寸 → +padding → resolveSize()修正 → setMeasuredDimension()

选型指南:如果你只是在现有组件基础上微调(如限制为正方形、限制最大宽高比),用模式一更省事。如果你从零开始画内容(如画圆、画自定义图形),用模式二。

二、两种实现方案对比:重写 onMeasure vs 重写 layout

1方案总览

要让一个 View 变成正方形,有两种技术方案:

方案介入时机原理推荐度
重写 onMeasure() 测量阶段 在测量阶段告诉父容器自己的期望尺寸是正方形,父容器在布局时根据这个测量结果分配空间 强烈推荐
重写 layout() 布局阶段 在布局阶段强行修改父容器分配的边界,把 r、b 替换成 l+max、t+max 不推荐
2为什么推荐 onMeasure?
父容器调用
子View.measure()
子View.onMeasure()
告知期望尺寸=正方形
父容器根据测量结果
规划 layout 空间
子View.layout()
正常保存边界
优点:符合 Android 的 measure → layout流程,不会破坏父容器的布局逻辑,通用性最好。你的 SquareImageView 示例就是这种做法,简洁且安全。
3为什么不推荐重写 layout?

也能变成正方形,但风险较大

  • 父容器可能已经根据原始测量值规划好了相邻控件的位置
  • 你擅自修改实际占用区域,容易导致重叠、空白或布局错乱
  • 这相当于"先答应父容器说我只占这么点地,等父容器分配完了又反悔多占"
一般不推荐直接这么干,除非你完全掌控父容器的行为(比如父容器是你自己写的自定义 ViewGroup)。

三、场景一:继承已有View微调尺寸 — SquareImageView

1需求分析

在社交应用、电商应用中,头像、商品图经常需要正方形显示。但用户上传的图片比例千奇百怪(16:9、4:3、1:1…),ImageView 默认按比例缩放,无法保证正方形。

解决方案:继承 AppCompatImageView,重写 onMeasure(),在测量完毕后重新测量一次,让较短的一边对齐较长的一边。

2完整代码实现
import android.content.Context;
import android.util.AttributeSet;
import androidx.appcompat.widget.AppCompatImageView;

/**
 * 正方形 ImageView
 * 原理:在父类测量完毕后,取宽高的较大值作为正方形的边长,强制设为正方形
 * 效果:较短边对齐较长边,无论源图比例如何,最终显示为正方形
 */
public class SquareImageView extends AppCompatImageView {

    public SquareImageView(Context context) {
        super(context);
    }

    public SquareImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public SquareImageView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 1. 先让父类按照原测量规格进行测量(内部已调用一次 setMeasuredDimension)
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        // 2. 获取测量后的宽高
        int measuredWidth = getMeasuredWidth();
        int measuredHeight = getMeasuredHeight();

        // 3. 取较大值作为正方形的边长
        int size = Math.max(measuredWidth, measuredHeight);

        // 4. 将宽高都设置为这个较大值(即让较短边对齐较长边)
        setMeasuredDimension(size, size);
    }
}
关键点解析:
  • 继承 AppCompatImageView:而非直接继承 ImageView,以获得兼容包的主题支持(tint、vector drawable 等)
  • super.onMeasure():先让父类按 ImageView 的默认逻辑完成测量(考虑图片比例、scaleType 等)
  • 取 max 而非 min:取较大值意味着让短边拉伸到长边的长度,保证内容完整显示。如果用 min,会把长边裁短,可能丢失内容
  • setMeasuredDimension(size, size):覆盖父类的测量结果,最终以最后一次调用为准
3执行时序图
1
父容器调用 measure(widthSpec, heightSpec)(final方法,不可重写)
2
measure() 内部调用 onMeasure(widthSpec, heightSpec)
3
super.onMeasure()→ ImageView 按 scaleType + 图片比例计算,调用 setMeasuredDimension(w, h)(第1次)
4
我们的代码:max(w, h),调用 setMeasuredDimension(max, max)(第2次,覆盖第1次结果)
5
measure() 校验:检查 PFLAG_MEASURED_DIMENSION_SET标志位是否已设置 → 通过
本质:在父类的 onMeasure结尾追加了自定义尺寸逻辑,先后两次调用 setMeasuredDimension,最终以最后一次结果为准,无额外测量开销。
4使用示例
<!-- 在布局文件中使用,和普通 ImageView 完全一样 -->
<com.yourpackage.SquareImageView
    android:layout_width="200dp"
    android:layout_height="wrap_content"
    android:src="@drawable/user_avatar"
    android:scaleType="centerCrop"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintStart_toStartOf="parent" />

即使用户头像是一张 16:9 的横图,SquareImageView 也会把它显示为一个 200dp × 200dp 的正方形(配合 centerCrop 居中裁剪)。

四、场景二:完全自定义View的尺寸计算 — CircleView

1与 SquareImageView 的本质区别
SquareImageViewCircleView
继承自 AppCompatImageView(已有组件) View(裸 View,无默认测量逻辑)
super.onMeasure() 调用(利用父类已有的测量能力) 不调用(父类默认实现不够用)
尺寸来源 父类测量结果(基于图片比例 + scaleType) 内部图形尺寸(圆的半径 + padding)
依赖关系 尺寸由外部图片决定 → 再修正 尺寸由内部图形决定 → 影响外部
需要 resolveSize() (父类已处理) (必须手动处理 MeasureSpec)
关键区别:CircleView 是"我要根据内部图形的尺寸来决定外部 View 的尺寸",所以必须从零开始计算,并用 resolveSize()将计算结果与父容器的约束(MeasureSpec)进行协商。
2完整代码实现
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View

/**
 * 自定义圆形 View
 * 根据圆的半径 + padding 计算自身期望尺寸
 * 这是"完全自定义尺寸计算"的标准模板
 */
class CircleView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val radius = 100f.dpToPx()   // 圆的半径
    private val padding = 100f.dpToPx()  // 圆周围的内边距

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 1. 根据内部图形尺寸计算 View 的期望尺寸
        //    直径 + 两侧 padding = 总尺寸
        val size = ((padding + radius) * 2).toInt()

        // 2. 使用 resolveSize() 将期望尺寸与父容器约束协商
        //    它内部处理了 EXACTLY / AT_MOST / UNSPECIFIED 三种模式
        val width = resolveSize(size, widthMeasureSpec)
        val height = resolveSize(size, heightMeasureSpec)

        // 3. 设置最终测量结果
        setMeasuredDimension(width, height)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 绘制时从 padding 偏移位置开始画圆
        canvas.drawCircle(
            padding + radius,  // centerX
            padding + radius,  // centerY
            radius,            // 半径
            paint
        )
    }

    // dp 转 px 扩展函数
    private fun Float.dpToPx(): Float {
        return TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP,
            this,
            resources.displayMetrics
        )
    }
}
关键点解析:
  • 不调用 super.onMeasure():View 的默认 onMeasure 只根据背景和最小宽高计算,对我们的圆没有意义
  • size = (padding + radius) × 2:圆占据的空间 = 直径 + 两侧 padding。这里 padding 和 radius 用的是同一个变量名,但语义不同——padding变量存的是"圆周围留白距离",radius是圆的半径
  • resolveSize():关键!它将我们算出的期望尺寸与 MeasureSpec 协商,确保在 EXACTLY 模式下遵守父容器强制尺寸
  • onDraw 中必须 + padding:绘制时圆心坐标要偏移 padding,否则圆会贴在左上角
3自定义测量四步法(通用模板)

任何从 View 直接派生的自定义组件,测量流程都遵循以下四步:

① 重写
onMeasure()
② 计算出
自己的期望尺寸
③ 用 resolveSize()
修正结果
④ 调用
setMeasuredDimension()

用代码模板表示:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // ① 步骤在这里(重写 onMeasure)

    // ② 计算期望尺寸:内部图形需要的空间 + padding
    val desiredWidth = contentWidth + paddingLeft + paddingRight
    val desiredHeight = contentHeight + paddingTop + paddingBottom

    // ③ 用 resolveSize 与父容器协商(处理三种 MeasureSpec 模式)
    val finalWidth = resolveSize(desiredWidth, widthMeasureSpec)
    val finalHeight = resolveSize(desiredHeight, heightMeasureSpec)

    // ④ 设置最终测量结果
    setMeasuredDimension(finalWidth, finalHeight)
}
4如果没有 resolveSize 会怎样?

假设你直接在 onMeasure中写 setMeasuredDimension(500, 500),完全忽略 MeasureSpec:

父容器的约束你的行为后果
layout_width="100dp"→ EXACTLY, 100dp 强行设为 500px View 会超出父容器边界,可能出现裁剪或覆盖相邻控件
layout_width="wrap_content"→ AT_MOST, 屏幕宽度 强行设为 500px 只要 500px 不超过屏幕宽度就正常,但逻辑上不严谨
核心教训:setMeasuredDimension()之前,必须resolveSize()将期望尺寸与父容器的 MeasureSpec 协商。否则你的 View 会无视父容器的布局约束,导致布局异常。

五、MeasureSpec 三种模式快速回顾

1三种模式速查表
模式含义XML 对应子 View 行为
EXACTLY 精确尺寸,必须遵守 match_parent/ 固定 dp 值 必须使用 specSize,不可偏离
AT_MOST 最大不超过此值 wrap_content 可以 ≤ specSize,但不能超过
UNSPECIFIED 无约束,自由决定 ScrollView 内部、特殊系统场景 爱多大就多大

MeasureSpec 是一个 32 位 int,高 2 位存 mode,低 30 位存 size。通过 MeasureSpec.getMode()MeasureSpec.getSize()提取。

2父容器如何生成子 View 的 MeasureSpec
// ViewGroup 中计算子 View MeasureSpec 的核心逻辑(简化版)
int getChildMeasureSpec(int parentMeasureSpec, int padding, int childLayoutParam) {
    int specMode = MeasureSpec.getMode(parentMeasureSpec);
    int specSize = MeasureSpec.getSize(parentMeasureSpec);
    int availableSize = Math.max(0, specSize - padding); // 扣除父容器 padding

    if (childLayoutParam == LayoutParams.MATCH_PARENT) {
        // match_parent → 模式和父容器相同,尺寸为可用空间
        return MeasureSpec.makeMeasureSpec(availableSize, specMode);
    } else if (childLayoutParam == LayoutParams.WRAP_CONTENT) {
        // wrap_content → 模式为 AT_MOST(不能超出可用空间)
        return MeasureSpec.makeMeasureSpec(availableSize, MeasureSpec.AT_MOST);
    } else {
        // 固定值 → EXACTLY
        return MeasureSpec.makeMeasureSpec(childLayoutParam, MeasureSpec.EXACTLY);
    }
}
关键公式:子 View 的 MeasureSpec = 父容器根据自身 MeasureSpec+ 子 View 的 LayoutParams计算得出。这是 Android 测量流程的起点。

六、resolveSize 与 resolveSizeAndState 源码解析

1resolveSizeAndState 源码(API 29+)
// View.java — resolveSizeAndState 源码(API 29+)
public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
    final int specMode = MeasureSpec.getMode(measureSpec);
    final int specSize = MeasureSpec.getSize(measureSpec);
    final int result;
    switch (specMode) {
        case MeasureSpec.AT_MOST:
            // 期望尺寸超出可用空间 → 返回可用空间并标记"空间不足"
            if (specSize < size) {
                result = specSize | MEASURED_STATE_TOO_SMALL;
            } else {
                // 期望尺寸在可用空间内 → 返回期望尺寸
                result = size;
            }
            break;
        case MeasureSpec.EXACTLY:
            // 精确模式 → 必须遵守父容器指定的大小
            result = specSize;
            break;
        case MeasureSpec.UNSPECIFIED:
        default:
            // 无约束 → 返回自己的期望尺寸
            result = size;
    }
    // 将子 View 的测量状态合并进去(保留高位的状态标志)
    return result | (childMeasuredState & MEASURED_STATE_MASK);
}
2resolveSize 源码
// View.java — resolveSize 源码
// 本质就是 resolveSizeAndState,只是不传递状态
public static int resolveSize(int size, int measureSpec) {
    return resolveSizeAndState(size, measureSpec, 0) & MEASURED_SIZE_MASK;
}
两者关系:resolveSize()内部调用 resolveSizeAndState(),然后把状态位清零(& MEASURED_SIZE_MASK),只返回尺寸。如果你不需要传递测量状态(如 MEASURED_STATE_TOO_SMALL),用 resolveSize()就够了。
3resolveSizeAndState 的使用场景

大多数自定义 View 用 resolveSize()就够了。但以下场景需要 resolveSizeAndState()

  • 自定义 ViewGroup:如果不传递状态,会导致顶层容器不知道内部发生了"空间溢出",可能就不会触发省略号显示、软键盘调整布局等系统行为
  • ScrollView / RecyclerView 内的 View:需要告知父容器"我装不下了",触发滚动行为

实际上大部分人不用它,因为多数场景下你的 View 尺寸是固定的,不会溢出。但了解它的存在和原理有助于理解 Android 测量系统的完整性。

七、getMeasuredWidth/Height vs getWidth/Height

1两者区别
方法含义可用时机值来源
getMeasuredWidth()
getMeasuredHeight()
期望尺寸(测量结果) onMeasure() 调用 setMeasuredDimension() 之后立即可用 setMeasuredDimension() 设置的值
getWidth()
getHeight()
实际尺寸(布局结果) layout() 执行完毕后可用 mRight - mLeftmBottom - mTop(父容器在 layout 中分配的最终边界)
一句话总结:getMeasuredWidth()是"我想要多大",getWidth()是"我实际占了多大"。大多数情况下两者相等,但如果父容器在 layout 阶段调整了位置(如偏移、拉伸),两者可能不同。
2SquareImageView 中为什么用 getMeasuredWidth?

在 SquareImageView 的 onMeasure()中:

int measuredWidth = getMeasuredWidth();   //  正确
int measuredHeight = getMeasuredHeight(); //  正确
// 不能用 getWidth() / getHeight(),因为此时还没有 layout!

onMeasure()发生在 layout()之前,此时 getWidth()返回 0。我们必须用 getMeasuredWidth()获取 super.onMeasure() 刚刚设置的值。

八、核心思想总结

两种自定义测量模式对比

模式一:微调现有View模式二:完全自定义
继承自 现有组件(AppCompatImageView 等) View(裸 View)
super.onMeasure() 调用,利用父类能力 不调用,自己全算
尺寸来源 父类测量结果 → 自己修正 内部图形尺寸 → 自己计算
resolveSize() 通常不需要(父类已处理) 必须手动调用
典型代码量 5 行核心逻辑 15-20 行(含 resolveSize)
代表案例 SquareImageView CircleView

四句话记住自定义测量

  1. 重写 onMeasure,别重写 layout——onMeasure 符合 Android measure→layout 流程,通用安全;layout 容易导致布局错乱
  2. 继承已有 View → 调 super 再修正——先让父类完成测量(super.onMeasure),拿到结果后取 max/min 覆盖,先后两次 setMeasuredDimension,以最后一次为准
  3. 从零写 View → 算尺寸 + resolveSize——根据内部图形算出期望尺寸,加上 padding,用 resolveSize() 与父容器协商,最后 setMeasuredDimension
  4. resolveSize 不是可选的——它确保你的期望尺寸不会违背父容器的强制约束(EXACTLY),不调用会导致 View 超出边界

完整决策流程

需要自定义
测量逻辑?
↙ 继承已有View? ↙ ↘ 从View派生? ↘
super.onMeasure()
→ getMeasuredWidth()
→ 取 max
→ setMeasuredDimension()
算期望尺寸
→ +padding
→ resolveSize()
→ setMeasuredDimension()
SquareImageView
CircleView

正确做法清单

  • 重写 onMeasure(),不管 layout()
  • 从零写 View 时必须用 resolveSize()
  • 期望尺寸中加上 padding
  • onDraw 中从 padding 偏移开始绘制
  • 了解 resolveSizeAndState 的存在(自定义 ViewGroup 时可能需要)

常见错误

  • 重写 layout() 强制修改边界(破坏父容器布局)
  • 忘记 resolveSize(),直接传期望值给 setMeasuredDimension
  • 期望尺寸忘了加 padding(内容贴边)
  • 在 onMeasure 中用 getWidth() 而非 getMeasuredWidth()
  • CircleView 中调 super.onMeasure()(父类默认实现不适用)