28、自定义View:onMeasure、onLayout、onDraw流程,自定义属性,Canvas与Paint基础绘图
自定义 View 是 Android 开发中绕不开的一道坎。说实话,我当年刚入行时,看到别人写的自定义 View 代码,心里直犯怵——又是测量又是布局,还要自己拿笔画图,感觉像在写一个微型游戏引擎。但后来我明白了,这东西说白了就是三件事:量一量、摆一摆、画一画。
今天我们就把这套流程彻底捋清楚。我会结合自己踩过的坑,带你一步步掌握自定义 View 的核心套路。
一、自定义 View 的三大流程
Android 系统绘制一个 View,会依次调用三个方法:
- onMeasure() —— 测量自己该占多大地方
- onLayout() —— 确定子 View 的位置(ViewGroup 才需要)
- onDraw() —— 把自己画出来
你想想看,这就像你装修房子:先量尺寸(onMeasure),再摆家具(onLayout),最后刷墙贴壁纸(onDraw)。顺序不能乱,少一步都不行。
核心要点:这三个方法由系统自动调用,你只需要重写它们。千万别自己手动调用 onDraw(),用 invalidate() 通知系统重绘就行。
二、onMeasure:你到底想要多大?
onMeasure 是自定义 View 里最容易出问题的地方。我刚开始写的时候,经常遇到 View 显示不出来或者尺寸不对,十有八九是测量没写好。
系统会传进来两个参数:widthMeasureSpec 和 heightMeasureSpec。它们不是简单的数值,而是把「模式」和「大小」打包在了一起。模式有三种:
| 模式 | 含义 | 典型场景 |
|---|---|---|
| EXACTLY | 精确值,大小已定 | match_parent 或具体 dp 值 |
| AT_MOST | 最大不能超过某个值 | wrap_content |
| UNSPECIFIED | 随便多大都行 | ScrollView 内部、系统内部使用 |
💡 我的习惯:写自定义 View 时,一定要处理 wrap_content 的情况。很多人忘了这茬,结果 View 设置了 wrap_content 却撑满了全屏。这是因为你没在 AT_MOST 模式下主动设置一个默认大小。
来看一个标准写法:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 先获取模式和大小
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
// 处理 wrap_content 的情况
if (widthMode == MeasureSpec.AT_MOST) {
// 设置一个默认宽度,比如 200px
widthSize = 200;
}
if (heightMode == MeasureSpec.AT_MOST) {
heightSize = 200;
}
// 必须调用 setMeasuredDimension 保存结果
setMeasuredDimension(widthSize, heightSize);
}
这里有个坑:setMeasuredDimension() 必须调用,否则会抛异常。我曾经在写一个复杂布局时,忘了在某个分支里调用它,结果崩溃日志看得我一脸懵。
三、onLayout:把孩子们摆好
如果你自定义的是 View(不是 ViewGroup),那 onLayout 基本不用管。但如果你写的是 ViewGroup,比如自定义一个流式布局,那 onLayout 就是你的主战场。
onLayout 的任务很简单:遍历所有子 View,调用它们的 layout(l, t, r, b) 方法,告诉每个孩子「你该站在哪个位置」。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int currentX = getPaddingLeft();
int currentY = getPaddingTop();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (child.getVisibility() != GONE) {
int childWidth = child.getMeasuredWidth();
int childHeight = child.getMeasuredHeight();
// 摆放子 View
child.layout(currentX, currentY,
currentX + childWidth, currentY + childHeight);
// 更新下一个位置
currentX += childWidth;
}
}
}
⚠️ 注意:onLayout 里用的宽高,必须是 onMeasure 阶段测量出来的值。用 getMeasuredWidth() 和 getMeasuredHeight(),别用 getWidth()——后者在 onLayout 执行时可能还没更新。
四、onDraw:开始画画
onDraw 是自定义 View 最有趣的部分。你手里有两样武器:Canvas(画布)和 Paint(画笔)。
Canvas 负责「怎么画」——画圆、画线、画文字、画图片。Paint 负责「画成什么样」——颜色、粗细、阴影、渐变。
我个人的经验是:把 Paint 对象创建在构造方法里,不要在 onDraw 里 new 对象。onDraw 每秒可能调用几十次,频繁创建对象会导致 GC 频繁触发,界面卡顿。
// 在构造方法中初始化画笔
private Paint mCirclePaint;
private Paint mTextPaint;
public MyCustomView(Context context, AttributeSet attrs) {
super(context, attrs);
initPaints();
}
private void initPaints() {
mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mCirclePaint.setColor(Color.BLUE);
mCirclePaint.setStyle(Paint.Style.FILL);
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setColor(Color.WHITE);
mTextPaint.setTextSize(40);
mTextPaint.setTextAlign(Paint.Align.CENTER);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int cx = getWidth() / 2;
int cy = getHeight() / 2;
int radius = Math.min(cx, cy) - 20;
// 画一个蓝色圆
canvas.drawCircle(cx, cy, radius, mCirclePaint);
// 在圆心画文字
canvas.drawText("Hello", cx, cy + 15, mTextPaint);
}
你看,代码其实不多。但这里有个细节:super.onDraw(canvas) 要不要调用?如果你完全自己画,可以调也可以不调。但如果你继承的是 Button、TextView 这类已有内容的 View,建议调一下,否则系统默认的绘制就没了。
五、自定义属性:让布局文件里能配置
光在代码里写死颜色和大小,太死板了。我们得让用户在 XML 布局里能设置这些值。这就用到自定义属性了。
步骤很简单,三步走:
- 在
res/values/attrs.xml里声明属性 - 在 View 构造方法里解析这些属性
- 在布局文件里使用
先看 attrs.xml:
<resources>
<declare-styleable name="MyCustomView">
<attr name="circleColor" format="color" />
<attr name="circleRadius" format="dimension" />
<attr name="labelText" format="string" />
</declare-styleable>
</resources>
然后在构造方法里解析:
public MyCustomView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyCustomView);
int circleColor = a.getColor(R.styleable.MyCustomView_circleColor, Color.BLUE);
float radius = a.getDimension(R.styleable.MyCustomView_circleRadius, 100f);
String label = a.getString(R.styleable.MyCustomView_labelText);
a.recycle(); // 记得回收!
// ... 用这些值初始化画笔
}
💡 避坑指南:TypedArray 用完后一定要调用 recycle()。我曾经漏掉这一行,结果在列表滚动时频繁创建 View,内存泄漏了。排查了半天才发现是 TypedArray 没释放。
布局文件里这样用:
<com.example.MyCustomView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:circleColor="#FF5722"
app:circleRadius="80dp"
app:labelText="自定义View" />
六、Canvas 与 Paint 基础绘图
Canvas 提供了一系列绘图方法,我挑几个最常用的:
| 方法 | 作用 | 示例 |
|---|---|---|
| drawCircle() | 画圆 | canvas.drawCircle(cx, cy, r, paint) |
| drawRect() | 画矩形 | canvas.drawRect(left, top, right, bottom, paint) |
| drawLine() | 画直线 | canvas.drawLine(startX, startY, stopX, stopY, paint) |
| drawText() | 画文字 | canvas.drawText(text, x, y, paint) |
| drawPath() | 画任意路径 | 配合 Path 对象使用 |
| drawBitmap() | 画图片 | canvas.drawBitmap(bitmap, left, top, paint) |
Paint 的常用设置:
setColor()—— 颜色setStyle()—— FILL(填充)、STROKE(描边)、FILL_AND_STROKEsetStrokeWidth()—— 描边宽度setTextSize()—— 文字大小setAntiAlias(true)—— 抗锯齿,必开setShader()—— 设置渐变、图片纹理等
核心原则:onDraw 里不要做耗时操作,不要 new 对象,不要做复杂的计算。它应该只做一件事——把已有的数据画出来。如果你需要更新画面,调用 invalidate() 触发重绘即可。
七、一个完整的例子:自定义圆形进度条
说了这么多,我们来写一个实际能用的东西——圆形进度条。它综合了测量、绘制和自定义属性。
public class CircleProgressView extends View {
private Paint mBgPaint;
private Paint mProgressPaint;
private Paint mTextPaint;
private float mProgress = 0f; // 0~100
public CircleProgressView(Context context, AttributeSet attrs) {
super(context, attrs);
initPaints();
}
private void initPaints() {
mBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBgPaint.setColor(0xFFE0E0E0);
mBgPaint.setStyle(Paint.Style.STROKE);
mBgPaint.setStrokeWidth(20);
mProgressPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mProgressPaint.setColor(0xFF3F51B5);
mProgressPaint.setStyle(Paint.Style.STROKE);
mProgressPaint.setStrokeWidth(20);
mProgressPaint.setStrokeCap(Paint.Cap.ROUND); // 圆头
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setColor(0xFF333333);
mTextPaint.setTextSize(50);
mTextPaint.setTextAlign(Paint.Align.CENTER);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 确保是正方形
int size = Math.min(
MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec));
setMeasuredDimension(size, size);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int center = getWidth() / 2;
int radius = center - 30; // 留出画笔宽度空间
// 画背景圆环
canvas.drawCircle(center, center, radius, mBgPaint);
// 画进度弧
RectF oval = new RectF(center - radius, center - radius,
center + radius, center + radius);
float sweepAngle = (mProgress / 100f) * 360f;
canvas.drawArc(oval, -90, sweepAngle, false, mProgressPaint);
// 画百分比文字
String text = (int) mProgress + "%";
// 文字垂直居中需要计算基线
Paint.FontMetrics fm = mTextPaint.getFontMetrics();
float textY = center - (fm.ascent + fm.descent) / 2;
canvas.drawText(text, center, textY, mTextPaint);
}
public void setProgress(float progress) {
mProgress = Math.max(0, Math.min(100, progress));
invalidate(); // 触发重绘
}
}
这个例子虽然简单,但涵盖了自定义 View 的核心要素:测量保证正方形、onDraw 里画圆环和文字、对外提供 setProgress 方法更新状态。你在项目里可以直接拿来用,改改颜色和粗细就行。
好了,关于自定义 View 的核心流程就讲到这里。说白了就是三件事:量好尺寸、摆好位置、画好内容。再加上自定义属性让布局更灵活,Canvas 和 Paint 让你想画什么画什么。多写几个自定义 View,你会发现这东西其实没那么神秘。