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() 测量宽高 onLayout() 确定子View位置 onDraw() 绘制内容 📌 调用顺序(系统自动触发) 1. 父View调用 child.measure() → 触发 onMeasure() 2. 父View调用 child.layout() → 触发 onLayout() 3. 系统调用 child.draw() → 触发 onDraw()

二、onMeasure:你到底想要多大?

onMeasure 是自定义 View 里最容易出问题的地方。我刚开始写的时候,经常遇到 View 显示不出来或者尺寸不对,十有八九是测量没写好。

系统会传进来两个参数:widthMeasureSpecheightMeasureSpec。它们不是简单的数值,而是把「模式」和「大小」打包在了一起。模式有三种:

模式 含义 典型场景
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 布局里能设置这些值。这就用到自定义属性了。

步骤很简单,三步走:

  1. res/values/attrs.xml 里声明属性
  2. 在 View 构造方法里解析这些属性
  3. 在布局文件里使用

先看 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_STROKE
  • setStrokeWidth() —— 描边宽度
  • 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,你会发现这东西其实没那么神秘。


公众号:蓝海资料掘金营,微信 deep3321