33、自定义View基础:onDraw、Canvas、Paint、Path绘制基本图形

说实话,自定义View是Android开发里绕不过去的一道坎。很多新手一听到「自定义View」就觉得头大,觉得那是大神才玩的东西。其实没那么玄乎——说白了,就是你在屏幕上画画而已。

我刚开始带团队的时候,有个同事为了做一个圆角进度条,硬是用了三层嵌套的布局加两张切图。我看了半天,问他:「你为什么不直接画一个?」他愣了下,说:「画?怎么画?」嗯,这就是我们今天要聊的东西。

自定义View的核心流程

自定义View的本质,就是重写它的绘制方法。Android系统会在合适的时机调用这些方法,你只需要告诉它「画什么」和「怎么画」。

我个人习惯把自定义View分成三个步骤:

  1. 继承View —— 创建一个类,继承自View或其子类
  2. 重写onDraw —— 在这里执行你的绘制逻辑
  3. 使用Canvas和Paint —— Canvas是画布,Paint是画笔

就这么简单。你想想看,你在纸上画画需要什么?纸、笔、还有你想画的内容。对应到Android里,Canvas就是纸,Paint就是笔,而onDraw就是你画画的动作。

核心要点:onDraw方法会被频繁调用(比如每次界面刷新、每次滚动),所以千万不要在里面做耗时操作,也不要创建对象。我见过有人直接在onDraw里new Paint(),结果界面卡成PPT。

onDraw:绘制的入口

onDraw是View绘制流程的最后一站。系统准备好画布后,就会调用这个方法,把Canvas对象传给你。

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    // 在这里画你的内容
}

注意那个super.onDraw(canvas)。如果你继承的是ViewGroup或者某个已有控件(比如TextView),记得调用父类方法,否则原有的绘制逻辑就丢了。但如果你继承的是纯粹的View,不调用也没关系——View的onDraw本来就是空的。

我曾经踩过的坑:有一次我继承LinearLayout重写onDraw,忘了调super,结果整个布局的背景全没了,子View也显示不出来。排查了半天才发现是这个问题。嗯,血的教训。

Canvas:画布的操作

Canvas提供了各种绘图方法。我把它分成几类:

类别 常用方法 说明
绘制图形 drawCircle, drawRect, drawOval, drawLine 画基本几何形状
绘制文本 drawText 在画布上写字
绘制路径 drawPath 画任意形状
变换操作 translate, rotate, scale, save, restore 移动、旋转、缩放画布

这里有个小技巧:save和restore要成对使用。save会把当前画布状态(比如旋转角度、裁剪区域)保存到栈里,restore则恢复。我习惯在每次变换前后都加上这对操作,避免影响后续绘制。

canvas.save();
canvas.rotate(45, centerX, centerY);
// 画旋转后的内容
canvas.restore();
// 画布恢复原状,不影响后面的绘制

Paint:画笔的配置

Paint决定了你画出来的东西长什么样。颜色、粗细、填充还是描边、抗锯齿……都在这里设置。

Paint paint = new Paint();
paint.setColor(Color.RED);
paint.setStrokeWidth(4f);
paint.setStyle(Paint.Style.STROKE);  // 描边模式
paint.setAntiAlias(true);            // 抗锯齿

我个人习惯把Paint的创建放在构造函数里,而不是onDraw中。因为onDraw会被频繁调用,每次都new一个Paint对象,GC压力会很大。你想想看,一秒钟刷新60帧,每帧都new一个对象,那画面太美我不敢看。

避坑指南:setAntiAlias一定要设为true。不设的话,画出来的圆会有明显的锯齿,看起来像像素游戏里的素材。我曾经在一个支付项目里忘了开抗锯齿,结果那个圆形按钮的边缘全是锯齿,被测试小姐姐追着改了三版。

Path:画任意形状

Path是Canvas的进阶玩法。圆、矩形这些基本图形用Canvas直接画就行,但如果你想画个五角星、画个波浪线、画个心形,就得靠Path了。

Path的核心操作就几个:

  • moveTo —— 把笔抬起来,移动到某个点
  • lineTo —— 从当前点画直线到目标点
  • quadTo / cubicTo —— 画贝塞尔曲线
  • close —— 闭合路径
  • addCircle / addRect —— 添加基本图形到路径中

举个例子,画一个三角形:

Path path = new Path();
path.moveTo(100, 100);   // 起点
path.lineTo(200, 100);   // 画到右上角
path.lineTo(150, 200);   // 画到底部中间
path.close();            // 闭合,回到起点

canvas.drawPath(path, paint);

Path配合贝塞尔曲线,可以画出各种流畅的曲线。比如做下拉刷新时的波浪效果、做图表时的平滑折线,都是用Path实现的。

实战:画一个带阴影的圆形

光说不练假把式。我们写一个完整的自定义View,画一个带阴影的圆形:

public class ShadowCircleView extends View {
    private Paint circlePaint;
    private Paint shadowPaint;
    private float radius = 100f;

    public ShadowCircleView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    private void init() {
        // 主圆画笔
        circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        circlePaint.setColor(Color.parseColor("#FF6B6B"));
        circlePaint.setStyle(Paint.Style.FILL);

        // 阴影画笔
        shadowPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        shadowPaint.setColor(Color.parseColor("#33000000"));
        shadowPaint.setShadowLayer(12f, 4f, 4f, Color.GRAY);
    }

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

        float cx = getWidth() / 2f;
        float cy = getHeight() / 2f;

        // 先画阴影(偏移4px)
        canvas.drawCircle(cx + 4, cy + 4, radius, shadowPaint);
        // 再画主圆
        canvas.drawCircle(cx, cy, radius, circlePaint);
    }
}

这里有个细节:阴影用setShadowLayer实现,但要注意,这个API在关闭硬件加速时才能正常工作。如果你发现阴影没显示,检查一下View是否开启了硬件加速。我一般在自定义View的构造函数里加一句setLayerType(LAYER_TYPE_SOFTWARE, null)

知识体系总览

下面这张图概括了自定义View绘制的核心脉络:

自定义View绘制核心流程 onDraw(Canvas canvas) Canvas 画布操作 Paint 画笔配置 Path 路径定义 Canvas 常用方法 • drawCircle / drawRect / drawOval • drawText / drawBitmap • translate / rotate / save / restore Paint 核心属性 • setColor / setStrokeWidth • setStyle (FILL/STROKE) • setAntiAlias / setShadowLayer Path 路径操作 • moveTo / lineTo / close • quadTo / cubicTo (贝塞尔) • addCircle / addRect / addPath

几个实用建议

最后,分享几个我在项目中积累的经验:

  • 不要在onDraw里做计算 —— 所有坐标、尺寸的计算放到onSizeChanged或构造函数里。onDraw只负责画。
  • 善用invalidate —— 需要重绘时调用invalidate(),它会触发onDraw。但别频繁调用,一秒钟60次就够了。
  • 考虑View的测量 —— 自定义View最好重写onMeasure,否则wrap_content会失效,直接占满父布局。
  • 用dp代替px —— 绘制时记得把dp转成px,否则在不同屏幕上显示效果差异很大。

我的习惯:我会在自定义View里写一个工具方法dp2px(float dp),所有尺寸都用dp传入,内部转成px。这样在布局文件里看到的数值和代码里的数值是一致的,调试起来方便很多。

自定义View其实没那么可怕。你只要记住:onDraw是画布,Canvas是操作,Paint是画笔,Path是模板。把这四个东西搞明白,大部分UI需求你都能手动画出来。下次遇到什么奇奇怪怪的UI效果,别急着找第三方库,先想想能不能自己画一个。


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