How do you drive an animation per frame with CADisplayLink, and why does it leak its target?

9.10.4 · L3 · Interview

Predict first

Does the owner die when the last reference goes, with a plain target and with a weak proxy?

import UIKit

final class Ticker: NSObject {
    var link: CADisplayLink?; var n = 0
    deinit { print("Ticker deinit") }
    @objc func tick(_ l: CADisplayLink) { n += 1 }
}
final class Proxy { weak var target: Ticker?; init(_ t: Ticker) { target = t }; @objc func tick(_ l: CADisplayLink) { target?.tick(l) } }

var t: Ticker? = Ticker(); weak var w = t
t!.link = CADisplayLink(target: t!, selector: #selector(Ticker.tick(_:)))
t!.link!.add(to: .main, forMode: .common)
RunLoop.main.run(until: Date().addingTimeInterval(0.5))
print("ticking:", t!.n > 0)
let link = t!.link!
t = nil
print("released, alive:", w != nil)
link.invalidate()
print("invalidated, alive:", w != nil)

var t2: Ticker? = Ticker(); weak var w2 = t2
let proxy = Proxy(t2!)
let dl = CADisplayLink(target: proxy, selector: #selector(Proxy.tick(_:))); dl.add(to: .main, forMode: .common)
t2 = nil
print("proxy, alive:", w2 != nil)
dl.invalidate()
  • ticking: true, released, alive: true, Ticker deinit, invalidated, alive: false, Ticker deinit, proxy, alive: false
  • ticking: true, released, alive: false, Ticker deinit, invalidated, alive: false, Ticker deinit, proxy, alive: false
  • ticking: false, released, alive: true, Ticker deinit, invalidated, alive: false, proxy, alive: true

Mental model. A display link is a metronome you hold by the string: unless you cut the string, the metronome keeps the room it is in from being cleaned up.

A CADisplayLink calls a method once per display refresh, so you can compute state from the elapsed time (timestamp and targetTimestamp) and update the UI for that frame. Always derive motion from time, never count frames, because the rate is 60 or 120 Hz (or lower under Low Power Mode). Create it with a target and selector, add it to a run loop in the .common mode so it keeps firing while the user scrolls (the default mode pauses it, as with a Timer), and pause it with isPaused when idle. On ProMotion hardware request a range with preferredFrameRateRange (CAFrameRateRange(minimum: 80, maximum: 120, preferred: 120)); the default range is zero, meaning the system chooses.

The classic bug: the run loop keeps the link alive and the link retains its target strongly, so an owner that holds the link and is released still lives until you call invalidate(), and its deinit never runs (checked below: the object is still alive after its last reference is dropped, and goes away only after invalidate). Invalidate in viewWillDisappear (not deinit, which never comes), or give the link a small proxy object that holds the real target weak, which fixes it even if you forget (checked too). Do heavy work off the main thread and keep the callback short; a display link runs on the main thread and shares the frame budget.

The link retains its target until invalidated; a weak proxy lets the owner go.

Common traps

  • Counting frames to move something at a fixed speed. The speed then changes with the refresh rate; use the timestamp.
  • Putting invalidate() in deinit. The link retains the owner, so deinit never runs.

They might follow up with

  • Why does a link stop while scrolling? It was added in the default mode, not .common.
  • How do you save energy? Pause it when nothing animates and request a lower frame-rate range.

Related questions

  • 9.7.1 What is a hitch versus a hang, how does ProMotion change the budget, and how do you measure and fix them?
  • 6.3.2 What are run loop modes, and why does a Timer stop firing while the user scrolls?
  • 9.2.1 Walk through how a frame gets from your code to the screen.

← What are the standard timing curves, and how do speed, timeOffset and…