最近在手表上调一个界面的动画,遇到一个有点反常的问题:动画大约每秒顿一下,非常有规律。
可是看数据,每一帧从构建到画完只要几毫秒,远低于 16ms 的刷新间隔,帧率也是满的。按常理,这样的动画不该卡。
这篇记录一下这个问题的原因,以及最后是怎么想通的。
出帧是怎么驱动的
先说一下我们 UI 库出帧的方式。
出帧完全是按需的:谁调用了 update(),而 GPU 刚好空闲,就立刻构建一帧。CPU 遍历当前的控件树,生成 GPU 任务链交给 GPU 去画。
动画的节奏则来自各自的软件定时器。正在显示的窗口会每 16ms 推进一次动画并调用 update();多个还没来得及处理的 update() 会被合并成一次。
也就是说,什么时候出一帧,是由一个软件定时器决定的,和屏幕什么时候刷新没有关系。
问题出在两个时钟不同步
帧生成耗时比刷新间隔短,是流畅的前提,但还不够。
屏幕有自己的刷新节奏,每刷新一帧会发出一个 TE 信号。如果出帧的节奏由一个和屏幕无关的定时器决定,只要两者的间隔不完全相等,误差就会一帧一帧累积下来,周期性地出问题。
我实测了一下:定时器约 16.0ms 触发一次,屏幕约 16.26ms 刷新一帧,每帧差 0.26ms。看起来很小,但会一直累积:
| |
16.26 / 0.26 ≈ 62 帧,大约一秒。正好对上“每秒顿一下”。
定时器每触发一次,动画就往前走一步。一个刷新周期里触发两次,屏幕上这一帧就走了两步,看起来就是动画跳了一下。实测不处理时,29 秒里跳了 28 次。
反过来,如果定时器比屏幕慢,误差累积到一个周期时,屏幕会连续两次显示同一个画面,看起来就是动画停了一下。
两种情况本质上是同一件事:动画按定时器的节奏走,画面按屏幕的节奏显示,两个节奏对不上。 这和帧生成耗时长短无关,帧画得再快也躲不掉。
方案 A:发现撞上了,就补救
第一个想到的办法是在动画里做补救:
- 屏幕准备好接收了才出下一帧。 推进动画前先看上一帧画面有没有真正交出去。还没交出去,这一拍就跳过,不让新的一步把还没显示的那一步覆盖掉。
- 画面按帧推进,而不是按定时器触发的真实时刻推进。 每次推进固定走一个屏幕刷新周期的量。否则跳过一拍之后,下一拍会按两拍的时间补上,屏幕上照样是一下走了两步。
这么做之后,肉眼确实看不出卡顿了。
但它只是缓解,没有解决根源。触发动画的仍然是那个 16ms 的定时器,两个时钟照样在慢慢错开,只是每次撞上的时候被我们识别出来并绕过去了。为了“按帧推进”,还得估算屏幕的刷新周期,再不断用真实时间去校正,免得越走越偏。
而且这套逻辑要写在每一个动画里。
方案 B:让动画时钟跟着屏幕走
回头再看问题的本质:只要动画的节奏来自一个独立的定时器,而不是跟着屏幕刷新走,就会有这个问题。
单纯调整定时器的时间也不行。定时器只能按毫秒设置,而屏幕周期是 16.26ms 这种带小数的值;不同的屏幕刷新间隔不一样,同一块屏幕还会随温度变化。
所以彻底的办法是让出帧的节奏跟着屏幕走:不再由 16ms 定时器驱动动画,改成屏幕每刷新一帧,就推进一次动画、出一帧。
TE 信号就是现成的时钟:亮屏时每一帧都会触发一次中断。想法说起来很简单,把原来由 16ms 定时器触发的帧生成,改成由 TE 触发就行。
改完之后,效果提升很明显,那个每秒一次的顿挫彻底消失了。和方案 A 比,它不需要识别、跳过和估算周期,因为两个时钟从一开始就是同一个,自然也就没有“错开”这回事。
小结
流畅的动画需要两个条件:
- 每一帧都能在一个刷新周期内画完;
- 动画的节奏和屏幕的刷新节奏是同一个时钟。