14、动画与线程:主线程动画更新机制、Handler与Choreographer、帧率控制与丢帧处理
动画跑起来,说白了就是画面一帧一帧地变。但这一帧一帧怎么变、谁来驱动、卡了怎么办——这些才是真正决定动画质量的关键。今天我们就来聊聊动画背后的线程机制。
主线程动画更新机制
Android的UI更新,只能在主线程做。这个限制很多人知道,但为什么?
因为Android的UI渲染不是线程安全的。如果多个线程同时改同一个TextView的文字,那画面就乱套了。所以Google干脆规定:所有UI操作,包括动画的每一帧更新,都得在主线程完成。
我在项目中遇到过一个问题:一个列表动画在低端机上掉帧严重。排查后发现,是某个后台线程频繁往主线程post任务,把主线程的消息队列堵死了。动画的绘制指令排了半天队才轮到,不卡才怪。
核心要点:动画的每一帧更新,最终都会通过主线程的Looper派发。主线程一旦繁忙,动画就会掉帧。
那主线程是怎么知道「该更新下一帧了」?这就引出了两个关键角色:Handler和Choreographer。
Handler与Choreographer
Handler大家都很熟,它负责往主线程的消息队列里塞任务。但动画更新如果用Handler的postDelayed来驱动,会有一个问题:
你没法精确控制时间。postDelayed只是说「至少延迟xx毫秒」,但具体什么时候执行,取决于消息队列前面排了多少任务。如果前面有个耗时操作,你的动画帧就得等着。
Choreographer就是来解决这个问题的。
Choreographer直译过来是「编舞者」。它负责协调动画、输入、绘制这三件事,让它们在每一帧里按顺序执行。
它的工作流程是这样的:
- Choreographer监听VSync信号(垂直同步信号,硬件每16.6ms发一次)
- 收到信号后,它回调注册的动画回调(比如View的invalidate、属性动画的更新)
- 然后执行布局、绘制,最后交给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信号是起点,Choreographer是调度中心,主线程是最终执行者。任何一个环节出问题,都会导致丢帧。
我的建议:做动画优化时,先看Choreographer的回调是否准时。如果回调本身延迟了,那问题大概率在主线程的消息队列上。如果回调准时但绘制慢,那就得优化onDraw里的逻辑。
最后说一个很多人忽略的点:动画的帧率不等于屏幕刷新率。你完全可以让动画跑在30fps,只要视觉上平滑就行。省下来的CPU时间,可以做其他事情。这个取舍,得根据具体场景来定。
嗯,关于动画与线程的内容,今天就聊到这里。记住一句话:动画卡顿,十有八九是主线程堵了。找到堵点,问题就解决了一半。