📜 ⬆️ ⬇️

Pattern Problems Coordinator and what's the RouteComposer

I continue the series of articles about the library RouteComposer we use, and today I want to talk about the Coordinator pattern. By writing this article, I was inspired by the discussion of one of the articles on the Coordinator pattern here on Habré.


The Pattern Coordinator, having been introduced not so long ago, is gaining more and more popularity in IOS developer circles, and, in general, it is clear why. Because the tools out of the box that UIKit provides are a rather non-universal mess.


image


I have already raised the issue of fragmentation of ways of composition with the view controllers on the stack, and to avoid repetition - you can just read about it here .


Let's be honest. At some point, and Epol realized that putting twist controllers in the application development center, she did not offer any sensible way to create or transfer data between them, and, having ordered the solution of this problem to the developers of autocompilers from Xcode, but can the developers of UISearchConnroller, at some point presented storyboards and segues. Then Epol realized that the application consisting of 2 screens she writes only herself, and in the next iteration offered the opportunity to split storyboards into several parts, as Xcode began to crash when it reached a certain size. Segues have changed with this concept, in several iterations that are not very compatible with each other. Their support is tightly embedded in the massive UIViewController class, and, ultimately, 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 typkasts in this block of code is amazing, as are the string constants in the storyboards themselves, for which Xcode does not offer any means to track. And the slightest desire to change something in the process of navigation will allow you to compile the project without any effort, and it will crash into runtime with a cheerful crackling sound without any warning from Xcode. Here is such a WYSIWYG eventually turned out. What you see is what you get.


One can argue about the charms of these gray arrows in the storyboards supposedly showing someone the connections between the screens, but, as my practice showed, and I deliberately 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 you added support for the iPad and another navigation model, or support for pushing, then everything was generally sad.


Since then, several attempts have been made to solve this problem, some of which turned into separate frameworks, some into separate architectural patterns, because by creating the view controller inside the controller view, this massive and unwieldy piece of code made even more.


Let's return to the Coordinator pattern. For obvious reasons, you will not find its description in Wikipedia because it is not a standard programming / design pattern. Rather, it is a kind of abstraction that suggests hiding under the hood all this “ugly” code of creating and inserting into the stack a new view of the controller, storing references to the container controllers and pushing data between the controllers. The most useful article describing this process, I would call an article on raywenderlich.com . It is starting to become popular after the 2015 NSSpain conference, when it was told to the general public. In more detail what has been told can be found here and here .


I will briefly describe what it is before moving on.


In all interpretations the Pattern Coordinator fits approximately 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 start function. The coordinator, in addition, may have links to child coordinators, that is, they have some ability to compose, and, for example, you can replace one implementation with another. That is, it sounds pretty elegant.


However, non-elegance starts pretty soon:


  1. Some implementations propose to transform the Coordinator from some generating pattern into something more reasonable, following the stack of view controllers, and make it a container delegate , for example, UINavigationController , to handle pressing the Back button or swipe back and remove the child coordinator. For natural reasons, only one object can be a delegate, which limits the ability to control 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 else on the list.
  2. Often, the logic of creating the next controller depends on the business logic . For example, to go to the next screen, the user must be logged in to the system. Clearly, this is an asynchronous process, which involves spawning some intermediate screen with a login form, the login process itself can succeed or not. To avoid turning the Coordinator into a Massive Coordinator (by analogy with the Massive View Controller), we need decomposition. That is, in fact, it is necessary to create a Coordinator Coordinator.
  3. Another problem faced by coordinators is that they are essentially wrappers for container UITabBarController controllers, such as UINavigationController , UITabBarController and so on. And someone has to provide links to these controllers to them. If everything is more or less clear with the child coordinators, then the initial coordinators of the chain are not 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.
  4. All this becomes even more complicated when the application begins to support external events that spawn controllers. Such as push-notifications or universal links (the user clicks on the link in the letter and continues in the appropriate screen of the application). There are other uncertainties that the Pattern Coordinator does not have an exact answer for. You need to know exactly which screen the user is 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 - the chat list, the chat itself which is pushed into the navigation of the chat list controller and the settings screen shown modally. A user can be on one of these screens when they receive a push notification and tap on it. And then uncertainty begins, if it is in the chat list, you need to start the chat with this particular user, if it is already in the chat, then you need to switch it, and if it is already in the chat with this user, then do nothing and update it if the user is on the settings screen - it's probably necessary to close and do the previous steps. Or maybe not close and just show the chat modally over the settings? And if the settings in another tab, and not modal? These if/else begin to spread over the coordinators or go to another Mega-Coordinator in the form of a piece of spaghetti. Plus, there are either active iterations of the stack of view controllers and an attempt to determine where the user is at the moment, or an attempt to build an application that monitors its state, but this is not a very simple task, simply based on the nature of the stack of controllers.
  5. And the cherry on the cake are UIKit glitches . A trivial example: UITabBarController , which has a UINavigationController in the second tab with some other UIViewController . The user in the first tab causes some kind of event that requires switching the tab and UINavigationController another controller in his UINavigationController . This is all you need to do in that order. If the user has never opened the second tab before this, and the UINavigationController not called the viewDidLoad method, push will not work, leaving only a vague message in the console. That is, the coordinators cannot simply be made listeners of the events in this example; they must work in a certain sequence. So 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 rose-colored glasses, then it is a way of decomposing a part of logic, which is usually written inside the massive UIViewController , into another class. All attempts to make it something more than a kind of generating factory and introduce there another logic, do not end too well.


It is worth noting that there are libraries based on this pattern, which with one success or another allow to partially offset the listed disadvantages. I would point out XCoordinator and RxFlow .


What did we do?


Having played in the project that we got from the other team for support and development, with the coordinators and their simplified “great-grandmother” Routers in the VIPER architectural approach, we rolled back to an approach that proved itself well in the previous large project of our company. There is no name for this approach. It lies on the surface. When we had free time, it was isolated in a separate library RouteComposer, which completely replaced the coordinators and showed itself more flexible.


What is this approach? In order to rely on the stack (tree) I twist the controllers as it is. That would not create extra entities that need to be monitored. Do not save or track states.


Let's take a closer look at the UIKit entities and try to figure out what we have in the bottom line and what you can work with:


  1. The stack of view controllers is some kind of tree. There is a fundamental view controller, which has child view controllers. View controllers presented modally are a special case of child view controllers, as they are also tied to the generated view controller. This is all available out of the box.
  2. Entities twist controllers need to create. They all have different designers, they can be created using Xib files or Storyboards. They have different input parameters. But they are united by the fact that they need to be created. So here we are going to use the Factory pattern, which knows how to create the controller I need. Each factory is easy to cover with exhaustive unit tests and it is not dependent on others.
  3. We divide the controllers twist into 2 classes: 1. Just twist the controllers, 2. Container View Controller twists . Container view controllers differ from the usual ones in that they can contain child view controllers - also containers or simple ones. These view controllers are available out of the box: UINavigationController , UITabBarController and so on, but can be created by the user. If you abstract, you can find the following properties in all containers: 1. They have a list of all 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 I twist UIKit controllers. They just have different methods for this. But the tasks are only 3.
  4. To embed a factory-created view of a controller, use the method of the parent view controller UINavigationController.pushViewController(...) , UITabBarController.selectedViewController = ... , UIViewController.present(...) and so on. You may notice that you always need 2 views of the controller, one already on the stack, and one that needs to be built into the stack. Wrap it in a wrapper and call it an Action . Each action is easy to cover with exhaustive unit tests and each is independent of the others.
  5. From the above, it turns out that using prepared entities you can build a chain of configuration Factory -> Action -> Factory -> Action -> Factory and, having executed it, you can build a tree with view controllers of any complexity. You only need to specify the input point. Such input points are usually either the rootViewController belonging to the UIWindow or the current view controller, which is the most extreme branch of the tree. That is, such a configuration is correctly recorded as: Starting ViewController -> Action -> Factory -> ... -> Factory .
  6. In addition to the configuration, you will need an entity that knows how to run and build the provided configuration. Call it Router . He does not possess a state, he does not hold any references. It has one method to which the configuration is passed and it sequentially performs the configuration steps.
  7. Add responsibility to the router by adding Interceptors to the configuration chain. Interceptors are available in 3 types: 1. Launched before navigation. Let's remove in them the tasks of user authentication to the system and other asynchronous tasks. 2. Executed at the time of creation I twist the 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 executed by the router sequentially, performing in turn small, easily readable, atomic entities.
  8. There remains the last task that is not solved by the configuration - this is the state of the application at the moment. What if we do not need to build the entire configuration chain, but only a part of it, because the user has partially passed it? This question can always be answered unambiguously by a tree with a view of controllers. Because if part of the chain is already built, it is already in the tree. So, 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 a factory task, so another atomic entity is entered - a search engine (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 it 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 it is necessary to build the whole chain from the initial controller.
    image
  9. The configuration must be strictly typed. Therefore, each entity works with only one type of controller view; one type of data and configuration completely rests on the ability of swift to work with associatedtypes . We want to rely on the compiler, not on runtime. A developer may intentionally weaken typing, but not vice versa.

An example of this configuration:


 let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory()) .add(LoginInterceptor<UUID>()) // 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 is for us to provide configurations of the chains that the router will perform when the user presses the 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, that's the same thing. We do not need to think about the state of the application at the time of the start of navigation, we need to make sure that the configuration was written correctly initially, and we are sure that the router will build it one way or another.


The described approach is more complicated than a certain abstraction or pattern, but we have not yet encountered a task 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 mathematics.


The library, as well as the implementation of the router provided to it, does not use any tricks with objective runtime and fully follows all the concepts of Cocoa Touch, only helping to break up the composition process into steps and performs them in a predetermined sequence. Library tested with iOS versions 9 through 12.


More details can be found in previous articles:
Composition of UIViewControllers and navigation between them (and not only) / Habr
Configuration examples of UIViewControllers using RouteComposer / Habr


Thanks for attention. I am pleased to answer questions in the comments.


')

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


All Articles