swiftmemory-leakspresentmodalviewcontrollerownership-semanticsunowned-references

Swift unowned self leaking when 'owned' by a view being presented


I am experiencing a leak with unowned self under conditions where, to the best of my knowledge, there shouldn't be a leak. Let me show an example, it is a little contrived, so bear with me, I've tried to make the simplest case I could.

Suppose I have a simple view controller that executes a closure on viewDidLoad:

class ViewController2: UIViewController {

    var onDidLoad: (() -> Void)?

    override func viewDidLoad() {
        super.viewDidLoad()
        onDidLoad?()
    }
}

and a class, ViewHandler, that owns an instance of this view controller and injects a call to a notify function into its closure, using an unowned reference:

class ViewHandler {

    private let viewController2 = ViewController2()

    func getViewController() -> ViewController2 {
        viewController2.onDidLoad = { [unowned self] in
            self.notify()
        }
        return viewController2
    }

    func notify() {
        print("My viewcontroller has loaded its view!")
    }
}

Then, when its view controller is presented by another view controller, the ViewHandler is leaking when nilled out:

class ViewController: UIViewController {

    private var viewHandler: ViewHandler?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        viewHandler = ViewHandler()
        self.present(viewHandler!.getViewController(), animated: true, completion: nil)

        viewHandler = nil // ViewHandler is leaking here.
    }
}

I know the example may seem a little contrived, but as far as I know there shouldn't be a leak. Let my try and break it down:

Before presenting ViewHandler.ViewController2, ownership should look like this:

ViewController -> ViewHandler -> ViewController2 -|
                       ^                          |
                       |_ _ _ _ unowned _ _ _ _ _ |

After presenting ViewHandler.ViewController2, ownership should look like this:

         _______________________________
        |                               v
ViewController -> ViewHandler -> ViewController2 -|
                       ^                          |
                       |_ _ _ _ unowned _ _ _ _ _ |

After nilling out ViewHandler, ownership should look like this:

         _______________________________
        |                               v
ViewController    ViewHandler -> ViewController2 -|
                       ^                          |
                       |_ _ _ _ unowned _ _ _ _ _ |

Nothing is owning ViewHandler and it should be released. However this is not the case and ViewHandler is leaking.

If I change the reference in the capture list of the closure injected into onDidLoad to weak, there is no leak and ViewHandler is released as expected:

func getViewController() -> ViewController2 {
    viewController2.onDidLoad = { [weak self] in
        self?.notify()
    }
    return viewController2
}

Also, something I can't explain, if I keep the reference as unowned and make ViewHandler inherit from NSObject, ViewHandler is released as expected and there is no leak:

class ViewHandler: NSObject {

    private let viewController2 = ViewController2()

    func getViewController() -> ViewController2 {
        viewController2.onDidLoad = { [unowned self] in
            self.notify()
        }
        return viewController2
    }

    ....
}

Anyone who can explain what going on?


Solution

  • According to my current understanding, the NSObject which conforms to NSObjectProtocol. This kind of objects was bridged from Objective-C which has a mature memory management. And when you use class, most of us still using this kind of class. It should not hurt if you are build a class from NSObject.

    The management of swift class seems working with a little experimental style as people prefer using a structure whenever possible. So it's not strange there is some behaviors unexpected.

    So when you choose swift class, you have to think more according to this experience. But the good side is they may bring some new and stable features which is different from classic NSObject.

    To make it simple, just remove vc2 as a private variable.

    class ViewHandler {
    
    func getViewController() -> ViewController2 {
    
        let viewController2  = ViewController2()
    
        viewController2.onDidLoad = { [unowned self] in
            self.notify()
        }
        return viewController2
    }
    
    func notify() {
        print("My viewcontroller has loaded its view!")
    }
    
    
    }
    

    In this case, the leak still exists. It's a hard condition to judge with unowned. Actually, the ownership of viewHandler has transferred to vc2.

    When vc2 is released, the leaks are also gone. It's kind of temporary leak.

      var firstTime: Bool = true
    
        override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
    
          if firstTime{
            viewHandler = ViewHandler()
            let vc = viewHandler!.getViewController()
            self.present(vc, animated: true, completion: nil)
    
            viewHandler = nil // ViewHandler is leaking here.
    
               DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                  vc.dismiss(animated: true, completion: nil)
                   // leaking is over.
               }
        }
        firstTime.toggle()
    }
    

    Even specific, the ownership is occupied by vc.onDidLoad. If

         viewHandler = nil // ViewHandler is leaking here.
    

    either

         vc.onDidLoad?() // error "ViewHandler has been dealloc!!"
    

    or

         vc.onDidLoad = nil. // There is no error here. 
    

    So you are suppose to handle here. And thus the "leaking" problem has been solved.