📜 ⬆️ ⬇️

Experience of using “coordinators” in a real “iOS” -project

The world of modern programming is rich in trends, and for the world of programming “iOS” applications this is doubly true. I hope I am not much mistaken in asserting that one of the most “fashionable” architectural patterns of recent years is the “coordinator”. So, our team some time ago realized the overwhelming desire to try this technique on themselves. Moreover, a very successful case has turned up - a significant change in logic and total re-planning of navigation in the application.

Problem


It often happens that controllers start taking on too much: “giving commands” directly to the UINavigationController that owns it, “communicating” with their own “brothers” controllers (even initializing them and passing to the navigation stack) - in general, do a lot of things than they are not supposed to even suspect.

One of the possible ways to avoid this is the “coordinator”. And, as it turned out, it is quite easy to use and very flexible: the template is able to manage the navigation events of both small modules (representing perhaps only one screen) and the entire application (launching its “flow”, relatively speaking, from UIApplicationDelegate ).

Story


Martin Fowler in his book “Patterns of Enterprise Application Architecture” called this pattern “Application Controller” . And his first popularizer in the iOS environment is Sorush Khanlu : it all started with his 2015 NSSpain talk . Then a review article appeared on his website , which had several sequels (for example, this ).
')
And then many reviews followed (the “ios coordinators” request produces 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” in a series of materials on how to get rid of the problem "Massive" controller.

Looking ahead, the most visible topic for discussion is the problem of the return button in the UINavigationController , the click on which is not handled by our code, and we can only get a callback .

Actually, why is this a problem? Coordinators, like any objects, in order to exist in memory need to be owned by some other object. As a rule, when building a navigation system with the help of coordinators, some coordinators generate others and keep a strong link on them. Upon “leaving the zone of responsibility” of the generated coordinator, control returns to the generating coordinator, and the memory occupied by the generated one must be released.

Soroush has his own 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 we came to the project, excerpts from the code and examples of its use are simplified and shortened where it does not interfere with their perception.

When we first started experimenting with the coordinators in the team, we did not have much time and freedom of action for this: it was necessary to reckon with the existing principles and navigation device. The first implementation of the coordinators was based on a common “router” that owns and manages the UINavigationController . He can do everything with UIViewController instances with respect 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) } 

The concrete implementation is initialized with the UINavigationController instance and does not contain anything particularly tricky in itself. The only restriction is that other instances of UINavigationController cannot be passed as interface argument values ​​(for obvious reasons: UINavigationController cannot contain UINavigationController in its stack — this is a UIKit constraint).

The coordinator, like any object, needs an owner — another object that will hold a link to it. The link to the root can be stored by the object generating it, but each coordinator can also generate other coordinators. Therefore, a basic interface was written to provide a mechanism for managing the generated coordinators:

 class Coordinator { private var childCoordinators = [Coordinator]() func add(dependency coordinator: Coordinator) { // ... } func remove(dependency coordinator: Coordinator) { // ... } } 

One of the implied merits of coordinators is the encapsulation of knowledge about specific subclasses of UIViewController . To ensure the interaction of the router and coordinators, we introduced the following interface:

 protocol Presentable { func presented() -> UIViewController } 

Then each specific coordinator should inherit from the Coordinator and implement the Presentable interface, 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) } 

(The Presentable approach also allows coordinators to be used inside modules that are written to interact directly with UIViewController instances without exposing them (modules) to cardinal processing.)

A brief example of this is all about:

 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


Then one day the moment of a total rework of navigation and absolute freedom of expression! The moment when nothing prevented trying to implement navigation on the coordinators using the cherished start() method - a version that captivated initially with its simplicity and brevity.

The capabilities of the Coordinator mentioned above will obviously not be redundant. But to the common interface, you need to add the same method:

 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 possibility to declare abstract classes (since it is more oriented towards the protocol-oriented approach than the more classical, object-oriented ), therefore the start() method can be either left with an empty implementation or shoved there is something like fatalError(_:file:line:) (forcing the method to override this method by successors). Personally, I prefer the first option.

But Swift has a great opportunity to add default protocol implementation methods, so the first thought, of course, was not to declare the 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 the implementation of these two methods obviously must 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 functioning of the coordinator can be added to the initializer. As a typical case, an instance of UINavigationController .

If it is the root coordinator whose responsibility is to display the root UIViewController , the coordinator can, for example, accept a new instance of UINavigationController with an empty stack.

Inside himself, the coordinator during event processing (about this later) can pass this UINavigationController on to other coordinators, which it spawns. 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, therefore not all of them should depend on such a base class. Therefore, one of the colleagues from the adjacent team offered to get rid of inheritance and introduce 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() { // ... } } 

Handle 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 should be managed by the coordinator who generated this controller. To solve this problem, we use traditional delegation .

Suppose there is a subclass of 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 to the same coordinator the handling of the corresponding controller events. 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 overview of the architectural template under discussion was published by Paul Hudson on his website Hacking with Swift , one might even say, a manual. It also contains a simple, straightforward explanation of one of the possible solutions to the above mentioned return button problem: the coordinator (if necessary) declares himself a delegate of the UINavigationController instance passed to him and tracks the event of interest to us.

This approach has a small flaw: the delegate of UINavigationController can only be the successor of NSObject .

So, there is a coordinator who spawns another coordinator. This, another one, on the start() call, adds a UIViewController to the UINavigationController stack. By clicking on the back button on the UINavigationBar all that needs to be done is to let the originating coordinator know that the generated coordinator has finished its work (flow). To do this, we have introduced another delegation tool: each delegate is assigned a delegate whose interface is implemented by the originating 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, the MainCoordinator does nothing: it simply launches the “flow” of another coordinator - in real life, of course, it is useless. In our application, the MainCoordinator receives data from outside, according to which it determines the state of the application - authorized, unauthorized, etc. - and what kind of screen you need to show. Depending on this, it launches the flow of the corresponding coordinator. If the child coordinator has finished his work, the main coordinator receives a signal about this through the CoordinatorFlowListener and, say, starts the flow of another coordinator.

Conclusion


The surviving solution, of course, has a number of drawbacks (like any solution to any problem).

Yes, you have to use a lot of delegation, but it is simple and has a common direction: from the generator to the generator (from the controller to the coordinator, from the generated coordinator to the generator).

Yes, in order to be saved from memory leaks, you have to add a UINavigationController delegate method to each coordinator with a nearly identical implementation. (The first approach lacks this disadvantage, but instead more generously shares its internal knowledge of 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 elements of logic, depending on external conditions, about which the coordinator is not directly informed. Basically, this is, in fact, what happens by calling the start() method or by calling onFlowFinished(coordinator:) . And anything can happen in these places, and this will always be “hardcoded” behavior: adding a controller to the stack, replacing the stack, returning to the root controller - whatever. And it all depends not on the competencies of the current controller, but on the external conditions.

Nevertheless, the code turns out to be “cute” and concise, working with it is really nice, and navigation is traced by code much easier. It seemed to us that, with the mentioned defects, being aware of them, it is quite possible to exist.
Thank you for reading this place! Hope to learn something useful for yourself. And if you suddenly want "more than me", then here is a link to my Twitter .

Source: https://habr.com/ru/post/444038/


All Articles