Catching Leaky View Controllers Without Instruments
One of the well-known techniques for finding memory leaks caused by retain cycles is checking if all view controllers get deallocated when they’re not on screen anymore. This is a process that should be manually repeated before each release but it’s both unpleasant and error-prone. Wouldn’t it be cool if we could learn about UIViewController leaks earlier in the process, during the day-to-day development?
Turns out it’s possible thanks to two not-so-well-known UIViewController properties:
isBeingDismissed– it’struewhen a presented modally view controller is being dismissed.isMovingFromParentViewController–truewhen a view controller is being removed from a parent view controller. This includes removal from system containers such as popping a view controller fromUINavigationController’s stack.
If one of these properties is true, we know that the view controller should get deallocated promptly. We don’t know how long exactly it will take a view controller to finish all its internal state cleaning and ARC to deallocate it, though. For the simplicity’s sake, let’s assume that it will be no more than 2 seconds.
Putting everything together we get:
extension UIViewController {
public func dch_checkDeallocation(afterDelay delay: TimeInterval = 2.0) {
let rootParentViewController = dch_rootParentViewController
// We don’t check `isBeingDismissed` simply on this view controller because it’s common
// to wrap a view controller in another view controller (e.g. in UINavigationController)
// and present the wrapping view controller instead.
if isMovingFromParentViewController || rootParentViewController.isBeingDismissed {
let type = type(of: self)
let disappearanceSource: String = isMovingFromParentViewController ? "removed from its parent" : "dismissed"
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: { [weak self] in
assert(self == nil, "\(type) not deallocated after being \(disappearanceSource)")
})
}
}
private var dch_rootParentViewController: UIViewController {
var root = self
while let parent = root.parent {
root = parent
}
return root
}
}
The interesting bits happen in asyncAfter(deadline:execute:) call. First, we weakify self ([weak self]), so that it’s not retained by the delayed closure. Then, we assert that self (the UIViewController instance) is nil. It’s not nil only if we have a retain cycle keeping the view controller alive.
Now, all we need to do is call dch_checkDeallocation() from viewDidDisappear(_:) in all view controllers (except for those that we keep alive after they’re removed from their parents or dismissed):
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
dch_checkDeallocation()
}
If there’s a leak, we’ll see an assertion failure (only in -Onone builds):

At this point, we can simply open the (awesome) Memory Graph Debugger to investigate and fix the reason of a cycle.
I think it’s really cool how quickly we can learn about newly introduced retain cycles with this approach. I hope you’ll enjoy using it too! The production-ready code (with more comments and #if DEBUG check) is available on GitHub: DeallocationChecker.
