Experience of using “coordinators” in a real “iOS” project
Problem
It often happens that controllers start taking on too much: “give commands” to those who directly own it
UINavigationController, “communicate” with their “brothers” -controllers (even initialize them and pass them to the navigation stack) - in general, there’s a lot to do about they are not supposed to even suspect. One of the possible ways to avoid this is precisely the “coordinator”. Moreover, as it turned out, it’s quite convenient to work and very flexible: the template is able to manage navigation events of both small modules (representing, perhaps, only one single screen), and the entire application (launching its own “flow”, relatively speaking, directly from
UIApplicationDelegate)History
Martin Fowler, in his book Patterns of Enterprise Application Architecture, called this pattern Application Controller . And his first popularizer in the "iOS" environment is considered Sorush Khanlu : it all started with his report on "NSSpain" in 2015. Then a review article appeared on his website , which had several sequels (for example, this ).
And then a lot of reviews followed (the “ios coordinators” query returned dozens of results of varying quality and degree of detail), including even a guide on Ray Wenderlich and an article from Paul Hudson on his “Hacking with Swift”as part of a series of materials on ways to get rid of the problem of the "massive" controller.
Looking ahead, the most noticeable topic of discussion is the problem of the return button in
UINavigationController, clicking on which is not processed by our code, but we can only get a callback . Actually, why is this a problem? Coordinators, like any objects, in order to exist in memory, need some other object to “own” them. As a rule, when building a navigation system using coordinators, some coordinators generate others and keep a strong link to them. When “leaving the zone of responsibility” of the originating coordinator, control returns to the originating coordinator, and the memory occupied by the originator must be freed.
Sorush hashis vision of solving this problem , and also notes a couple of worthy approaches . But we will come back to this.
First approach
Before starting to show the real code, I would like to clarify that although the principles are fully consistent with those that we came up with in the project, excerpts from the code and examples of its use are simplified and reduced wherever it does not interfere with their perception.
When we first started to experiment with the coordinators in the team, we did not have a lot of time and freedom of action for this: it was necessary to reckon with the existing principles and the navigation device. The first option for the implementation of the coordinators was based on a common “router” that owns and controls
UINavigationController. He knows how to do with instances UIViewControllereverything that is needed with regards to navigation - “push” / “pop”, “present” / “dismiss” plus manipulations with the “root” controller . An example of the interface of such a router:import UIKit
protocol Router {
func present(_ module: UIViewController, animated: Bool)
func dismissModule(animated: Bool, completion: (() -> Void)?)
func push(_ module: UIViewController, animated: Bool, completion: (() -> Void)?)
func popModule(animated: Bool)
func setAsRoot(_ module: UIViewController)
func popToRootModule(animated: Bool)
}A specific implementation is initialized with an instance
UINavigationControllerand does not contain anything particularly tricky in itself. The only limitation: you cannot pass other instances as the values of the arguments to the interface methods UINavigationController(for obvious reasons: UINavigationControllerit cannot contain UINavigationControllerin its stack - this is a limitation UIKit). The coordinator, like any object, needs an owner - another object that will store a link to it. A link to the root can be stored by the object that generates it, but each coordinator can also generate other coordinators. Therefore, a base interface was written to provide a management mechanism for the generated coordinators:
class Coordinator {
private var childCoordinators = [Coordinator]()
func add(dependency coordinator: Coordinator) {
// ...
}
func remove(dependency coordinator: Coordinator) {
// ...
}
}One of the implied advantages of coordinators is the encapsulation of knowledge about specific subclasses
UIViewController. To ensure the interaction of the router and the coordinators, we introduced the following interface:protocol Presentable {
func presented() -> UIViewController
}Then each specific coordinator should inherit from
Coordinatorand implement the interface Presentable, and the router interface should take the following form:protocol Router {
func present(_ module: Presentable, animated: Bool)
func dismissModule(animated: Bool, completion: (() -> Void)?)
func push(_ module: Presentable, animated: Bool, completion: (() -> Void)?)
func popModule(animated: Bool)
func setAsRoot(_ module: Presentable)
func popToRootModule(animated: Bool)
}(Approach c
Presentablealso allows the use of coordinators inside modules that are written to interact directly with instances UIViewController, without subjecting them (modules) to radical processing.) A brief example of this is the case:
final class FirstCoordinator: Coordinator, Presentable {
func presented() -> UIViewController {
return UIViewController()
}
}
final class SecondCoordinator: Coordinator, Presentable {
func presented() -> UIViewController {
return UIViewController()
}
}
let nc = UINavigationController()
let router = RouterImpl(navigationController: nc) // Router implementation.
router.setAsRoot(FirstCoordinator())
router.push(SecondCoordinator(), animated: true, completion: nil)
router.popToRootModule(animated: true)Next approximation
And then one day the moment came for a total alteration of navigation and absolute freedom of expression! The moment when nothing prevented us from trying to implement navigation on the coordinators using the cherished method
start()- the version that captivated us initially with its simplicity and conciseness. The possibilities mentioned above
Coordinator, obviously, will not be superfluous. But the same method needs to be added to the general interface:protocol Coordinator {
func add(dependency coordinator: Coordinator)
func remove(dependency coordinator: Coordinator)
func start()
}
class BaseCoordinator: Coordinator {
private var childCoordinators = [Coordinator]()
func add(dependency coordinator: Coordinator) {
// ...
}
func remove(dependency coordinator: Coordinator) {
// ...
}
func start() { }
}“Swift” does not offer the ability to declare abstract classes (since it is more oriented to a protocol-oriented approach than to a more classical, object-oriented one ), therefore the method
start()can be left with an empty implementation, or put something there something like fatalError(_:file:line:)(forcing to override this method by the heirs). Personally, I prefer the first option. But Swift has a great opportunity to add default implementation methods to protocol methods, so the first thought, of course, was not to declare a base class, but to do something like this:
extension Coordinator {
func add(dependency coordinator: Coordinator) {
// ...
}
func remove(dependency coordinator: Coordinator) {
// ...
}
}But protocol extensions cannot declare stored fields, and implementations of these two methods should obviously be based on a private stored type property.
The basis of any particular coordinator will look like this:
final class SomeCoordinator: BaseCoordinator {
override func start() {
// ...
}
}Any dependencies that are necessary for the coordinator to function can be added to the initializer. As a typical case - an instance
UINavigationController. If this is the root coordinator whose responsibility is to map the root
UIViewController, the coordinator can, for example, accept a new instance UINavigationControllerwith an empty stack. When processing events (more on that later)
UINavigationController, the coordinator can transmit this further to other coordinators that it generates. And they can also do with the current state of navigation what they need: “push”, “present”, and at least replace the entire navigation stack.Possible interface improvements
As it turned out later, not every coordinator will generate other coordinators, so not all of them should depend on such a base class. Therefore, one of the colleagues from the related team suggested getting rid of inheritance and introducing the dependency manager interface as an external dependency:
protocol CoordinatorDependencies {
func add(dependency coordinator: Coordinator)
func remove(dependency coordinator: Coordinator)
}
final class DefaultCoordinatorDependencies: CoordinatorDependencies {
private let dependencies = [Coordinator]()
func add(dependency coordinator: Coordinator) {
// ...
}
func remove(dependency coordinator: Coordinator) {
// ...
}
}
final class SomeCoordinator: Coordinator {
private let dependencies: CoordinatorDependencies
init(dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) {
dependencies = dependenciesManager
}
func start() {
// ...
}
}Handling user-generated events
Well, the coordinator created and somehow initiated a new mapping. Most likely, the user looks at the screen and sees a certain set of visual elements with which he can interact: buttons, text fields, etc. Some of them provoke navigation events, and they must be controlled by the coordinator who generated this controller. To solve this problem, we use traditional delegation .
Suppose there is a subclass
UIViewController:final class SomeViewController: UIViewController { }And the coordinator who adds it to the stack:
final class SomeCoordinator: Coordinator {
private let dependencies: CoordinatorDependencies
private weak var navigationController: UINavigationController?
init(navigationController: UINavigationController,
dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) {
self.navigationController = navigationController
dependencies = dependenciesManager
}
func start() {
let vc = SomeViewController()
navigationController?.pushViewController(vc, animated: true)
}
}We delegate the processing of the corresponding controller events to the same coordinator. Here, in fact, the classical scheme is used:
protocol SomeViewControllerRoute: class {
func onSomeEvent()
}
final class SomeViewController: UIViewController {
private weak var route: SomeViewControllerRoute?
init(route: SomeViewControllerRoute) {
self.route = route
super.init(nibName: nil, bundle: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@IBAction
private func buttonAction() {
route?.onSomeEvent()
}
}
final class SomeCoordinator: Coordinator {
private let dependencies: CoordinatorDependencies
private weak var navigationController: UINavigationController?
init(navigationController: UINavigationController,
dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) {
self.navigationController = navigationController
dependencies = dependenciesManager
}
func start() {
let vc = SomeViewController(route: self)
navigationController?.pushViewController(vc, animated: true)
}
}
extension SomeCoordinator: SomeViewControllerRoute {
func onSomeEvent() {
// ...
}
}Handling the return button
Another good review of the discussed architectural template was published by Paul Hudson on his website “Hacking with Swift,” one might even say a guide. It also contains a simple, straightforward, explanation of one of their possible solutions to the aforementioned problem of the return button: the coordinator (if necessary) declares himself a delegate of the instance passed to him
UINavigationControllerand monitors the event of interest to us. This approach has a slight drawback:
UINavigationControlleronly an heir can be a delegate NSObject. So, there is a coordinator that spawns another coordinator. This, the other, on call
start()adds UINavigationControllersome kind of his own to the stack UIViewController. By pressing the back button onUINavigationBarall that needs to be done is to let the originating coordinator know that the generated coordinator has finished his work (“flow”). To do this, we introduced another delegation tool: a delegate is allocated to each generated coordinator, the interface of which is implemented by the generating coordinator:protocol CoordinatorFlowListener: class {
func onFlowFinished(coordinator: Coordinator)
}
final class MainCoordinator: NSObject, Coordinator {
private let dependencies: CoordinatorDependencies
private let navigationController: UINavigationController
init(navigationController: UINavigationController,
dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) {
self.navigationController = navigationController
dependencies = dependenciesManager
super.init()
}
func start() {
let someCoordinator = SomeCoordinator(navigationController: navigationController, flowListener: self)
dependencies.add(someCoordinator)
someCoordinator.start()
}
}
extension MainCoordinator: CoordinatorFlowListener {
func onFlowFinished(coordinator: Coordinator) {
dependencies.remove(coordinator)
// ...
}
}
final class SomeCoordinator: NSObject, Coordinator {
private weak var flowListener: CoordinatorFlowListener?
private weak var navigationController: UINavigationController?
init(navigationController: UINavigationController,
flowListener: CoordinatorFlowListener) {
self.navigationController = navigationController
self.flowListener = flowListener
}
func start() {
// ...
}
}
extension SomeCoordinator: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool) {
guard let fromVC = navigationController.transitionCoordinator?.viewController(forKey: .from) else { return }
if navigationController.viewControllers.contains(fromVC) { return }
if fromVC is SomeViewController {
flowListener?.onFlowFinished(coordinator: self)
}
}
}In the example above
MainCoordinator, it does nothing: it simply launches the “flow” of another coordinator - in real life, of course, it is useless. In our application, it MainCoordinatorreceives data from the outside, according to which it determines what state the application is in - authorized, not authorized, etc. - and which screen needs to be shown. Depending on this, it launches a flow of the corresponding coordinator. If the generated coordinator has finished his work, the main coordinator receives a signal about this through CoordinatorFlowListenerand, say, launches a “flow” of another coordinator.Conclusion
The accustomed solution, of course, has a number of disadvantages (like any solution to any problem).
Yes, you have to use a lot of delegation, but it is simple and has a single direction: from the generated to the generated (from the controller to the coordinator, from the generated coordinator to the generated).
Yes, in order to be saved from memory leaks, you have to add a delegate method
UINavigationControllerwith almost identical implementation to each coordinator . (The first approach does not have this drawback, but instead more generously shares its internal knowledge about the appointment of a specific coordinator.)But the biggest drawback of this approach is that, in real life, the coordinators, unfortunately, will know a little more about the world around them than we would like. More precisely, they will have to add logic elements that depend on external conditions, which the coordinator is not directly aware of. Basically, this is, in fact, what happens when a method is called
start()or by a callback onFlowFinished(coordinator:). And anything can happen in these places, and it will always be a "hardcoded" behavior: adding a controller to the stack, replacing the stack, returning to the root controller - whatever. And all this does not depend on the competencies of the current controller, but on external conditions.Nevertheless, the code is “pretty” and concise, it’s really nice to work with it, and navigation through the code is much easier. It seemed to us that with the mentioned shortcomings, being aware of them, it is quite possible to exist.
Thank you for reading to this place! I hope they learned something useful for themselves. And if you suddenly want "more than me", then here is a link to my Twitter .