15、动画与自定义View:onDraw中实现动画、invalidate触发重绘、属性动画驱动自定义View

说实话,自定义 View 加上动画,是很多 Android 开发者又爱又恨的地方。爱的是它能做出酷炫效果,恨的是——稍不留神就卡顿、内存泄漏、或者动画根本不动。

我最早接触这个组合,是在做一个实时心率波形的需求。那时候刚入行,直接在 onDraw 里 new 了个 Paint,结果每帧都在创建对象,GC 频繁触发,界面直接卡成 PPT。嗯,从那以后我就记住了:onDraw 里不能有对象创建,不能有耗时操作

这一章,我们就来聊聊自定义 View 动画的三种核心玩法:onDraw 中直接驱动动画invalidate 触发重绘、以及属性动画驱动自定义 View。说白了,就是让你知道「动画到底是怎么画出来的」。

自定义 View 动画核心路径 onDraw 直接驱动 每帧更新变量 + invalidate() invalidate 触发重绘 请求系统重新调用 onDraw 属性动画驱动 ValueAnimator + 自定义属性 适用场景 简单循环动画 进度条、加载指示器 注意事项 不要频繁调用 invalidate 避免在 onDraw 中创建对象 最佳实践 属性动画 + 自定义 View 配合 AnimatorUpdateListener 核心:invalidate() 是连接动画与绘制的桥梁 三种方式本质都是「改变状态 → 触发重绘 → 重新绘制」

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)
            }
        })
    }
}
⚠️ 注意: 上面这种方式虽然能跑,但有个问题——它没有考虑系统帧率。如果界面卡顿,动画时间会不准。我在早期项目里吃过这个亏,后来改用 Choreographer 或者直接上属性动画才解决。

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。而且它天然支持 repeatCountrepeatMode、各种 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 就是那个「通知」。


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