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’strue
when a presented modally view controller is being dismissed.isMovingFromParentViewController
–true
when 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.