一、View的绘制流程:理解onMeasure、onLayout、onDraw三大核心方法

说实话,刚接触Android自定义View的时候,我也有点懵。三个方法,十几个参数,各种模式——到底谁先谁后?每个方法该干什么?

别急,咱们一步步拆解。View的绘制流程,说白了就是三个步骤:量一量、摆一摆、画一画。对应到代码里,就是onMeasure、onLayout、onDraw。

1.1 绘制流程的总览

先记住这个顺序:onMeasure → onLayout → onDraw。这个顺序是固定的,系统从根View开始,一层层往下调用。

我个人习惯把这三个方法比作装修房子:

  • onMeasure:量尺寸。量一下客厅多宽、卧室多长,决定家具该买多大的。
  • onLayout:摆位置。沙发放哪、桌子放哪,确定每个家具的位置。
  • onDraw:刷墙贴砖。真正把颜色、图案画上去。

嗯,这个比喻虽然简单,但很实用。你想想看,如果尺寸没量对,后面摆位置和画画都是白搭。

1.2 onMeasure:测量模式与尺寸计算

onMeasure是绘制流程的第一步,也是坑最多的一步。我在项目中遇到过好几次,自定义View显示不全或者撑爆屏幕,最后发现都是测量模式没处理好。

系统会传进来两个参数:widthMeasureSpecheightMeasureSpec。这两个参数里,其实藏着两个信息:测量模式父容器建议的大小

测量模式有三种:

模式 含义
EXACTLY MeasureSpec.EXACTLY 精确模式。父容器说了,你就这么大。比如match_parent或固定dp值。
AT_MOST MeasureSpec.AT_MOST 最大模式。你最大不能超过这个值,具体多大你自己看着办。比如wrap_content。
UNSPECIFIED MeasureSpec.UNSPECIFIED 未指定模式。你想多大就多大,一般用在ScrollView或ListView里。

重点来了:自定义View时,如果你不重写onMeasure,默认行为是直接使用父容器给的大小。这会导致wrap_content失效——明明设置了wrap_content,结果却撑满了全屏。

为什么会这样?因为View的默认onMeasure实现,直接调用了setMeasuredDimension,传进去的是MeasureSpec里解析出来的大小。对于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);

    // 默认宽高
    int defaultWidth = 200;
    int defaultHeight = 200;

    if (widthMode == MeasureSpec.AT_MOST) {
        // wrap_content时,使用我们自己的默认大小
        widthSize = Math.min(defaultWidth, widthSize);
    }
    if (heightMode == MeasureSpec.AT_MOST) {
        heightSize = Math.min(defaultHeight, heightSize);
    }

    setMeasuredDimension(widthSize, heightSize);
}

小技巧:我个人习惯把默认大小定义成常量,比如DEFAULT_WIDTH = 200。这样在多个自定义View里复用,也方便后期调整。

1.3 onLayout:子View的摆放逻辑

onLayout是ViewGroup特有的方法。说白了,就是决定每个子View放在哪。

我曾经踩过一个坑:自定义了一个流式布局,结果子View全叠在一起了。排查了半天,发现是onLayout里忘了计算每个子View的left和top。

onLayout的签名是这样的:

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    // changed:布局是否发生变化
    // l, t, r, b:当前ViewGroup的边界
}

对于ViewGroup,你需要遍历所有子View,调用每个子View的layout()方法:

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childCount = getChildCount();
    int currentTop = t;
    int currentLeft = l;

    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(currentLeft, currentTop,
                         currentLeft + childWidth, currentTop + childHeight);

            // 更新下一个子View的位置
            currentTop += childHeight;
        }
    }
}

注意:onLayout里一定要用getMeasuredWidth()getMeasuredHeight(),而不是getWidth()getHeight()。因为此时View还没有完成布局,getWidth()可能返回0。

1.4 onDraw:真正的绘制工作

onDraw是最后一步,也是最有意思的一步。在这里,你可以用Canvas画任何东西——圆形、矩形、文字、甚至自定义的路径。

我记得第一次写自定义View时,在onDraw里画了一个渐变圆环,效果出来那一刻,真的很有成就感。

onDraw的基本结构:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    // 1. 绘制背景
    // 2. 绘制内容
    // 3. 绘制子View(如果是ViewGroup)
    // 4. 绘制装饰(如前景、滚动条等)
}

一个简单的例子:画一个带边框的圆形

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    int width = getWidth();
    int height = getHeight();
    int radius = Math.min(width, height) / 2;

    // 画填充圆
    paint.setStyle(Paint.Style.FILL);
    paint.setColor(Color.BLUE);
    canvas.drawCircle(width / 2f, height / 2f, radius, paint);

    // 画边框
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeWidth(5);
    paint.setColor(Color.WHITE);
    canvas.drawCircle(width / 2f, height / 2f, radius, paint);
}

性能提醒:onDraw方法会被频繁调用(比如滑动、动画)。不要在onDraw里创建对象,否则会频繁触发GC,导致掉帧。把Paint、Path这些对象在构造方法里创建好。

1.5 三个方法的调用时机与关系

这三个方法不是独立运行的。它们之间有明确的依赖关系:

  • onMeasure先执行,确定宽高。
  • onLayout接着执行,确定位置。
  • onDraw最后执行,进行绘制。

如果onMeasure里设置的尺寸不对,onLayout里摆放的位置就会错位,onDraw画出来的东西自然也不对。

另外,这三个方法不一定每次都会全部执行。比如:

  • 如果只是改变位置(调用layout()),只会触发onLayout和onDraw。
  • 如果只是改变背景色(调用invalidate()),只会触发onDraw。
  • 如果改变了尺寸(调用requestLayout()),三个方法都会重新执行。

避坑指南:我曾经在自定义View里调用了invalidate(),以为只会重绘,结果发现onMeasure也被调用了。后来才意识到,是因为我在onDraw里修改了某个会影响尺寸的属性。所以,不要在onDraw里修改会影响布局的属性

1.6 实战:一个完整的自定义View示例

说了这么多,咱们来个完整的例子。一个带进度显示的圆形进度条:

public class CircleProgressView extends View {
    private Paint paint;
    private int progress = 0;
    private static final int DEFAULT_SIZE = 300;

    public CircleProgressView(Context context, AttributeSet attrs) {
        super(context, attrs);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    }

    @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);

        if (widthMode == MeasureSpec.AT_MOST) {
            widthSize = Math.min(DEFAULT_SIZE, widthSize);
        }
        if (heightMode == MeasureSpec.AT_MOST) {
            heightSize = Math.min(DEFAULT_SIZE, heightSize);
        }

        // 保持正方形
        int size = Math.min(widthSize, heightSize);
        setMeasuredDimension(size, size);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width = getWidth();
        int height = getHeight();
        int radius = Math.min(width, height) / 2 - 20;

        // 画背景圆环
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeWidth(30);
        paint.setColor(Color.LTGRAY);
        canvas.drawCircle(width / 2f, height / 2f, radius, paint);

        // 画进度弧
        paint.setColor(Color.BLUE);
        float sweepAngle = progress / 100f * 360;
        canvas.drawArc(20, 20, width - 20, height - 20,
                       -90, sweepAngle, false, paint);

        // 画进度文字
        paint.setStyle(Paint.Style.FILL);
        paint.setTextSize(50);
        paint.setColor(Color.BLACK);
        String text = progress + "%";
        float textWidth = paint.measureText(text);
        canvas.drawText(text, (width - textWidth) / 2f,
                        height / 2f + 15, paint);
    }

    public void setProgress(int progress) {
        this.progress = Math.max(0, Math.min(100, progress));
        invalidate(); // 只触发onDraw
    }
}

这个例子涵盖了三个核心方法:onMeasure处理了wrap_content的情况,onDraw画了圆环和文字。你想想看,如果onMeasure里没有处理AT_MOST模式,这个View在wrap_content时就会撑满屏幕,效果就完全不对了。

1.7 总结

View的绘制流程,说白了就是三个步骤:量、摆、画。每个步骤都有它的职责和坑点:

  • onMeasure:别忘了处理wrap_content,否则默认会撑满父容器。
  • onLayout:用getMeasuredWidth/Height,别用getWidth/Height。
  • onDraw:别创建对象,别修改布局属性。

嗯,掌握了这三个方法,自定义View就算入门了。下一章我们会深入讲Canvas和Paint的高级用法,到时候就能画出更炫酷的效果了。

一句话记住:onMeasure定大小,onLayout定位置,onDraw定长相。三者缺一不可,顺序也不能乱。