从 SquareImageView 到 CircleView,掌握两种自定义测量模式:微调现有View vs 完全自主计算
Android 自带的 View(ImageView、TextView 等)有自己默认的测量逻辑,但在以下场景就不够用了:
| 场景 | 默认行为 | 你真正想要的 |
|---|---|---|
| 正方形头像 | ImageView 按图片原始比例显示,宽高可能不一致 | 无论源图比例如何,最终显示为正方形,取较长边对齐 |
| 自定义圆形 View | 没有现成的 CircleView,需要自己从 View 派生 | 根据圆的半径 + padding 决定 View 尺寸,不受父容器内容影响 |
onMeasure()要做的事。
根据你的起点不同,自定义测量分为两大类:
继承 AppCompatImageView、AppCompatTextView等现有组件,在父类测量之后对结果做修正。
代表案例:SquareImageView(正方形 ImageView)
核心操作:super.onMeasure()→ 获取结果 → 取 max → setMeasuredDimension(max, max)
直接从 View派生,不调用 super,根据内部图形的尺寸自主计算期望宽高。
代表案例:CircleView(自定义圆形 View)
核心操作:计算内容尺寸 → +padding → resolveSize()修正 → setMeasuredDimension()
选型指南:如果你只是在现有组件基础上微调(如限制为正方形、限制最大宽高比),用模式一更省事。如果你从零开始画内容(如画圆、画自定义图形),用模式二。
要让一个 View 变成正方形,有两种技术方案:
| 方案 | 介入时机 | 原理 | 推荐度 |
|---|---|---|---|
| 重写 onMeasure() | 测量阶段 | 在测量阶段告诉父容器自己的期望尺寸是正方形,父容器在布局时根据这个测量结果分配空间 | 强烈推荐 |
| 重写 layout() | 布局阶段 | 在布局阶段强行修改父容器分配的边界,把 r、b 替换成 l+max、t+max | 不推荐 |
measure → layout流程,不会破坏父容器的布局逻辑,通用性最好。你的 SquareImageView 示例就是这种做法,简洁且安全。
也能变成正方形,但风险较大:
在社交应用、电商应用中,头像、商品图经常需要正方形显示。但用户上传的图片比例千奇百怪(16:9、4:3、1:1…),ImageView 默认按比例缩放,无法保证正方形。
解决方案:继承 AppCompatImageView,重写 onMeasure(),在测量完毕后重新测量一次,让较短的一边对齐较长的一边。
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);
}
}
measure(widthSpec, heightSpec)(final方法,不可重写)
onMeasure(widthSpec, heightSpec)
setMeasuredDimension(w, h)(第1次)
max(w, h),调用 setMeasuredDimension(max, max)(第2次,覆盖第1次结果)
PFLAG_MEASURED_DIMENSION_SET标志位是否已设置 → 通过
onMeasure结尾追加了自定义尺寸逻辑,先后两次调用 setMeasuredDimension,最终以最后一次结果为准,无额外测量开销。
<!-- 在布局文件中使用,和普通 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 居中裁剪)。
| SquareImageView | CircleView | |
|---|---|---|
| 继承自 | AppCompatImageView(已有组件) | View(裸 View,无默认测量逻辑) |
| super.onMeasure() | 调用(利用父类已有的测量能力) | 不调用(父类默认实现不够用) |
| 尺寸来源 | 父类测量结果(基于图片比例 + scaleType) | 内部图形尺寸(圆的半径 + padding) |
| 依赖关系 | 尺寸由外部图片决定 → 再修正 | 尺寸由内部图形决定 → 影响外部 |
| 需要 resolveSize() | (父类已处理) | (必须手动处理 MeasureSpec) |
resolveSize()将计算结果与父容器的约束(MeasureSpec)进行协商。
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
)
}
}
padding变量存的是"圆周围留白距离",radius是圆的半径任何从 View 直接派生的自定义组件,测量流程都遵循以下四步:
用代码模板表示:
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)
}
假设你直接在 onMeasure中写 setMeasuredDimension(500, 500),完全忽略 MeasureSpec:
| 父容器的约束 | 你的行为 | 后果 |
|---|---|---|
layout_width="100dp"→ EXACTLY, 100dp |
强行设为 500px | View 会超出父容器边界,可能出现裁剪或覆盖相邻控件 |
layout_width="wrap_content"→ AT_MOST, 屏幕宽度 |
强行设为 500px | 只要 500px 不超过屏幕宽度就正常,但逻辑上不严谨 |
setMeasuredDimension()之前,必须用 resolveSize()将期望尺寸与父容器的 MeasureSpec 协商。否则你的 View 会无视父容器的布局约束,导致布局异常。
| 模式 | 含义 | 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()提取。
// 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.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);
}
// 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()就够了。
大多数自定义 View 用 resolveSize()就够了。但以下场景需要 resolveSizeAndState():
实际上大部分人不用它,因为多数场景下你的 View 尺寸是固定的,不会溢出。但了解它的存在和原理有助于理解 Android 测量系统的完整性。
| 方法 | 含义 | 可用时机 | 值来源 |
|---|---|---|---|
getMeasuredWidth()getMeasuredHeight() |
期望尺寸(测量结果) | onMeasure() 调用 setMeasuredDimension() 之后立即可用 | setMeasuredDimension() 设置的值 |
getWidth()getHeight() |
实际尺寸(布局结果) | layout() 执行完毕后可用 | mRight - mLeft、mBottom - mTop(父容器在 layout 中分配的最终边界) |
getMeasuredWidth()是"我想要多大",getWidth()是"我实际占了多大"。大多数情况下两者相等,但如果父容器在 layout 阶段调整了位置(如偏移、拉伸),两者可能不同。
在 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 |