-->

Swift: Presenting modally and dismissing a navigat

2019-08-15 05:33发布

问题:

I have a very common iOS app scenario:

The MainVC of the app is a UITabBarController. I set this VC as the rootViewController in the AppDelegate.swift file:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    window = UIWindow()
    window?.rootViewController = MainVC()
    window?.makeKeyAndVisible()
}

When the user logs out, I present a navigation controller with the LandingVC as the root view controller of the navigation stack.

let navController = UINavigationController(rootViewController: LandingVC)
self.present(navController, animated: true, completion: nil)

Inside LandingVC you click on Login button and the LoginVC is pushed on the top of the stack.

navigationController?.pushViewController(LoginVC(), animated: true)

When the user successfully logs in I dismiss() the navigation controller from inside the LoginVC.

self.navigationController?.dismiss(animated: true, completion: nil)

Basically, I am trying to achieve the flow below:

Everything works, but the problem is that the LoginVC is never deallocated from the memory. So if a user logs in and logs out 4 times (no reason to do that but still there is a chance), I will see LoginVC 4 times in the memory and LandingVC 0 times.

I don't understand why the LoginVC is not deallocated, but the LandingVC is.

In my mind (and correct me where I am wrong), since the navigation controller is presented and it contains 2 VCs (LandingVC and LoginVC), when I use dismiss() inside LoginVC it should dismiss the navigation controller, and therefore both contained VCs.

  • MainVC: presenting VC
  • Navigation Controller: presented VC

From Apple docs:

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.

I believe that something is going wrong when I dismiss the navigation controller within LoginVC. Is there a way to trigger dismiss() inside MainVC (presenting VC) as soon as the user logs in?

PS: using the code below will not do the trick since it pops to the root view controller of the navigation stack, which is the LandingVC; and not to MainVC.

self.navigationController?.popToRootViewController(animated: true)

Any help would be much appreciated!

====================================

My LoginVC code:

import UIKit
import Firebase
import NotificationBannerSwift

class LoginVC: UIViewController {

    // reference LoginView
    var loginView: LoginView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // dismiss keyboard when clicking outside textfields
        self.hideKeyboard()

        // setup view elements
        setupView()
        setupNavigationBar()
    }

    fileprivate func setupView() {
        let mainView = LoginView(frame: self.view.frame)
        self.loginView = mainView
        self.view.addSubview(loginView)

        // link button actions from LoginView to functionality inside LoginViewController
        self.loginView.loginAction = loginButtonClicked
        self.loginView.forgotPasswordAction = forgotPasswordButtonClicked
        self.loginView.textInputChangedAction = textInputChanged

        // pin view
        loginView.translatesAutoresizingMaskIntoConstraints = false
        loginView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        loginView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        loginView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        loginView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
}

    fileprivate func setupNavigationBar() {
        // make navigation controller transparent
        self.navigationController?.navigationBar.isTranslucent = true
        self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
        self.navigationController?.navigationBar.shadowImage = UIImage()

        // change color of text
        self.navigationController?.navigationBar.tintColor = UIColor.white

        // add title
        navigationItem.title = "Login"

        // change title font attributes
        let textAttributes = [
            NSAttributedStringKey.foregroundColor: UIColor.white,
            NSAttributedStringKey.font: UIFont.FontBook.AvertaRegular.of(size: 22)]
        self.navigationController?.navigationBar.titleTextAttributes = textAttributes
    }


    fileprivate func loginButtonClicked() {
        // some local authentication checks

        // ready to login user if credentials match the one in database
        Auth.auth().signIn(withEmail: emailValue, password: passwordValue) { (data, error) in
            // check for errors
            if let error = error {
                // display appropriate error and stop rest code execution
                self.handleFirebaseError(error, language: .English)
                return
            }


            // if no errors during sign in show MainTabBarController
            guard let mainTabBarController = UIApplication.shared.keyWindow?.rootViewController as? MainTabBarController else { return }

            mainTabBarController.setupViewControllers()

            // this is where i dismiss navigation controller and the MainVC is displayed
            self.navigationController?.dismiss(animated: true, completion: nil)
        }
    }

    fileprivate func forgotPasswordButtonClicked() {
        let forgotPasswordViewController = ForgotPasswordViewController()

        // present as modal
        self.present(forgotPasswordViewController, animated: true, completion: nil)
    }

    // tracks whether form is completed or not
    // disable registration button if textfields not filled
    fileprivate func textInputChanged() {
        // check if any of the form fields is empty
        let isFormEmpty = loginView.emailTextField.text?.count ?? 0 == 0 ||
        loginView.passwordTextField.text?.count ?? 0 == 0

        if isFormEmpty {
            loginView.loginButton.isEnabled = false
            loginView.loginButton.backgroundColor = UIColor(red: 0.80, green: 0.80, blue: 0.80, alpha: 0.6)
        } else {
            loginView.loginButton.isEnabled = true
            loginView.loginButton.backgroundColor = UIColor(red: 32/255, green: 215/255, blue: 136/255, alpha: 1.0)
        }
    }
}

回答1:

After a lot of searching, I think I found the solution:

What inspired me was all guys commenting this question and also this article:

https://medium.com/@stremsdoerfer/understanding-memory-leaks-in-closures-48207214cba

I will start with my philosophy of coding: I like to keep my code separated and clean. So, I always try to create a UIView with all the elements I want and then "link" it to the appropriate view controller. But what happens when the UIView has buttons, and the buttons need to fulfill actions? As we all know, there is no room for "logic" inside the views:

class LoginView: UIView {

    // connect to view controller
    var loginAction: (() -> Void)?
    var forgotPasswordAction: (() -> Void)?

    // some code that initializes the view, creates the UI elements and constrains them as well

    // let's see the button that will login the user if credentials are correct
    let loginButton: UIButton = {
        let button = UIButton(title: "Login", font: UIFont.FontBook.AvertaSemibold.of(size: 20), textColor: .white, cornerRadius: 5)
        button.addTarget(self, action: #selector(handleLogin), for: .touchUpInside)
        button.backgroundColor = UIColor(red: 0.80, green: 0.80, blue: 0.80, alpha: 0.6)
        return button
    }()

    // button actions
    @objc func handleLogin() {
        loginAction?()
    }

    @objc func handleForgotPassword() {
        forgotPasswordAction?()
    }
}

So as the article says:

LoginVC has a strong reference to LoginView, that has strong reference to the loginAction and forgotPasswordAction closures that just created a strong reference to self.

As you can see pretty clearly we have a cycle. Meaning that, if you exit this view controller, it can’t be removed from memory because it’s still referenced by the closure.

That could be the reason why my LoginVC was never deallocated from the memory. [SPOILER ALERT: that was the reason!]

As shown in the question, the LoginVC is responsible for executing all button actions. What I was doing before was:

class LoginVC: UIViewController {

    // reference LoginView
    var loginView: LoginView!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }

    fileprivate func setupView() {
        let mainView = LoginView(frame: self.view.frame)
        self.loginView = mainView
        self.view.addSubview(loginView)

        // link button actions from LoginView to functionality inside LoginVC

        // THIS IS WHAT IS CAUSING THE RETAIN CYCLE <--------------------
        self.loginView.loginAction = loginButtonClicked
        self.loginView.forgotPasswordAction = forgotPasswordButtonClicked

        // pin view
        .....
    }

    // our methods for executing the actions
    fileprivate func loginButtonClicked() { ... }
    fileprivate func forgotPasswordButtonClicked() { ... }

}

Now that i am aware of what is causing the retain cycle, I need to find a way and break it. As the article says:

To break a cycle, you just need to break one reference, and you will want to break the easiest one. When dealing with a closure you will always want to break the last link, which is what the closure references.

To do so, you need to specify when capturing a variable that you don’t want a strong link. The two options that you have are: weak or unowned and you declare it at the very beginning of the closure.

So what I changed in LoginVC to achieve this was:

fileprivate func setupView() {

    ...
    ...
    ...

    self.loginView.loginAction = { [unowned self] in
        self.loginButtonClicked()
    }

    self.loginView.forgotPasswordAction = { [unowned self] in
        self.forgotPasswordButtonClicked()
    }

    self.loginView.textInputChangedAction = { [unowned self] in
        self.textInputChanged()
    }
}

After this simple code change (yeah it took me 10 days to figure it out), everything is running as before, but the memory is thanking me.

Couple things to say:

  1. When I first noticed this memory issue, I blamed myself for not dismissing/popping the view controllers correctly. You can find out more in my previous StackOverflow question here: ViewControllers, memory consumption and code efficiency

  2. In the process, I learned a lot about presenting/pushing view controllers and navigation controllers; so even though I was looking in the wrong direction, I surely learned a lot.

  3. Nothing comes for free, memory leak taught me that!

Hope I could help others with the same issue as me!