9、自定义View实战(一):绘制圆形进度条,掌握onDraw与invalidate的配合。
好,我们直接进入正题。今天要搞一个非常经典的自定义View——圆形进度条。你别看它长得简单,里面藏着的门道可不少。说白了,它就是onDraw和invalidate这对黄金搭档的实战演练。
我刚开始学自定义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 的协作流程
为了让你更直观地理解,我画了一张流程图。这张图展示了从进度更新到界面刷新的完整链路。
这张图很清楚地展示了整个流程。你注意看右侧的说明,特别是第2点和第4点。理解了这些,你就不会犯我之前犯过的错误了。
9.5 总结与个人经验
好了,我们来总结一下今天的内容。
圆形进度条的核心,就是数据驱动绘制。你只需要关注两件事:
- 在onDraw里画什么:背景圆环 + 进度圆弧。
- 什么时候触发重绘:进度变化时调用invalidate()。
我个人觉得,自定义View最难的不是API怎么用,而是什么时候该画,什么时候不该画。很多初学者喜欢在onDraw里做各种计算、各种判断,结果把性能搞得很差。记住:onDraw只负责画,不负责算。计算的事情,交给setProgress或者onSizeChanged去做。
最后,分享一个我自己的习惯。我在写自定义View时,一定会先画一张流程图,理清楚数据流向和绘制逻辑。就像上面那张SVG图一样。画完之后,代码怎么写,心里就有数了。你也试试看?
核心要点回顾:
- 使用
Paint.Style.STROKE画空心圆环 - 使用
canvas.drawArc()画进度圆弧 - 起始角度设为-90°,从12点钟方向开始
- 进度更新后调用
invalidate()触发重绘 - 不要在
onDraw中调用invalidate() - 不要在
onDraw中创建对象
公众号:蓝海资料掘金营,微信deep3321