Coordinator pattern problems and what does RouteComposer have to do with it
I continue the series of articles about the RouteComposer library that we use, and today I want to talk about the Coordinator pattern. I was prompted to write this article by a discussion of one of the articles about the pattern. The coordinator here on Habr.
The Coordinator pattern, being introduced not so long ago, is gaining more and more popularity among iOS developers, and, in general, it is clear why. Because the tools out of the box that UIKit provides are not quite a universal mess.
I have already raised the question of the fragmentation of the way I compose the view of controllers on the stack, and to avoid repetition, you can just read about it here .
Let's be honest. At some point, Epole realized that by putting the controllers in the application development center, she did not offer any sensible way to create or transfer data between them, and, having entrusted the solution to this problem to the developers, it was autocompleted from Xcode, and maybe to the UISearchConnroller developers, at some point introduced storyboards and segues to us. Then Epolus realized that she wrote applications consisting of 2 screens only herself, and in the next iteration she offered the opportunity to split storyboards into several components, since Xcode began to crash when the storyboard reached a certain size. Segues have changed along with this concept, in several iterations that are not very compatible with each other. Their support is tightly sewn into a massive classUIViewController
, and, in the end, we got what we got. This:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "showDetail" {
if let indexPath = tableView.indexPathForSelectedRow {
let object = objects[indexPath.row] as! NSDate
let controller = (segue.destination as! UINavigationController).topViewController as! DetailViewController
controller.detailItem = object
controller.navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem
controller.navigationItem.leftItemsSupplementBackButton = true
}
}
}
The number of force tycastcasts in this block of code is amazing, as are the string constants in the storyboards themselves, for tracking which Xcode does not offer any means at all. And the slightest desire to change something in the navigation process will allow you to compile the project without any effort and it will crash with a bang in runtime without the slightest warning from Xcode. Here is such a WYSIWYG in the end it turned out. What you see is what you get.
You can argue for a long time about the charms of these gray arrows in storyboards supposedly showing someone the connections between the screens, but, as my practice has shown, I intentionally interviewed several familiar developers from different companies, as soon as the project grew beyond 5-6 screens, people tried find a more reliable solution and finally began to keep the structure of the stack of view controllers in my head. And if support for the iPad and other navigation models or support for pushes were added, then everything was sad there.
Since then, several attempts have been made to solve this problem, some of which resulted in separate frameworks, some in separate architectural patterns, since the creation of view controllers inside the view controller made this massive and clumsy piece of code even more.
Let's go back to the Coordinator pattern. For obvious reasons, you will not find its description on Wikipedia because it is not a standard programming / design pattern. Rather, it is a kind of abstraction, which suggests hiding under the hood all this “ugly” code for creating and inserting a new controller twist on the stack, storing references to the controllers container and pushing data between the controllers. The most suitable article describing this process I would call an article on raywenderlich.com . It begins to become popular after the 2015 NSSpain conference, when the general public was told about it. In more detail what was told can be found here and here .
I will briefly describe what it consists of before moving on.
The Coordinator pattern in all interpretations approximately fits into this picture:
That is, the coordinator is a protocol
protocol Coordinator {
func start()
}
And all the ugly code is supposed to be hidden in the function start
. The coordinator, in addition, can have links to child coordinators, that is, they have some ability to composition, and, for example, you can replace one implementation with another. That is, it sounds pretty elegant.
However, the inanities begin quite soon:
- Some implementations propose to turn the Coordinator from a certain generating pattern into something more reasonable, monitoring the stack of controllers and making it a container delegate , for example
UINavigationController
, to handle pressing the Back button or swipe back and delete the child coordinator. For natural reasons, only one object can be a delegate, which limits the control of the container itself and leads to the fact that this logic either lies with the coordinator, or creates the need to delegate this logic further to someone further down the list. - Often the logic for creating the next controller depends on the business logic . For example, to go to the next screen, the user must be logged into the system. Clearly, this is an asynchronous process, which includes generating some intermediate screen with the login form, the login process itself can end successfully or not. To avoid transforming the Coordinator into a Massive Coordinator (similar to the Massive View Controller), we need decomposition. That is, in fact, you need to create a Coordinator Coordinator.
- Another problem that the coordinators are faced with is that they are essentially wrappers for container view controllers such as
UINavigationController
,UITabBarController
and so on. And someone should provide links to these controllers . If with child coordinators everything is even less clear, then with the initial coordinators of the chain, not everything is so simple. Plus, when changing the navigation, for example for the A / B test, refactoring and adaptation of such coordinators results in a separate headache. Especially if the type of container changes. - All this becomes even more complicated when the application starts supporting external events that generate view controllers. Such as push notifications or universal links (the user clicks on the link in the letter and continues in the corresponding application screen). Here other uncertainties arise for which the Coordinator pattern does not have an exact answer. You need to know exactly which screen the user is currently on in order to show him the next screen requested by an external event.
The simplest example is a chat application consisting of 3 screens - a chat list, the chat itself which is pushed into the navigation of the chat list controller and the settings screen displayed modally. The user can be on one of these screens when he receives a push notification and taps on it. And here the uncertainty begins, if he is in the chat list, you need to start a chat with this specific user, if he is already in the chat, you need to switch it, and if he is already in the chat with this user, then do nothing and update, if the user is on settings screen - it, apparently you need to close and follow the previous steps. Or maybe not close and just show the chat modally over the settings? And if the settings are in another tab, and not modal? Theseif/else
start or spread out over the coordinators or go to another Mega-Coordinator in the form of a piece of spaghetti. Plus, it’s either active iterations on the controllers’s view stack and an attempt to determine where the user is at the moment, or an attempt to build some kind of application that monitors their status, but this is not an easy task, just based on the nature of the view controllers stack itself. - And the cherry on the cake are UIKit glitches . A banal example:
UITabBarController
in which in the second tabUINavigationController
with some otherUIViewController
. The user in the first tab causes a certain event that requires switching the tab and pushingUINavigationController
another controller into it. All this needs to be done in just such a sequence. If the user has never opened a second tab before, and the method hasUINavigationController
not been calledviewDidLoad
push
will not work leaving only a slurred message in the console. That is, coordinators cannot simply be made listeners of events in this example, they must work in a certain sequence. So they must have knowledge of each other. And this already contradicts the first statement of the Coordinator pattern, that the coordinators do not know anything about the generating coordinators and are connected only with the child ones. And also limits their interchangeability.
This list can be continued, but in general it is clear that the Coordinator pattern is a rather limited, poorly scalable solution. If you look at it without pink glasses, then it is a way of decomposing part of the logic, which is usually written inside massive UIViewController
s, into another class. All attempts to make it more than just some generative factory and introduce other logic there do not end well.
It is worth noting that there are libraries based on this pattern, which, with one way or another, allow partially mitigate the above disadvantages. I would mention XCoordinator and RxFlow .
What have we done?
Having played in the project that we got from another team for support and development, with the coordinators and their simplified “great-grandmother” Router in the VIPER architectural approach , we rolled back to the approach that worked well in the previous large project of our company. This approach does not have a name. It lies on the surface. When we had free time, it was compiled into a separate RouteComposer library which completely replaced the coordinators and proved to be more flexible.
What is this approach? In that, to rely on the stack (tree) I twist the controllers as it is. In order not to create unnecessary entities that need to be monitored. Do not save or track conditions.
Let's look at the UIKit entities more closely and try to figure out what we have in the bottom line and what we can work with:
- The controller stack is a tree. There is a root view controller that has child view controllers. View controllers presented modally are a special case of child view controllers, since they also have a binding to the generated view controller. It is all available out of the box.
- I need to create entities of controllers. They all have different constructors; they can be created using Xib files or Storyboards. They have different input parameters. But they are united in that they need to be created. So, here we can use the Factory pattern , which knows how to create the desired view controller. Each factory is easy to cover with comprehensive unit tests and it is independent of others.
- We divide the view controllers into 2 classes: 1. Just view the controllers, 2. Container view controllers (Container View Controller) . Container view controllers differ from ordinary ones in that they can contain child view controllers - also containers or simple ones. Such twist controllers are available out of the box:
UINavigationController
,UITabBarController
and so on, but can also be created by the user. If we ignore it, we can find the following properties in all containers: 1. They have a list of all the controllers that they contain. 2. One or more controllers are currently visible. 3. They may be asked to make one of these controllers visible. This is all that UIKit controllers can do . They just have different methods for this. But there are only 3 tasks. - To build a factory twist the controller, using the method of the parent twist Controller
UINavigationController.pushViewController(...)
,UITabBarController.selectedViewController = ...
,UIViewController.present(...)
and so on. You may notice that 2 view controllers are always required, one already on the stack, and one that needs to be embedded on the stack. Wrap this in a wrapper and call it Action (Action) . Each action is easy to cover with comprehensive unit tests and each is independent of the others. - From the above, it turns out that using prepared entities, you can build a configuration chain Factory -> Action -> Factory -> Action -> Factory, and after completing it, you can build a view tree of controllers of any complexity. You only need to specify the entry point. These input points are usually either the rootViewController owned by the UIWindow or the current view controller, which is the most extreme branch of the tree. That is, such a configuration is correctly written as: Starting ViewController -> Action -> Factory -> ... -> Factory .
- In addition to the configuration, you will need some entity that knows how to start and build the provided configuration. We will call it Router . It does not have a state, it does not hold any links. It has one method to which the configuration is passed and it sequentially performs the configuration steps.
- Add responsibility to the router by adding Interceptors classes to the configuration chain . Interceptors are possible of 3 types: 1. Launched before starting navigation. We remove the tasks of user authentication in the system and other asynchronous tasks in them. 2. Run at the time of creation of the view controller to set the values. 3. Performed after navigation and performing various analytical tasks. Each entity is easily covered by unit tests and does not know how it will be used in the configuration. She has only one responsibility and she fulfills it. That is, the configuration for complex navigation may look like [Pre-navigation Task ...] -> Starting ViewController -> Action -> (Factory + [ContextTask ...]) -> ... -> (Factory + [ContextTask ...]) -> [Post NavigationTask ...]. That is, all tasks will be performed by the router sequentially, performing in turn small, easily readable, atomic entities.
- The last task that cannot be solved by the configuration remains - this is the state of the application at the moment. What if we need to build not the entire configuration chain, but only part of it, because the user partially passed it? This question can always be answered unambiguously by the tree of view controllers. Because if part of the chain is already built, it is already in the tree. This means that if each factory in the chain can answer the question whether it is built or not, then the router will be able to understand which part of the chain needs to be completed. Of course, this is not the task of the factory, so another atomic entity is introduced - the Finder, and any configuration looks like this:[Pre-navigation Task ...] -> Starting ViewController -> Action -> (Finder / Factory + [ContextTask ...]) -> ... -> (Finder / Factory + [ContextTask ...]) -> [Post NavigationTask ...] . If the router starts reading it from the end, then one of the Finders will tell him that it is already built, and the router from this point will begin to build the chain back. If not one of them finds himself in the tree, then you need to build the entire chain from the initial controller.
- The configuration must be strongly typed. Therefore, each entity works with only one type of controller view; one type of data and configuration rests entirely on the ability of swift to work with associatedtypes . We want to rely on the compiler, not on runtime. A developer can intentionally weaken typing, but not vice versa.
An example of such a configuration:
let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory())
.add(LoginInterceptor()) // Have to specify the context type till https://bugs.swift.org/browse/SR-8719 is fixed
.add(ProductViewControllerContextTask())
.add(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance))
.using(UINavigationController.push())
.from(NavigationControllerStep())
.using(GeneralActions.presentModally())
.from(GeneralStep.current())
.assemble()
The items described above cover the entire library and describe the approach. All that remains for us is to provide the chain configurations that the router will execute when the user clicks a button or an external event occurs. If these are different types of devices, for example iPhone or iPad, then we will provide different transition configurations using polymorphism. If we have A / B testing, the same thing. We do not need to think about the state of the application at the moment of starting navigation, we need to make sure that the configuration is written correctly initially, and we are sure that the router will somehow build it.
The described approach is more complicated than a certain abstraction or pattern, but we have not yet faced the problem where it would not be enough. Of course, RouteComposer requires some study and understanding of how it works. However, this is much easier than learning the basics of AutoLayout or RunLoop. No higher math.
The library, as well as the implementation of the router provided to it, does not use any objective tricks with runtime and fully follows all Cocoa Touch concepts, only helping to break the composition process into steps and executing them in the given sequence. The library is tested with iOS versions 9 through 12.
It is possible to read in more details in previous articles:
Composition of UIViewControllers and navigation between them (and not only) /
geek magazine Sample configuration of UIViewControllers using RouteComposer / geek magazine
Thanks for attention. I will be happy to answer questions in the comments.