15、动画与自定义View:onDraw中实现动画、invalidate触发重绘、属性动画驱动自定义View
说实话,自定义 View 加上动画,是很多 Android 开发者又爱又恨的地方。爱的是它能做出酷炫效果,恨的是——稍不留神就卡顿、内存泄漏、或者动画根本不动。
我最早接触这个组合,是在做一个实时心率波形的需求。那时候刚入行,直接在 onDraw 里 new 了个 Paint,结果每帧都在创建对象,GC 频繁触发,界面直接卡成 PPT。嗯,从那以后我就记住了:onDraw 里不能有对象创建,不能有耗时操作。
这一章,我们就来聊聊自定义 View 动画的三种核心玩法:onDraw 中直接驱动动画、invalidate 触发重绘、以及属性动画驱动自定义 View。说白了,就是让你知道「动画到底是怎么画出来的」。
15.1 onDraw 中实现动画:最原始的方式
你想想看,动画的本质是什么?说白了就是每一帧画面都不一样。在自定义 View 里,onDraw 方法就是画每一帧的地方。如果我们能在 onDraw 被调用时,让绘制的内容随着时间变化,那不就是动画了吗?
具体怎么做?我习惯用一个成员变量来记录当前进度,然后在 onDraw 里根据这个进度去计算绘制参数。每次更新进度后,调用 invalidate() 让系统重新走一遍 onDraw。
class ProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var progress = 0f
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF6200EE")
style = Paint.Style.FILL
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 根据 progress 计算圆弧角度
val sweepAngle = progress * 360f
val rectF = RectF(50f, 50f, width - 50f, height - 50f)
canvas.drawArc(rectF, -90f, sweepAngle, true, paint)
}
fun startAnimation(duration: Long = 2000) {
val startTime = System.currentTimeMillis()
val endTime = startTime + duration
// 使用 post 不断更新进度
post(object : Runnable {
override fun run() {
val now = System.currentTimeMillis()
if (now >= endTime) {
progress = 1f
invalidate()
return
}
progress = (now - startTime).toFloat() / duration
invalidate()
// 继续下一帧
post(this)
}
})
}
}
15.2 invalidate 触发重绘:动画的「油门」
invalidate 这个方法,你可以把它理解成「告诉系统:我这 View 脏了,需要重画」。它不会立即执行 onDraw,而是向系统消息队列发一个请求,等下一帧刷新时再调用。
这里有个细节很多人不知道:invalidate 只能在 UI 线程调用。如果你在子线程里调了,会直接抛异常。那子线程想刷新 UI 怎么办?用 postInvalidate(),它内部会通过 Handler 切到主线程。
我见过不少新手在 onDraw 里写循环调用 invalidate,结果导致界面卡死。为什么?因为 onDraw 本身就在绘制过程中,你再调 invalidate,相当于「画到一半又请求重画」,系统会陷入死循环。正确的做法是:在动画循环中调用 invalidate,而不是在 onDraw 内部。
核心原则:
- invalidate() —— UI 线程调用,请求重绘
- postInvalidate() —— 非 UI 线程调用,安全重绘
- 不要在 onDraw 内部调用 invalidate()
- 频繁调用 invalidate 会导致性能下降,建议控制在 60fps 以内
15.3 属性动画驱动自定义 View:优雅且高效
说实话,前面两种方式虽然能实现动画,但代码写起来不够优雅。尤其是你要做「加速」、「减速」、「回弹」这类效果时,自己算插值器太痛苦了。
属性动画(ValueAnimator / ObjectAnimator)就是来解决这个问题的。它帮你管理动画的时序、插值器、重复模式,你只需要告诉它「我要改变哪个值,从多少到多少」。
我个人习惯在自定义 View 里配合 AnimatorUpdateListener 来使用。举个例子,做一个圆形放大缩小的呼吸动画:
class BreathingView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var radius = 100f // 当前半径,由动画驱动
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF6200EE")
style = Paint.Style.FILL
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val cx = width / 2f
val cy = height / 2f
canvas.drawCircle(cx, cy, radius, paint)
}
fun startBreathing() {
val animator = ValueAnimator.ofFloat(80f, 150f)
animator.duration = 1500
animator.repeatMode = ValueAnimator.REVERSE
animator.repeatCount = ValueAnimator.INFINITE
animator.interpolator = AccelerateDecelerateInterpolator()
animator.addUpdateListener { animation ->
radius = animation.animatedValue as Float
invalidate() // 关键:更新后触发重绘
}
animator.start()
}
}
你看,代码清晰多了。属性动画帮你处理了时间流逝、插值计算,你只需要在监听器里更新变量并调用 invalidate。而且它天然支持 repeatCount、repeatMode、各种 Interpolator,想实现弹跳、回弹效果,一行代码的事。
💡 我的经验: 如果你需要同时驱动多个属性(比如位置 + 大小 + 透明度),可以用 AnimatorSet 把它们组合起来。我曾经做过一个点赞动画,同时驱动了缩放、旋转和透明度变化,用 AnimatorSet 的 playTogether 非常方便。
15.4 三种方式的对比与选型
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| onDraw 直接驱动 | 完全可控,无额外依赖 | 代码繁琐,时间不准,容易出 bug | 极简单的循环动画,临时调试 |
| invalidate + 定时器 | 理解简单,适合入门 | 性能一般,帧率不稳定 | 学习阶段,或对精度要求不高的场景 |
| 属性动画驱动 | 功能强大,性能好,代码优雅 | 需要理解 Animator 机制 | 生产环境,复杂动画,推荐首选 |
我个人建议:能用属性动画就别手写循环。除非你是在做一个非常特殊的自定义效果,属性动画无法满足你的需求,那时候再考虑 onDraw 直接驱动。但即便如此,也建议配合 Choreographer 来获取帧回调,而不是自己用 postDelayed 算时间。
15.5 避坑指南
最后分享几个我踩过的坑:
- 我曾经在 onDraw 里 new 了 Path 对象,结果每帧都在创建对象,GC 频繁触发,动画卡成狗。后来改成成员变量复用,问题解决。
- 我曾经忘记在 Activity 销毁时停止动画,导致 View 已经 detached 了还在不断 invalidate,内存泄漏。解决方案是在
onDetachedFromWindow()中取消所有动画。 - 我曾经在子线程直接调了 invalidate,结果 crash。记住:UI 线程用 invalidate,子线程用 postInvalidate。
- 属性动画的监听器里别忘了调 invalidate。我见过有人写了 AnimatorUpdateListener 但没调 invalidate,结果动画值在变,界面纹丝不动。嗯,排查了半天才发现。
其实这些坑都不难避免,只要你记住一个核心:动画的本质是「状态变化 + 重绘触发」。状态变了,一定要让系统知道它需要重画。invalidate 就是那个「通知」。