#apps
Feb 5, 2019

Preventing memory leak when not dismissing modally-presented UIViewController in iOS

Ondřej Macoszek
Ondřej Macoszek
iOSQAUI testingswift
Preventing memory leak when not dismissing modally-presented UIViewController in iOS

For the last year or so, we’ve been doing UI testing for the iOS Showmax app using EarlGrey. It’s a good tool and works well, especially with regards to the visual quality of screens.

Flaky test

At a certain point, when we had a critical mass of tests running, we started seeing tests randomly failing with the error EarlGrey.MultipleElementsFoundException. It was strange. When we ran a single test, everything was fine. The failure came when we ran all of the tests together.

In the failing UI test, we were testing whether a certain button was shown (or not) in the modally-presented UIViewController. We did this using the EarlGrey method selectElementWithMatcher to find a button with the given accessiblityIdentifier. In this case, EarlGrey found multiple buttons with the same accessibility identifier and thus returned the error EarlGrey.MultipleElementsFoundException. After several hours of debugging, we found that two of our tests were presenting modally same UIViewController. Surprisingly, despite our destroying of the tested controller at the end of first test, the modal view remained in the UIWindow. Thus, when the second test started, there were indeed multiple elements (buttons with the same accessiblityIdentifier) present inside the UIWindow. How is this possible?

Happy path

In most cases, modal controllers are dismissed by the user in production as this ensures that related modal views are removed from the UIWindow and then released.

Leaking path

This is what happens when the presenting controller is abandoned without dismissing its presented controller. A simple example of this is in UI tests where we want to test a single controller separately from the others. Steps to reproduce:

  1. Have controller A
  2. Modally present controller M from A
  3. Abandon controller A without dismissing M

Here’s an example of how to reproduce leak I’m talking about:

import UIKit

// MARK: - App Delegate

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)
        window?.makeKeyAndVisible()
        makeNewRootViewController(every: 3)
        return true
    }
}

// MARK: - View Controller

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        DispatchQueue.main.asyncAfter(deadline: .now()+0.5) { [weak self] in
            self?.present(UIViewController(), animated: true, completion: nil)
        }
    }
}

// MARK: - Helpers

func makeNewRootViewController(every seconds: Double) {
    UIApplication.shared.keyWindow?.rootViewController = ViewController()
    printCountOfTransitionViews()
    DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
        makeNewRootViewController(every: seconds)
    }
}

func printCountOfTransitionViews() {
    let count = UIApplication.shared.keyWindow?.subviews

                .filter { String(describing: type(of: $0)) == "UITransitionView" }.count ?? 0
    print("Found \(count)x UITransitionView in UIApplication.shared.keyWindow?.subviews")
}

In most default cases, one would probably expect that setting a new controller into UIWindow.rootViewController would ensure that previous controller and all of its related views should be de-allocated, as this variable was probably the only strong reference to a previous controller. But setting a new rootViewController will not cause the previous controller to get deallocated if it’s presenting modally some controller at that moment.

What’s the Cause?

The leaking modal controller was caused by its having a strong reference from UIWindow.keyWindow to UITransitionView.__presentationControllerToNotifyOnLayoutSubviews and further to the current modally-presented view controller.

Post image

The same problem was reported to Apple in 2015, but there is as yet no resolution. So, here’s a question: Is this the expected behavior of modal controllers?

What the Documentation Says

There is no explicit mention about who is responsible, or what should be done, in the event that you want to destroy a view controller that is currently modally presenting another view controller. The following excerpts sort of imply that we are required to call dismiss in the code if the user does not explicitly dismiss the modal controller.

The presenting view controller is responsible for dismissing the view controller it presented. If you call this method on the presented view controller itself, UIKit asks the presenting view controller to handle the dismissal.

– dismiss(animated:completion:) - UIViewController | Apple Developer Documentation

Dismissing a view controller removes it from the view controller hierarchy and removes its view from the screen.
– View Controller Programming Guide for iOS: Presenting a View Controller

How to Solve It

Always, always, always clean up (dismiss) what you’ve presented.

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    dismiss(animated: false, completion: nil)
}

Or, throw away all references on the current UIWindow key and create new one.

NOTE: Surprisingly, calling dismiss in deinit will not work because this line won’t get called until the view controller is deallocated; and, this won’t happen until the controller is dismissed because of strong reference from UITransitionView towards presenting the view controller.

Conclusion

Remember that, when not dismissed, modal controllers can cause the unnecessary leaking of memory on stuff that we will not use anymore, and potentially unpredictable results when testing. So, it’s always better to dismiss them when abandoning their parent controllers.

Share article via: