Which UIKit patterns keep a view controller alive after it is dismissed, and how do you break each one?
Predict first
Which of these ownership patterns free the controller?
import UIKit
nonisolated(unsafe) var freed = false
final class Leaky: UIViewController {
let mode: String
var onTap: (() -> Void)?
var timer: Timer?
var link: CADisplayLink?
init(_ mode: String) { self.mode = mode; super.init(nibName: nil, bundle: nil) }
required init?(coder: NSCoder) { fatalError() }
deinit { freed = true }
@objc func tick() {}
override func viewDidLoad() {
super.viewDidLoad()
switch mode {
case "closure-weak": onTap = { [weak self] in self?.title = "x" }
case "timer-block-weak": timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in self?.title = "t" }
case "displaylink-proxy":
link = CADisplayLink(target: Proxy(self), selector: #selector(Proxy.tick))
link?.add(to: .main, forMode: .common)
case "closure-strong": onTap = { self.title = "x" }
case "timer-target": timer = Timer.scheduledTimer(timeInterval: 60, target: self, selector: #selector(tick), userInfo: nil, repeats: true)
case "displaylink-direct":
link = CADisplayLink(target: self, selector: #selector(tick))
link?.add(to: .main, forMode: .common)
case "uiaction-strong":
let b = UIButton()
b.addAction(UIAction { _ in self.title = "y" }, for: .touchUpInside)
view.addSubview(b)
case "notification-block": NotificationCenter.default.addObserver(forName: .init("n"), object: nil, queue: .main) { _ in self.title = "n" }
default: break
}
}
}
final class Proxy: NSObject {
weak var target: Leaky?
init(_ t: Leaky) { target = t }
@objc func tick() { target?.tick() }
}
func check(_ mode: String) {
freed = false
var vc: Leaky? = Leaky(mode)
_ = vc!.view
vc = nil
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
print(mode, freed ? "freed" : "LEAKED")
}
for m in ["closure-weak", "timer-block-weak", "displaylink-proxy", "closure-strong", "timer-target", "displaylink-direct", "uiaction-strong", "notification-block"] {
check(m)
}
- freed for closure-weak, timer-block-weak, displaylink-proxy; LEAKED for the other five
- all freed
- all LEAKED
Mental model. A dismissed screen is a tenant who moved out but left a key with the run loop, the notification centre and its own buttons; until every key is returned, the landlord cannot demolish the room.
A controller is freed when nothing strong points at it, so every cycle has an owner you did not notice. The check released a controller and watched deinit: it was freed with a [weak self] closure, with a block-based Timer that captures weakly, and with a CADisplayLink whose target is a small weak proxy; it leaked with a stored closure that captures self strongly, with Timer.scheduledTimer(timeInterval:target:…) (the timer retains its target, and the run loop retains the timer), with a CADisplayLink targeting self directly, with a UIAction closure capturing self that is added to a button inside the controller's own view, and with a NotificationCenter block observer that captures self (the centre holds the block).
Fixes: capture [weak self] in stored closures and in handlers owned by the controller's subviews; give display links and target-based timers a weak proxy or use the block-based timer; invalidate() timers and display links in viewDidDisappear because invalidation must happen before deinit can ever run; remove block-based notification observers (keep the returned token and call removeObserver) or use the selector API, which stops delivering when the observer is freed on modern systems. Delegates are weak by convention, and a closure that does not outlive the call (UIView.animate, sort comparators) needs no weak capture. Detect leaks with a deinit log in debug, the Memory Graph debugger and Instruments. Caveat: the check ran a short run loop in a headless process; the pattern results are consistent, but verify the actual object graph in your own app with the memory graph.
Weak captures and a weak proxy free it; strong closures, timer targets, direct display-link targets, self-owned actions and notification blocks leak it.
Common traps
- Assuming UIKit closures are always weak. Only your capture list decides; a button owned by the view that captures the controller is a cycle.
- Using
deinitto invalidate a timer. The timer is what stopsdeinitfrom running.
They might follow up with
- Does a weak capture ever hurt? If the work must finish after dismissal (saving), capture what you need strongly instead of
self. - What about
[unowned self]? It crashes if the controller is gone; use it only when the lifetimes are provably tied.
Related questions
- 7.10.4 What is UIButton.Configuration, and when does its configurationUpdateHandler run?
- 6.3.1 What is a run loop, and what happens in one iteration?
- 6.3.2 What are run loop modes, and why does a Timer stop firing while the user scrolls?
← In what order do updateConstraints, the controller hooks and… How are UIMenu, UIAction and deferred elements structured, and where… →