Expérience de l'utilisation de «coordinateurs» dans un véritable projet «iOS»

Le monde de la programmation moderne est riche en tendances, et cela est doublement vrai pour le monde de la programmation des applications "iOS" . J'espère que je ne me trompe pas beaucoup en affirmant que l'un des modèles architecturaux les plus «à la mode» de ces dernières années est le «coordinateur». Notre équipe a donc réalisé il y a quelque temps une irrésistible envie d'essayer cette technique par elle-même. De plus, un très bon cas s'est présenté - un changement significatif dans la logique et une re-planification totale de la navigation dans l'application.

Le problème


Il arrive souvent que les contrôleurs commencent à en prendre trop: «donner des commandes» directement au UINavigationController , «communiquer» avec leurs contrôleurs «frères» (même les initialiser et les transmettre à la pile de navigation) - en général, il y a beaucoup à faire qu'ils ne devraient même pas soupçonner.

Un des moyens possibles pour éviter cela est précisément le «coordinateur». De plus, comme il s’est avéré, il est assez pratique de travailler et très flexible: le modèle est capable de gérer les événements de navigation des deux petits modules (représentant, peut-être, un seul écran) et de l’application entière (en lançant son «flux», relativement parlant, directement depuis UIApplicationDelegate ).

L'histoire


Martin Fowler, dans son livre Patterns of Enterprise Application Architecture, a appelé ce modèle Application Controller . Et son premier vulgarisateur dans l'environnement "iOS" est considéré comme Sorush Khanlu : tout a commencé avec son reportage sur "NSSpain" en 2015. Puis un article de revue est apparu sur son site Web , qui avait plusieurs suites (par exemple, ceci ).

Et puis de nombreuses critiques ont suivi (la requête «coordinateurs ios» donne des dizaines de résultats de qualité et de degré de détail différents), y compris même un guide sur Ray Wenderlich et un article de Paul Hudson sur son «Hacking with Swift» dans le cadre d'une série de documents sur la façon de se débarrasser du problème Contrôleur "massif".

Pour l'avenir, le sujet de discussion le plus notable est le problème du bouton de retour dans UINavigationController , dont le clic n'est pas traité par notre code, mais nous ne pouvons obtenir qu'un rappel .

En fait, pourquoi est-ce un problème? Les coordinateurs, comme tous les objets, pour exister en mémoire, ont besoin d'un autre objet pour les «posséder». En règle générale, lors de la construction d'un système de navigation à l'aide de coordinateurs, certains coordinateurs en génèrent d'autres et gardent un lien fort avec eux. En «quittant la zone de responsabilité» du coordinateur d'origine, le contrôle revient au coordinateur d'origine et la mémoire occupée par le coordinateur doit être libérée.

Sorush a sa propre vision pour résoudre ce problème , et note également quelques approches dignes . Mais nous y reviendrons.

Première approche


Avant de commencer à montrer le vrai code, je tiens à préciser que bien que les principes soient pleinement cohérents avec ceux que nous avons élaborés dans le projet, des extraits du code et des exemples de son utilisation sont simplifiés et réduits partout où cela n'interfère pas avec leur perception.

Lorsque nous avons commencé à expérimenter avec les coordinateurs de l'équipe, nous n'avions pas beaucoup de temps et de liberté d'action pour cela: il fallait tenir compte des principes existants et de l'appareil de navigation. La première option d'implémentation pour les coordinateurs était basée sur un «routeur» commun, qui est détenu et exploité par l' UINavigationController . Il sait comment faire avec les instances de l' UIViewController tout ce qui est nécessaire en matière de navigation - push / pop, present / disable plus les manipulations avec le contrôleur racine . Un exemple de l'interface d'un tel routeur:

 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) } 

Une implémentation spécifique est initialisée avec une instance de UINavigationController et ne contient rien de particulièrement délicat en soi. La seule limitation: vous ne pouvez pas passer d'autres instances de UINavigationController comme arguments aux méthodes d'interface (pour des raisons évidentes: UINavigationController ne peut pas contenir UINavigationController dans sa pile - il s'agit d'une restriction UIKit ).

Le coordinateur, comme tout objet, a besoin d'un propriétaire - un autre objet qui stockera un lien vers celui-ci. Un lien vers la racine peut être stocké par l'objet qui le génère, mais chaque coordinateur peut également générer d'autres coordinateurs. Par conséquent, une interface de base a été écrite pour fournir un mécanisme de gestion pour les coordinateurs générés:

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

L'un des avantages implicites des coordinateurs est l'encapsulation des connaissances sur des sous-classes spécifiques de l' UIViewController . Pour assurer l'interaction du routeur et des coordinateurs, nous avons introduit l'interface suivante:

 protocol Presentable { func presented() -> UIViewController } 

Ensuite, chaque coordinateur spécifique devrait hériter du Coordinator et implémenter l'interface Presentable , et l'interface du routeur devrait prendre la forme suivante:

 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) } 

(L'approche avec Presentable vous permet également d'utiliser des coordinateurs à l'intérieur de modules écrits pour interagir directement avec les instances de UIViewController , sans les soumettre (modules) à un traitement radical.)

Un bref exemple de tout cela en action:

 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) 

Prochaine approximation


Et puis, un jour, le moment est venu de modifier totalement la navigation et la liberté d'expression absolue! Le moment où rien ne nous a empêché d'essayer d'implémenter la navigation sur les coordinateurs en utilisant la méthode convoitée start() - une version qui captivait à l'origine par sa simplicité et sa concision.

Les fonctionnalités de Coordinator mentionnées ci-dessus ne seront évidemment pas superflues. Mais la même méthode doit être ajoutée à l'interface générale:

 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" n'offre pas la possibilité de déclarer des classes abstraites (car il est plus orienté vers une approche orientée protocole que vers une approche plus classique, orientée objet ), donc la méthode start() peut être laissée avec une implémentation ou une poussée vide il quelque chose comme fatalError(_:file:line:) (forçant à remplacer cette méthode par les héritiers). Personnellement, je préfère la première option.

Mais Swift a une excellente occasion d'ajouter des méthodes d'implémentation par défaut aux méthodes de protocole, donc la première pensée, bien sûr, n'était pas de déclarer une classe de base, mais de faire quelque chose comme ceci:

 extension Coordinator { func add(dependency coordinator: Coordinator) { // ... } func remove(dependency coordinator: Coordinator) { // ... } } 

Mais les extensions de protocole ne peuvent pas déclarer de champs stockés, et les implémentations de ces deux méthodes doivent évidemment être basées sur une propriété de type stockée privée.

La base de tout coordinateur particulier ressemblera à ceci:

 final class SomeCoordinator: BaseCoordinator { override func start() { // ... } } 

Toutes les dépendances nécessaires au fonctionnement du coordinateur peuvent être ajoutées à l'initialiseur. Comme cas typique, une instance de UINavigationController .

S'il s'agit du coordinateur racine dont la responsabilité est de mapper le UIViewController racine, le coordinateur peut, par exemple, accepter une nouvelle instance du UINavigationController avec une pile vide.

Lors du traitement des événements (plus à ce sujet plus tard), le coordinateur peut transmettre ce UINavigationController plus loin aux autres coordinateurs qu'il génère. Et ils peuvent également faire avec l'état actuel de la navigation ce dont ils ont besoin: «pousser», «présenter», et au moins remplacer la pile de navigation entière.

Améliorations possibles de l'interface


Comme il s'est avéré plus tard, tous les coordinateurs ne généreront pas d'autres coordinateurs, donc tous ne devraient pas dépendre d'une telle classe de base. Par conséquent, l'un des collègues de l'équipe connexe a suggéré de se débarrasser de l'héritage et d'introduire l'interface du gestionnaire de dépendances en tant que dépendance externe:

 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() { // ... } } 

Gestion des événements générés par l'utilisateur


Eh bien, le coordinateur a créé et lancé une nouvelle cartographie. Très probablement, l'utilisateur regarde l'écran et voit un certain ensemble d'éléments visuels avec lesquels il peut interagir: boutons, champs de texte, etc. Certains d'entre eux provoquent des événements de navigation, et ils doivent être contrôlés par le coordinateur qui a généré ce contrôleur. Pour résoudre ce problème, nous utilisons la délégation traditionnelle.

Supposons qu'il existe une sous-classe de UIViewController :

 final class SomeViewController: UIViewController { } 

Et le coordinateur qui l'ajoute à la pile:

 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) } } 

Nous déléguons le traitement des événements de contrôleur correspondants au même coordinateur. Ici, en fait, le schéma classique est utilisé:

 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() { // ... } } 

Manipulation du bouton de retour


Un autre bon examen du modèle architectural discuté a été publié par Paul Hudson sur son site Web «Hacking with Swift», on pourrait même dire un guide. Il contient également une explication simple et directe de l'une de leurs solutions possibles au problème du bouton de retour susmentionné: le coordinateur (si nécessaire) se déclare délégué de l'instance UINavigationController qui lui a été transmise et surveille l'événement qui nous intéresse.

Cette approche présente un petit inconvénient: seul le NSObject peut être un délégué UINavigationController .

Donc, il y a un coordinateur qui engendre un autre coordinateur. Cet autre, en appelant start() ajoute une sorte de UIViewController à la pile UINavigationController . En cliquant sur le bouton de retour sur le UINavigationBar tout ce que vous avez à faire est de faire savoir au coordinateur d'origine que le coordinateur généré a terminé son travail («flux»). Pour ce faire, nous avons introduit un autre outil de délégation: un délégué est alloué à chaque coordinateur généré, dont l'interface est implémentée par le coordinateur générateur:

 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) } } } 

Dans l'exemple ci-dessus, le MainCoordinator ne fait rien: il lance simplement le flux d'un autre coordinateur - dans la vraie vie, bien sûr, il est inutile. Dans notre application, le MainCoordinator reçoit des données de l'extérieur, selon lesquelles il détermine l'état dans lequel l'application est - autorisée, non autorisée, etc. - et quel écran doit être affiché. En fonction de cela, il lance un flux du coordinateur correspondant. Si le coordinateur d'origine a terminé son travail, le coordinateur principal reçoit un signal à ce sujet via le CoordinatorFlowListener et, par exemple, lance le flux d'un autre coordinateur.

Conclusion


Bien entendu, la solution habituelle présente un certain nombre d'inconvénients (comme toute solution à tout problème).

Oui, vous devez utiliser beaucoup de délégation, mais c'est simple et a une seule direction: du généré au généré (du contrôleur au coordinateur, du coordinateur généré au généré).

Oui, pour échapper aux fuites de mémoire, vous devez ajouter une méthode déléguée UINavigationController avec une implémentation presque identique à chaque coordinateur. (La première approche n'a pas cet inconvénient, mais partage plutôt plus généreusement ses connaissances internes sur la nomination d'un coordinateur spécifique.)

Mais le plus gros inconvénient de cette approche est que, dans la vraie vie, les coordinateurs, malheureusement, en savent un peu plus sur le monde qui les entoure que nous ne le souhaiterions. Plus précisément, ils devront ajouter des éléments logiques qui dépendent des conditions externes, dont le coordinateur n'a pas directement connaissance. Fondamentalement, c'est en fait ce qui se passe lorsque la méthode start() est onFlowFinished(coordinator:) ou que le onFlowFinished(coordinator:) est onFlowFinished(coordinator:) . Et tout peut arriver à ces endroits, et ce sera toujours un comportement "codé en dur": ajouter un contrôleur à la pile, remplacer la pile, retourner au contrôleur racine - peu importe. Et tout cela ne dépend pas des compétences du contrôleur actuel, mais des conditions externes.

Néanmoins, le code est «joli» et concis, il est vraiment agréable de travailler avec lui et la navigation dans le code est beaucoup plus facile. Il nous a semblé qu'avec les lacunes mentionnées, en étant conscients, il est tout à fait possible d'exister.
Merci d'avoir lu cet endroit! J'espère qu'ils ont appris quelque chose d'utile pour eux-mêmes. Et si vous voulez tout à coup "plus que moi", voici un lien vers mon Twitter .

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


All Articles