Instruments Finds the Leak Your Closures Hid
A detail screen that never deallocated, a retain cycle three closures deep, and the memory graph that exposed it.
The first symptom was a notification observer firing twice, then three times, then once more for every visit to a detail screen. The screen was being dismissed but never deallocated, and each zombie copy was still listening.
The fastest way to confirm a suspected leak is the Memory Graph Debugger. Run the app, visit the screen a few times, go back, and press the three-circle button in Xcode’s debug bar. Leaked instances show up in the navigator with a purple warning, and filtering by your class name shows exactly how many copies are alive.
Reading the graph
Selecting one of the zombie controllers showed the cycle: the controller owned a view model, the view model stored a completion closure, and that closure captured self — the controller — strongly.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final class RecipeDetailViewController: UIViewController {
private let model = RecipeDetailModel()
override func viewDidLoad() {
super.viewDidLoad()
// Leaks: model -> closure -> self -> model
model.onUpdate = { recipe in
self.render(recipe)
}
// Fixed: the closure no longer keeps the controller alive
model.onUpdate = { [weak self] recipe in
self?.render(recipe)
}
}
}
Catching it next time
Instruments’ Leaks template will flag many cycles automatically, but the cheapest guard is a line in deinit during development — print("deinit \(Self.self)") — so a missing message is noticed immediately.
Two rules cover most cases. Closures that are stored by something you own need [weak self]. Closures that run once and are released, like UIView.animate blocks, don’t. When in doubt, check the graph.
— 30 —
NavigationStack Deep Links in Forty Lines
Drive NavigationStack from an array of routes and deep links become a one-line parse.
Diffable Data Sources Are UIKit's Best-Kept Secret
Stop calling performBatchUpdates by hand. Snapshots describe state; UIKit animates the diff.