第2章:自定义View基础:从零开始创建一个自定义View

说实话,很多初学者一听到「自定义View」就觉得头大。我当年也是这样,总觉得那是大神才碰的东西。但后来我发现,只要你理解了几个核心概念,这事儿其实没那么玄乎。

今天我们就从零开始,手把手创建一个自定义View。我会带着你一步步走,把构造方法、onDraw、Canvas和Paint这几个关键点讲透。

2.1 自定义View的三种构造方法

先说说构造方法。你可能会问:「为啥一个View要写好几个构造方法?」

嗯,这其实是为了适配不同的创建场景。Android系统在XML布局里加载View时,和你在代码里new一个View时,调用的构造方法是不一样的。

public class MyCustomView extends View {

    // 在代码中直接new时调用
    public MyCustomView(Context context) {
        super(context);
        init();
    }

    // 在XML布局中使用时调用
    public MyCustomView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    // 带style属性时调用
    public MyCustomView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
}

我个人习惯把初始化逻辑抽到一个单独的 init() 方法里。这样不管哪个构造方法进来,都能走同一套初始化流程。省得你写三遍重复代码,对吧?

小提示:如果你不确定该重写几个构造方法,至少把前两个写上。第三个带defStyleAttr的,大部分场景用不到。

2.2 onDraw方法:View的「画布时刻」

onDraw是自定义View的灵魂。说白了,你所有想要显示的内容,都得在这里画出来。

系统会在需要绘制View的时候自动调用onDraw。你不需要手动去调它,只需要在需要刷新时调用 invalidate() 就行。

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    // 你的绘制代码写在这里
}

我记得刚入行那会儿,有个同事在onDraw里做了大量对象创建的操作,结果界面卡得不行。后来我才明白——onDraw会被频繁调用,千万别在这里new对象。

避坑指南:我曾经在onDraw里直接new了一个Paint对象,导致列表滑动时帧率掉到20帧。后来改成在init()里提前创建好,问题就解决了。

2.3 Canvas:你的画板

Canvas就像一块画板。你可以在上面画圆、画线、画文字、画图片。它提供了一系列的drawXXX方法,基本上你能想到的图形都能画。

方法 作用
drawCircle() 画圆形
drawRect() 画矩形
drawLine() 画直线
drawText() 画文字
drawBitmap() 画图片
drawPath() 画任意路径

你想想看,Canvas其实就是一个「状态机」。你调用drawCircle时,它就在当前位置画个圆。你调用translate时,它就把坐标系移动一下。理解了这个,后面学动画就轻松多了。

2.4 Paint:你的画笔

光有画板还不够,你还得有一支画笔。Paint就是干这个的。

你可以设置画笔的颜色、粗细、样式、抗锯齿等等。没有Paint,Canvas画出来的东西就是默认的黑色细线,丑得没法看。

private Paint mPaint;

private void init() {
    mPaint = new Paint();
    mPaint.setColor(Color.RED);          // 设置颜色
    mPaint.setStrokeWidth(5f);           // 设置线宽
    mPaint.setStyle(Paint.Style.FILL);   // 设置填充样式
    mPaint.setAntiAlias(true);           // 开启抗锯齿
}
重点:抗锯齿一定要开!不开的话,画出来的圆边缘会有锯齿,看着特别廉价。setAntiAlias(true) 这行代码,我建议你养成习惯。

2.5 实战:画一个带边框的圆形

光说不练假把式。我们来写一个完整的自定义View,画一个红色填充、蓝色边框的圆形。

public class CircleView extends View {

    private Paint mFillPaint;
    private Paint mStrokePaint;

    public CircleView(Context context) {
        super(context);
        init();
    }

    public CircleView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    private void init() {
        // 填充画笔
        mFillPaint = new Paint();
        mFillPaint.setColor(Color.RED);
        mFillPaint.setStyle(Paint.Style.FILL);
        mFillPaint.setAntiAlias(true);

        // 边框画笔
        mStrokePaint = new Paint();
        mStrokePaint.setColor(Color.BLUE);
        mStrokePaint.setStyle(Paint.Style.STROKE);
        mStrokePaint.setStrokeWidth(8f);
        mStrokePaint.setAntiAlias(true);
    }

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

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

        // 画填充圆
        canvas.drawCircle(width / 2f, height / 2f, radius, mFillPaint);
        // 画边框圆
        canvas.drawCircle(width / 2f, height / 2f, radius, mStrokePaint);
    }
}

这段代码里,我用了两支画笔。一支画填充,一支画边框。你可能会问:「为啥不用一支画笔切换样式?」

嗯,理论上可以。但实际项目中,我建议你每种样式单独建一个Paint对象。这样代码更清晰,也方便后续修改。我在项目里见过有人用一个Paint来回切换,结果改了一个地方,其他地方也跟着变了,调试起来特别头疼。

2.6 理解View的尺寸

在onDraw里,getWidth()getHeight() 拿到的是View的实际宽高。但要注意,这两个值在onDraw被调用时才是有效的。如果你在构造方法里调用,拿到的就是0。

为什么会这样?因为View的测量和布局是在onDraw之前完成的。构造方法执行时,View还没被测量呢。

小技巧:如果你需要在View创建时就获取尺寸,可以重写 onSizeChanged() 方法。这个方法在尺寸变化时会被调用,而且参数里直接给你传了新的宽高。

2.7 总结一下

今天的内容其实就三个核心点:

  • 构造方法:负责初始化,把Paint等对象创建好
  • onDraw:负责绘制,用Canvas和Paint画出你想要的内容
  • Canvas + Paint:一个提供画板,一个提供画笔,缺一不可

我个人觉得,自定义View最难的不是语法,而是「思维转换」。你习惯了写布局文件里那些现成的控件,突然要自己控制每一个像素的绘制,确实需要适应一下。

但别急,后面几章我们会一步步深入。从简单的图形,到复杂的动画,再到性能优化。等你学完这30章,你会发现——自定义View其实挺有意思的。

下一章,我们来聊聊「测量与布局:onMeasure和onLayout的深入理解」。到时候我会分享一个我在实际项目中踩过的坑,保证让你印象深刻。