9、自定义View实战(一):绘制圆形进度条,掌握onDraw与invalidate的配合。

好,我们直接进入正题。今天要搞一个非常经典的自定义View——圆形进度条。你别看它长得简单,里面藏着的门道可不少。说白了,它就是onDrawinvalidate这对黄金搭档的实战演练。

我刚开始学自定义View那会儿,也画过一个圆形进度条。当时觉得,不就是画个弧嘛,有什么难的?结果一跑起来,CPU直接飙到50%,界面卡成PPT。嗯,后来我才明白,invalidate调用不当,就是性能杀手。

9.1 圆形进度条的核心原理

先理清思路。圆形进度条,本质上就是两个东西:

  • 一个静态的圆环:作为背景,告诉用户“这里有个圈”。
  • 一个动态的圆弧:随着进度变化,从0°画到360°。

你想想看,这个动态的圆弧怎么动起来?靠的就是invalidate。每次进度更新,我们调用invalidate(),系统就会重新调用onDraw,把新的圆弧画上去。一帧一帧地刷新,就形成了动画效果。

核心公式:

进度更新 → invalidate() → onDraw() → 重新绘制 → 进度更新 → ...

这就是一个闭环。理解了这个,你就掌握了自定义View动画的精髓。

我个人习惯把这种模式叫做“数据驱动绘制”。数据变了,View就重绘。简单、直接、有效。

9.2 实战:手写一个圆形进度条

好,我们直接上代码。我会一步步拆解,你跟着来就行。

9.2.1 定义属性

首先,我们需要几个关键属性:圆环颜色、进度条颜色、圆环宽度、当前进度。我建议把这些定义成自定义属性,方便在XML里配置。

// attrs.xml
<declare-styleable name="CircleProgressView">
    <attr name="circleColor" format="color" />
    <attr name="progressColor" format="color" />
    <attr name="circleWidth" format="dimension" />
    <attr name="progress" format="integer" />
</declare-styleable>

9.2.2 初始化画笔

在View的构造方法里,我们初始化两支画笔:一支画背景圆环,一支画进度圆弧。

public class CircleProgressView extends View {
    private Paint circlePaint;   // 画背景圆环
    private Paint progressPaint; // 画进度圆弧
    private int progress = 0;    // 当前进度 0-100

    public CircleProgressView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initPaints();
    }

    private void initPaints() {
        circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        circlePaint.setStyle(Paint.Style.STROKE);
        circlePaint.setStrokeWidth(circleWidth);
        circlePaint.setColor(circleColor);

        progressPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        progressPaint.setStyle(Paint.Style.STROKE);
        progressPaint.setStrokeWidth(circleWidth);
        progressPaint.setColor(progressColor);
        // 关键:让圆弧两端是圆角,看起来更舒服
        progressPaint.setStrokeCap(Paint.Cap.ROUND);
    }
}

这里有个小细节:Paint.Cap.ROUND。我之前做项目时,圆弧两端是平的,看起来像被切了一刀,特别丑。加上圆角后,视觉效果瞬间提升一个档次。

9.2.3 核心:onDraw 方法

这是重头戏。我们在这个方法里完成所有绘制工作。

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

    // 1. 计算圆心和半径
    float centerX = getWidth() / 2f;
    float centerY = getHeight() / 2f;
    float radius = Math.min(centerX, centerY) - circleWidth / 2f;

    // 2. 绘制背景圆环(完整的圆)
    canvas.drawCircle(centerX, centerY, radius, circlePaint);

    // 3. 绘制进度圆弧
    // 注意:Android的Canvas画弧,起始角度是3点钟方向
    // 我们想要从12点钟方向开始,所以起始角度设为 -90°
    float startAngle = -90f;
    // 进度对应的角度:进度100%时,扫过360°
    float sweepAngle = (progress / 100f) * 360f;
    // 画弧
    RectF oval = new RectF(
            centerX - radius,
            centerY - radius,
            centerX + radius,
            centerY + radius
    );
    canvas.drawArc(oval, startAngle, sweepAngle, false, progressPaint);
}

嗯,这里要注意一点。很多人第一次画弧都会栽跟头:起始角度。Android的drawArc默认从3点钟方向(即右侧)开始。如果你想要从12点钟方向开始,就得把起始角度设为-90°。我刚开始也搞错过,画出来的进度条从右边开始转,怎么看怎么别扭。

9.2.4 进度更新与invalidate

现在,我们需要一个方法来更新进度,并触发重绘。

public void setProgress(int progress) {
    // 边界保护
    if (progress < 0) progress = 0;
    if (progress > 100) progress = 100;

    this.progress = progress;
    // 关键:通知View重绘
    invalidate();
}

你看,就这么简单。每次调用setProgress,我们更新数据,然后调用invalidate()。系统会在下一个绘制周期调用onDraw,把新的圆弧画出来。

小技巧:如果你想要平滑动画,可以配合ValueAnimator使用。比如:

ValueAnimator animator = ValueAnimator.ofInt(0, 100);
animator.addUpdateListener(animation -> {
    setProgress((int) animation.getAnimatedValue());
});
animator.setDuration(2000);
animator.start();

这样进度条就会从0%平滑过渡到100%,效果非常棒。

9.3 避坑指南:invalidate 的正确姿势

我曾经在项目里犯过一个低级错误:在onDraw方法里直接调用invalidate()。结果呢?View一直在重绘,CPU占用率居高不下,界面卡成幻灯片。

为什么会这样?因为invalidate()会触发onDraw,而onDraw里又调用了invalidate(),这就形成了一个死循环。记住:不要在onDraw里调用invalidate

正确的做法是:只在数据变化时调用invalidate()。比如上面的setProgress方法,只有进度真的变了,才触发重绘。

警告:

  • 不要在onDraw中调用invalidate(),会导致无限循环。
  • 不要在onDraw中创建对象(如new Paint()new RectF()),会触发频繁GC,导致掉帧。
  • 如果View的大小不变,可以考虑在onSizeChanged中预计算半径、矩形等,避免在onDraw中重复计算。

9.4 知识体系:onDraw 与 invalidate 的协作流程

为了让你更直观地理解,我画了一张流程图。这张图展示了从进度更新到界面刷新的完整链路。

onDraw 与 invalidate 协作流程 setProgress(progress) this.progress = progress invalidate() onDraw(Canvas) 关键说明 1. 数据变化是触发重绘的唯一原因 2. invalidate() 不会立即执行 onDraw 它只是标记View需要重绘 3. 系统在下一个VSync信号到来时 才会调用 onDraw 4. 不要在 onDraw 中调用 invalidate 5. 频繁调用 invalidate 会导致卡顿 建议控制刷新频率

这张图很清楚地展示了整个流程。你注意看右侧的说明,特别是第2点和第4点。理解了这些,你就不会犯我之前犯过的错误了。

9.5 总结与个人经验

好了,我们来总结一下今天的内容。

圆形进度条的核心,就是数据驱动绘制。你只需要关注两件事:

  1. 在onDraw里画什么:背景圆环 + 进度圆弧。
  2. 什么时候触发重绘:进度变化时调用invalidate()。

我个人觉得,自定义View最难的不是API怎么用,而是什么时候该画,什么时候不该画。很多初学者喜欢在onDraw里做各种计算、各种判断,结果把性能搞得很差。记住:onDraw只负责画,不负责算。计算的事情,交给setProgress或者onSizeChanged去做。

最后,分享一个我自己的习惯。我在写自定义View时,一定会先画一张流程图,理清楚数据流向和绘制逻辑。就像上面那张SVG图一样。画完之后,代码怎么写,心里就有数了。你也试试看?

核心要点回顾:

  • 使用Paint.Style.STROKE画空心圆环
  • 使用canvas.drawArc()画进度圆弧
  • 起始角度设为-90°,从12点钟方向开始
  • 进度更新后调用invalidate()触发重绘
  • 不要在onDraw中调用invalidate()
  • 不要在onDraw中创建对象

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