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’s true when a presented modally view controller is being dismissed.
  • isMovingFromParentViewControllertrue when a view controller is being removed from a parent view controller. This includes removal from system containers such as popping a view controller from UINavigationController’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.