14、动画与线程:主线程动画更新机制、Handler与Choreographer、帧率控制与丢帧处理

动画跑起来,说白了就是画面一帧一帧地变。但这一帧一帧怎么变、谁来驱动、卡了怎么办——这些才是真正决定动画质量的关键。今天我们就来聊聊动画背后的线程机制。

主线程动画更新机制

Android的UI更新,只能在主线程做。这个限制很多人知道,但为什么?

因为Android的UI渲染不是线程安全的。如果多个线程同时改同一个TextView的文字,那画面就乱套了。所以Google干脆规定:所有UI操作,包括动画的每一帧更新,都得在主线程完成。

我在项目中遇到过一个问题:一个列表动画在低端机上掉帧严重。排查后发现,是某个后台线程频繁往主线程post任务,把主线程的消息队列堵死了。动画的绘制指令排了半天队才轮到,不卡才怪。

核心要点:动画的每一帧更新,最终都会通过主线程的Looper派发。主线程一旦繁忙,动画就会掉帧。

那主线程是怎么知道「该更新下一帧了」?这就引出了两个关键角色:Handler和Choreographer。

Handler与Choreographer

Handler大家都很熟,它负责往主线程的消息队列里塞任务。但动画更新如果用Handler的postDelayed来驱动,会有一个问题:

你没法精确控制时间。postDelayed只是说「至少延迟xx毫秒」,但具体什么时候执行,取决于消息队列前面排了多少任务。如果前面有个耗时操作,你的动画帧就得等着。

Choreographer就是来解决这个问题的。

Choreographer直译过来是「编舞者」。它负责协调动画、输入、绘制这三件事,让它们在每一帧里按顺序执行。

它的工作流程是这样的:

  1. Choreographer监听VSync信号(垂直同步信号,硬件每16.6ms发一次)
  2. 收到信号后,它回调注册的动画回调(比如View的invalidate、属性动画的更新)
  3. 然后执行布局、绘制,最后交给SurfaceFlinger合成显示

说白了,Choreographer让动画更新和屏幕刷新保持同步。你不需要自己算时间,它帮你卡好节奏。

我的习惯:在自定义View做动画时,我一般用Choreographer.postFrameCallback,而不是Handler.postDelayed。前者更精准,能减少掉帧。

来看一个简单的例子:

class CustomAnimView(context: Context) : View(context) {
    private var startTime = 0L
    private val frameCallback = object : Choreographer.FrameCallback {
        override fun doFrame(frameTimeNanos: Long) {
            if (startTime == 0L) startTime = frameTimeNanos
            val elapsed = (frameTimeNanos - startTime) / 1_000_000f
            // 根据时间更新动画状态
            invalidate()
            // 继续注册下一帧
            Choreographer.getInstance().postFrameCallback(this)
        }
    }

    fun start() {
        Choreographer.getInstance().postFrameCallback(frameCallback)
    }
}

注意看,doFrame里传进来的frameTimeNanos,是VSync信号到达的时间戳,不是当前时间。这个时间戳非常关键——它代表「这一帧应该从什么时候开始算」。

帧率控制与丢帧处理

帧率控制,说白了就是让动画每秒更新多少次。Android的默认刷新率是60fps,也就是每帧16.6ms。现在很多手机支持90Hz、120Hz,对应的时间更短。

但问题是,你的动画逻辑能在16.6ms内执行完吗?

如果执行不完,就会丢帧。丢帧的表现就是画面卡顿、跳跃。

丢帧的原因通常有这几类:

  • 主线程耗时操作:比如在onDraw里做Bitmap解码、文件读写
  • 布局层级过深:measure/layout耗时太长
  • 过度绘制:同一像素被多次绘制
  • GC频繁触发:内存抖动导致主线程暂停

我曾经踩过一个坑:在属性动画的更新回调里,每次都对一个Bitmap做旋转操作。结果每帧都要创建新的Bitmap对象,GC频繁触发,动画卡成PPT。后来改用Matrix做变换,问题解决。

那怎么检测丢帧?

Choreographer的doFrame方法里,可以通过计算时间差来判断:

override fun doFrame(frameTimeNanos: Long) {
    val now = System.nanoTime()
    val diffMs = (now - frameTimeNanos) / 1_000_000f
    if (diffMs > 16.6f) {
        // 丢帧了
        Log.w("Anim", "丢帧: ${diffMs - 16.6f}ms")
    }
    // 继续动画...
}

当然,生产环境不建议这么写。更专业的做法是用Android自带的工具:

  • Profile GPU Rendering:开发者选项里的「GPU渲染模式分析」,能看到每帧的耗时柱状图
  • Systrace:可以精确看到每个线程在做什么,哪个阶段耗时最长
  • Choreographer.onFrameCallback:自己埋点统计

帧率控制还有一个常见场景:动画的帧率不需要和屏幕刷新率一致。比如一个进度条动画,每秒更新10次就够了,没必要跑60fps,浪费CPU。

这时候可以自己控制帧率:

class ThrottledAnimView(context: Context) : View(context) {
    private var lastFrameTime = 0L
    private val minInterval = 100L // 100ms,相当于10fps

    private val callback = object : Choreographer.FrameCallback {
        override fun doFrame(frameTimeNanos: Long) {
            val now = System.currentTimeMillis()
            if (now - lastFrameTime >= minInterval) {
                lastFrameTime = now
                invalidate()
            }
            Choreographer.getInstance().postFrameCallback(this)
        }
    }
}

这样既保持了动画的流畅感,又减少了不必要的绘制开销。

知识体系总览

下面这张图把本章的核心逻辑串起来了:

动画与线程:核心知识体系 硬件 VSync 信号 每16.6ms(60Hz)触发一次 Choreographer(编舞者) 协调动画回调、输入事件、绘制任务 动画回调 View.invalidate / 属性动画 输入事件 触摸、按键等 绘制任务 onDraw / 布局 主线程(UI线程) Looper + Handler + MessageQueue 丢帧 ❌ 耗时 > 16.6ms 帧率控制:通过 Choreographer 回调 + 时间差计算,实现精准动画更新

从这张图可以看得很清楚:VSync信号是起点,Choreographer是调度中心,主线程是最终执行者。任何一个环节出问题,都会导致丢帧。

我的建议:做动画优化时,先看Choreographer的回调是否准时。如果回调本身延迟了,那问题大概率在主线程的消息队列上。如果回调准时但绘制慢,那就得优化onDraw里的逻辑。

最后说一个很多人忽略的点:动画的帧率不等于屏幕刷新率。你完全可以让动画跑在30fps,只要视觉上平滑就行。省下来的CPU时间,可以做其他事情。这个取舍,得根据具体场景来定。

嗯,关于动画与线程的内容,今天就聊到这里。记住一句话:动画卡顿,十有八九是主线程堵了。找到堵点,问题就解决了一半。

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