複雑なアニメーションを実装する場合に、画面の更新(垂直同期)に合わせて、アニメーションの更新処理をしたいケースがあります。
単純にTimer
などで実装した場合、アニメーションの処理が呼ばれるタイミングが画面の更新と合わないため、次の垂直同期までに間に合わない可能性があり、
結果としてanimation hitchを生じさせる原因となります。
そのような問題を回避するために、垂直同期の直後のタイミングでの呼び出しが行われるCADisplayLink
を利用することができます。
Timerを利用した場合に困るケース
iOSに限らずですが、大体のモバイルアプリやゲームでは、ダブルバッファリングや、トリプルバッファリングを用いて描画をしています。
ダブルバッファリングでは、表裏の2枚のスクリーンがあり、今見えている画面の裏で次のフレームの準備をしつつ、垂直同期のタイミングに合わせて表裏を入れ替えています。
例えば、60Hzのリフレッシュレートのディスプレイで、最大のパフォーマンスを出す場合、
1フレームあたりおよそ16ms以内に次のフレームの描画の準備を終える必要があります。
仮に処理が重すぎて、次の垂直同期までに間に合わない場合は、以前のフレームがそのまま描画される状態なります。
(iOSの文脈では、animation hitchと呼ばれています)
Timer
の場合は特に垂直同期のタイミングとは関係なく、startしたタイミングから一定の間隔でcallbackが呼ばれます。
アニメーションの処理の開始が垂直同期のタイミングとずれることで、次の垂直同期までに動ける時間が短くなるため、後述のCADisplayLink
よりも不利になります。
(これについては、以下のWWDCの発表がわかりやすいです)
1秒に1回くらいの更新ではTimer
でもほとんどのケースで困らないのですが、
高頻度になるほど準備の時間がより短くなるので、60Hz、120Hzのリフレッシュレートに合わせようとした場合は不適切と言えます。
CADisplayLinkの使い方
リフレッシュレートと同調させるような、高頻度なアニメーションの更新をしたい場合には、Timer
よりもCADisplayLink
が適しています。
CADisplayLink
はRunLoop
に紐付けて使用します。
let displayLink = CADisplayLink(target: self, selector: #selector(update(_:))) displayLink.add(to: .main, forMode: .default) @objc func update(_ displayLink: CADisplayLink) { ... }
上記の場合は、垂直同期のすぐ後にupdate
が呼ばれ、効率よく次のフレームの準備を進めることができます。
現行のiPhoneやiPadのリフレッシュレートは60Hzや120Hzですが、 アプリの処理の重さや端末の状態、設定によって、実際のフレームレートは30fpsであったり、60fpsであったりと異なります。
そのため、Timer
で一定間隔に固定にしても、期待通りのフレームレートで動くとは限らず、過剰に呼び出されることもあり、効率の面でもあまり良い書き方とは言えません。
(特にiPhone 13 Proなどの端末では、最大120Hzの可変リフレッシュレートとなっているため、さらに配慮が必要です。)
また、CADisplayLink
の場合はpreferredFramesPerSecond
というプロパティで、callbackが呼ばれる頻度を大まかに設定することができます。
displayLink.preferredFramesPerSecond = 60
ただし、iOS15でdeprecatedになっていて、代わりにiOS15+では拡張されたpreferredFrameRateRange
が使えます。
displayLink.preferredFrameRateRange = .init(minimum: 15, maximum: 60, preferred: 60)
例えば、60fpsに設定しておくと、リフレッシュレートが120Hzの端末でも毎秒60回の更新に抑えられますが、 あくまでpreferredなので、元々30fpsしか出ていないようなケースでは、callbackが呼ばれる回数はおおよそ毎秒30回となります。
何も設定しなければ、自動で最大のfpsを取るようになるので、更新の頻度を抑える必要がない場合は設定しないでもOKです。
フレーム毎の時間の計算
先ほども書いたように、端末の種類や、設定、アプリによって実際のフレームレートは異なるため、 アニメーションがどの環境でも同じように見えるようにするためには、フレーム毎の時間を計測するのが適切です。
例えば、1回のcallbackで6度回転するようなアニメーションを実装した場合、
60fpsでは1秒に360度回転しますが、30fpsでは1秒に180度しか回転しません。
このように環境によってアニメーションの速度が変わってしまうので、意図した速さでアニメーションを動かすには、
実際に1フレームあたりどれくらいの時間がかかっているかを知ることが必要になります。
CADisplayLink
にはtimestamp
とtargetTimestamp
の2種類のプロパティがありますが、timestamp
は今準備しているフレームの開始時刻で、targetTimestamp
は今準備しているフレームが表示される予定時刻なので、アニメーションに時刻を利用する場合はtargetTimestamp
が適切になります。
Always use targetTimestamp to drive any animation, physics, or other time-related content provided in your CADisplayLink callback.
実際には、高負荷などで処理が間に合わない際に、callbackがスキップされるケースがあるので、
今のフレームのtargetTimestamp
が次のフレームのtimestamp
に一致するとは限りません。
したがって、厳密にアニメーションの計算のために時間の差分を取るには、targetTimestamp - timestamp
よりも、前回のtargetTimestamp
と比較をするのがより正確な手段になります。
var previousTimeStamp: CFTimeInterval = .zero func start() { let displayLink = CADisplayLink(target: self, selector: #selector(update(_:))) displayLink.add(to: .main, forMode: .default) previousTimeStamp = CACurrentMediaTime() } @objc func update(_ displayLink: CADisplayLink) { let delta = displayLink.targetTimestamp - previousTimeStamp previousTimeStamp = displayLink.targetTimestamp // ... }
先ほどの例のように、1秒に360度回転させるようなアニメーションをしたい場合には、以下のようになります。
@objc func update(_ displayLink: CADisplayLink) { let delta = displayLink.targetTimestamp - previousTimeStamp previousTimeStamp = displayLink.targetTimestamp angle += CGFloat.pi * 2 * delta }
CADisplayLink
はTimer
とは異なり、呼び出しの間隔が一定でない場合がありますが、
経過時間を考慮することで、環境によらず意図したスピードでアニメーションを動かすことができます。
リンク
書いている途中で試聴したのですが、以下のWWDC21の発表の後半でわかりやすくまとまっているので、そちらが参考になります。
iPhone13 Proなどの可変リフレッシュレートのディスプレイ対応についてもAppleのドキュメントがあります。