A deallocation approach for the Coordinator pattern


Coordinators changed the way we develop iOS Apps. They allow to have better control of the navigation flow in a way that is clean, simple and easy to use. However, there is one aspect of this pattern that might lead to memory leaks and this is that you are in control of the coordinator’s lifecycle so you have to manually deallocate them. This is not bad if you are careful and you have control of what is happening. Sadly that might not be always the case, specially when dealing with UIViewController/UINavigationController.

The actual scenario

While working with coordinators I found myself dealing with a few edge cases in which I didn’t have direct control of the flow (leading to potential memory leaks). I wanted to avoid ‘hacking, tracking, changing’ in the way controllers are presented in the application while keeping coordinators away from having to be merged with UIKit e.g:

navigationController.push(coordinator: SomeCoord()) // yuck!

So let’s assume your designer comes up with the following flow for a personal details input:

Let’s ignore for a moment any UX related issues you could find with this example like: Why not to use a modal presentation instead?. I had to deal with this kind of flows heavily and they expose the trickiest cases for deallocation so let’s move on.

The hotspots in this flow that could generate memory leaks are the following:

  • The user taps back on FirstNameVC
  • The user swipes back via interactivePopGestureRecognizer to dismiss FirstNameVC
  • The user taps cancel on FirstNameVC which should perform popToRootViewController
  • The user taps cancel on LastNameVC which should perform popToRootViewController
  • The user taps back on ResultVC
  • The user swipes back via interactivePopGestureRecognizer to dismiss ResultVC
  • In a TabBar based application you could be required to popToRootViewController if the user taps again the tabBarIcon in which the flow is taking place

As you can see, this simple flow generates seven possible user interactions you have to cover and react accordingly in order to guarantee that UpdateDetailsCoordinator gets deallocated. To keep this article short I will skip trying to demonstrate how the leak occurs, instead I will explain how you can tackle all of them to regain control and keep your coordinators on track.

The approach

Before anything, it is important to make clear what is the foundation of this solution:

If a Coordinator relies on a ViewController in order to exist and this controller is not alive anymore then such coordinator should be deallocated.

So we really don’t care how a ViewController got dismissed (swipe, or tap back or tap TabBar) what we really care about is that it got deallocated, bingo! 🎉

In the previous diagram flow if FirstNameVC or ResultVC get deallocated then UpdateDetailsCoordinator should get also deallocated.

Ok how can we work this out?. There are many ways to do Coordinators, this is just the one that worked better for us:

// 1
protocol Deinitcallable: AnyObject {
var onDeinit: (() -> Void)? { get set }
}
// 2
protocol CoordinatorProtocol: AnyObject {
func start()
var stop: (() -> Void)? { get set }
func setDeallocallable(with object: Deinitcallable)
var deallocallable: Deinitcallable? { get set }
}
// 3
extension CoordinatorProtocol {
/// sets the key Deallocallable object for a coordinator, this enables dealloaction of the coordinator once the object gets deallocated via onDeinit closure, only one key object can be active at a time. Calling this func multiple times in the life cycle of a coordinator will just replace previous key object

func setDeallocallable(with object: Deinitcallable) {
        deallocallable?.onDeinit = nil
object.onDeinit = {[weak self] in
self?.stop?()
}
deallocallable = object
}
}
// 4
class Coordinator: NSObject, CoordinatorProtocol {

// MARK: Properties
var stop: (() -> Void)?
weak var deallocallable: Deinitcallable?

func start() {}
}
// 5
class BaseVC: Deinitcallable {

var onDeinit: (() -> Void)?
    deinit {
onDeinit?()
}
}
  1. Before anything, we define a protocol to wrap the intent of dealing with deallocation. This protocol just defines a closure to be executed when an object gets deallocated.
  2. We have a protocol for our Coordinators. This protocol defines the way to send signals about starting/stopping our coordinator and the basics of dealing with deallocation via setDeallocallable and deallocallable.
  3. A default implementation for setDeallocallable. This just tracks what’s the objects that should be linked to a coordinator and what to do when this object gets deallocated via onDeinit closure. In this case we want to stop the coordinator.
  4. We define a base class for our coordinators in order to keep a reference to deallocallable . This is the object in which the coordinator relies on to exist. This object can be updated anytime via setDeallocallable.
  5. A base class for our ViewControllers that implement Deinitcallableprotocol. So the main logic for deallocation is centralised. As you can seedeinit will execute the closure onDeinit

This is how our MainCoordinator looks like after having defined the barebones of our solution:

class MainCoordinator: Coordinator {

let rootController: UINavigationController
    var updateDetailsCoord: UpdateDetailsCoordinator?

init(with rootController: UINavigationController) {
self.rootController = rootController
}

...

func didRequestToUpdateDetails() {
updateDetailsCoord = UpdateDetailsCoordinator(with: rootController)
updateDetailsCoord?.start()
updateDetailsCoord?.stop = { [weak self] in
self?.updateDetailsCoord = nil
}
}
}

To simplify things we are going to focus only on the update details flow. As you can see MainCoordinator keeps a reference to the child coordinator (UpdateDetailsCoordinator) and starts it when the user requests this action via didRequestToUpdateDetails . We free resources of this reference when stop is executed on the child coordinator making updateDetailsCoord = nil

So here we go, we need to guarantee that stop gets called on any of the previous 7 possible user interactions.

This is how our UpdateDetailsCoordinator looks like:

class UpdateDetailsCoordinator: Coordinator {

let rootController: UINavigationController

init(with rootController: UINavigationController) {
self.rootController = rootController
}
// 1
override func start() {
let firstNameVC = FirstNameVC()
setDeallocallable(with: firstNameVC)
rootController.pushViewController(firstNameVC, animated: true)
}
    // 2
func didCompleteFirstNameInput() {
let lastNameVC = LastNameVC()
rootController.pushViewController(lastNameVC, animated: true)
}

// 3
func didRequestSaveDetails() {
        //... some async work probably, then:
let firstVC = rootController.children.first
let resultVC = ResultVC()
        // 4
setDeallocallable(with: resultVC)
rootController.setViewControllers([firstVC, resultVC], animated: true)
}

// 5
func didTapFish() {
rootController.popViewController(animated: true)
}
    // 6
func didRequestCancel() {
rootController.popToRootViewController(animated: true)
}
}
  1. When this coordinator starts, it pushes firstNameVC into the rootController. At this point firstNameVC becomes the object who defines the reason of the coordinator to exist, so it is tagged as such via setDeallocallable(with: firstNameVC) . No matter what, if this controller does not exist anymore then a stop signal should be trigger to allow the coordinator to be deallocated.
  2. When the user completes the input of first name and taps next, we just want to push a new controller into the stack, in this case lastNameVC. Notice that we don’t call setDeallocallable here because lastNameVC is just another VC on the stack and it’s not crucial for our coordinator.
  3. The user completed the required input and taps ‘Save’. As part of the requirements from our designers we need to: push ResultsVC , remove firstNameVC and lastNameVC from the stack. Then, when the user dismisses ResultsVC, MainVC will be presented.
  4. If we remove firstNameVC from the stack it will be deallocated and it will trigger the deallocation mechanism, but things have changed and now we have a new controller who defines the existence of our coordinator (resultVC) so we need to update this link via setDeallocallable(with: resultVC) before calling setViewControllers
  5. Once the user reaches the final screen and taps ‘Finish’, we will just pop resultVC from the stack triggering the deallocation mechanism thanks to the link established in point 4
  6. If the user decides to cancel the process, we will just simply call popToRootViewController. Any previous controller marked as Deallocallable via setDeallocallable will trigger the deallocation mechanism for free.

Conclusion

For starters, you might think that not dealing with this is not that bad, after all we only have a single reference of a coordinator that is not properly deallocated at any time. Reinitiating the flow will indeed release the previous reference assigned to updateDetailsCoord but this will not change one fact, you could have a memory leak and you should take care of it.

There are multiple ways of addressing this, if you google it you will find solutions that go from tracking when a ViewController enters or leave the stack via UINavigationControllerDelegate, or solutions that blend UIKit with coordinators forcing you to change the way that ViewControllers are presented in your application.

What I like about this approach is that it is really light and not intrusive. As you can see it only took 2 protocols and 2 base classes to achieve this but more importantly, we didn’t have to fundamentally change the way Coordinators are meant to be, according to Soroush Khanlou:

A coordinator is an object that bosses one or more view controllers around.

Our coordinators are still in charge, they know about UIKit but not the other way around.


If you found this article useful or have any ideas about the topic please let me know in the comments.