Problèmes de modèle de coordinateur et qu'est-ce que RouteComposer a à voir avec cela

Je continue la série d'articles sur la bibliothèque RouteComposer que nous utilisons, et aujourd'hui je veux parler du modèle de coordinateur. J'ai été invité à écrire cet article par une discussion sur l'un des articles sur le modèle, le coordinateur ici sur Habré.


Le modèle de coordinateur, introduit il n'y a pas si longtemps, gagne de plus en plus en popularité parmi les développeurs iOS et, en général, il est clair pourquoi. Parce que les fonds de la boîte que fournit UIKit ne sont pas un gâchis assez polyvalent.


image


J'ai déjà soulevé la question de la fragmentation de la façon dont je compose la vue des contrôleurs sur la pile, et pour éviter les répétitions, vous pouvez simplement lire à ce sujet ici .


Soyons honnêtes. À un moment donné, Epole a réalisé qu'en plaçant les contrôleurs dans le centre de développement d'applications, elle n'offrait aucun moyen sensé de créer ou de transférer des données entre eux, et, après avoir confié la solution à ce problème aux développeurs, elle a été complétée automatiquement à partir de Xcode, et peut-être aux développeurs UISearchConnroller, à un moment donné nous a présenté des storyboards et des enchaînements. Epolus s'est alors rendu compte qu'elle avait écrit des applications composées de 2 écrans uniquement, et dans l'itération suivante, elle a offert la possibilité de diviser les storyboards en plusieurs composants, car Xcode a commencé à planter lorsque le storyboard a atteint une certaine taille. Les séquences ont changé avec ce concept, en plusieurs itérations qui ne sont pas très compatibles les unes avec les autres. Leur soutien est étroitement UIViewController classe UIViewController massive, et, finalement, nous avons obtenu ce que nous avons obtenu. Le voici:


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

Le nombre de tycastcasts forcés dans ce bloc de code est incroyable, tout comme les constantes de chaîne dans les storyboards eux-mêmes, pour suivre quel Xcode n'offre aucun moyen. Et le moindre désir de changer quelque chose dans le processus de navigation vous permettra de compiler le projet sans aucun effort et il se plantera avec un coup dans l'exécution sans le moindre avertissement de Xcode. Voici un tel WYSIWYG à la fin, il s'est avéré. Ce que vous voyez, c'est ce que vous obtenez.


Vous pouvez argumenter longtemps sur les charmes de ces flèches grises dans des storyboards censés montrer à quelqu'un les connexions entre les écrans, mais, comme ma pratique l'a montré, j'ai intentionnellement interviewé plusieurs développeurs familiers de différentes entreprises, dès que le projet a dépassé 5-6 écrans, les gens ont essayé trouver une solution plus fiable et a finalement commencé à garder la structure de la pile de contrôleurs de vue dans ma tête. Et si la prise en charge de l'iPad et d'autres modèles de navigation ou la prise en charge des push était ajoutée, tout était triste là-bas.


Depuis lors, plusieurs tentatives ont été faites pour résoudre ce problème, dont certaines ont abouti à des cadres distincts, d'autres à des modèles architecturaux distincts, car la création de contrôleurs de vue à l'intérieur du contrôleur de vue a rendu ce morceau de code massif et maladroit encore plus.


Revenons au modèle Coordinateur. Pour des raisons évidentes, vous ne trouverez pas sa description sur Wikipedia car ce n'est pas un modèle de programmation / conception standard. Il s'agit plutôt d'une sorte d'abstraction, qui suggère de cacher sous le capot tout ce code «laid» pour créer et insérer une nouvelle torsion de contrôleur sur la pile, stocker des références au conteneur de contrôleurs et pousser des données entre les contrôleurs. L'article le plus approprié décrivant ce processus que j'appellerais un article sur raywenderlich.com . Il commence à devenir populaire après la conférence NSSpain 2015, lorsque le grand public en a été informé. Plus en détail, ce qui a été dit peut être trouvé ici et ici .


Je décrirai brièvement en quoi il consiste avant de poursuivre.


Le modèle de coordinateur dans toutes les interprétations correspond approximativement à cette image:



Autrement dit, le coordinateur est un protocole


 protocol Coordinator { func start() } 

Et tout le code laid est censé être caché dans la fonction de start . En outre, le coordinateur peut avoir des liens vers des coordinateurs enfants, c'est-à-dire qu'ils ont une certaine capacité de composition et, par exemple, vous pouvez remplacer une implémentation par une autre. Autrement dit, cela semble assez élégant.


Cependant, les inanités commencent assez tôt:


  1. Certaines implémentations proposent de transformer le coordinateur d'un modèle de génération en quelque chose de plus raisonnable, en regardant la pile de contrôleurs et en faire un délégué du conteneur , par exemple, UINavigationController , pour gérer la pression du bouton Retour ou balayer vers l'arrière et supprimer le coordinateur enfant. Pour des raisons naturelles, un seul objet peut être un délégué, ce qui limite le contrôle du conteneur lui-même et conduit au fait que cette logique incombe soit au coordinateur, soit crée la nécessité de déléguer cette logique à quelqu'un plus bas dans la liste.
  2. Souvent, la logique de création du contrôleur suivant dépend de la logique métier . Par exemple, pour passer à l'écran suivant, l'utilisateur doit être connecté au système. De toute évidence, il s'agit d'un processus asynchrone, qui comprend la génération d'un écran intermédiaire avec le formulaire de connexion, le processus de connexion lui-même peut se terminer avec succès ou non. Pour éviter de transformer le coordinateur en un coordinateur massif (similaire au Massive View Controller), nous avons besoin d'une décomposition. Autrement dit, vous devez créer un coordinateur coordinateur.
  3. Un autre problème rencontré par les coordinateurs est qu'ils sont essentiellement des wrappers pour les contrôleurs de vue de conteneur tels que UINavigationController , UITabBarController etc. Et quelqu'un devrait fournir des liens vers ces contrôleurs . Si avec les coordinateurs enfants, tout est encore moins clair, alors avec les coordinateurs initiaux de la chaîne, tout n'est pas si simple. De plus, lors du changement de navigation, par exemple pour le test A / B, le refactoring et l'adaptation de ces coordinateurs entraînent un mal de tête séparé. Surtout si le type de conteneur change.
  4. Tout cela devient encore plus compliqué lorsque l'application commence à prendre en charge des événements externes qui génèrent des contrôleurs de vue. Tels que les notifications push ou les liens universels (l'utilisateur clique sur le lien dans la lettre et continue dans l'écran d'application correspondant). Ici, d'autres incertitudes surgissent pour lesquelles le modèle de coordinateur n'a pas de réponse exacte. Vous devez savoir exactement sur quel écran l'utilisateur se trouve actuellement afin de lui montrer le prochain écran demandé par un événement externe.
    L'exemple le plus simple est une application de chat composée de 3 écrans - une liste de chat, le chat lui-même qui est poussé dans la navigation du contrôleur de liste de chat et l'écran des paramètres affiché de façon modale. L'utilisateur peut être sur l'un de ces écrans lorsqu'il reçoit une notification push et appuie dessus. Et ici, l'incertitude commence, s'il est dans la liste de discussion, vous devez démarrer une discussion avec cet utilisateur spécifique, s'il est déjà dans la discussion, vous devez le changer, et s'il est déjà dans la discussion avec cet utilisateur, alors ne rien faire et mettre à jour, si l'utilisateur est sur écran des paramètres - apparemment, vous devez fermer et suivre les étapes précédentes. Ou peut-être pas fermer et simplement afficher le chat de manière modale sur les paramètres? Et si les paramètres sont dans un autre onglet, et non modaux? Celles if/else ci if/else commencent soit réparties sur les coordinateurs ou vont à un autre méga-coordinateur sous la forme d'un morceau de spaghetti. De plus, ce sont soit des itérations actives sur la pile de vues des contrôleurs et une tentative de déterminer où se trouve l'utilisateur en ce moment, soit une tentative de construction d'une sorte d'application qui surveille leur statut, mais ce n'est pas une tâche facile, basée uniquement sur la nature de la pile de contrôleurs de vue elle-même.
  5. Et la cerise sur le gâteau sont les pépins UIKit . Un exemple trivial: un UITabBarController avec un UINavigationController dans le deuxième onglet avec un autre UIViewController . L'utilisateur dans le premier onglet provoque un certain événement qui nécessite de basculer l'onglet et de UINavigationController un autre contrôleur de vue dans son UINavigationController . Tout cela doit être fait dans une telle séquence. Si l'utilisateur n'a jamais ouvert le deuxième onglet avant cela et que UINavigationController pas UINavigationController appelé sur viewDidLoad la méthode push ne fonctionnera pas en ne laissant qu'un message indistinct dans la console. Autrement dit, les coordinateurs ne peuvent pas simplement devenir des auditeurs d'événements dans cet exemple, ils doivent travailler dans une certaine séquence. Ils doivent donc se connaître. Et cela contredit déjà la première déclaration du modèle de coordinateur, selon laquelle les coordinateurs ne savent rien des coordinateurs générateurs et ne sont connectés qu'avec les enfants. Et limite également leur interchangeabilité.

Cette liste peut être prolongée, mais en général, il est clair que le modèle de coordinateur est une solution plutôt limitée et peu évolutive. Si vous le regardez sans lunettes roses, c'est un moyen de décomposer une partie de la logique, qui est généralement écrite à l'intérieur de massifs UIViewController , dans une autre classe. Toutes les tentatives pour en faire plus qu'une simple usine générative et y introduire d'autres logiques ne finissent pas bien.


Il convient de noter qu'il existe des bibliothèques basées sur ce modèle, qui, d'une manière ou d'une autre, permettent d'atténuer partiellement les inconvénients ci-dessus. Je mentionnerais XCoordinator et RxFlow .


Qu'avons-nous fait?


Après avoir joué dans le projet que nous avons obtenu d'une autre équipe pour le support et le développement, avec les coordinateurs et leur routeur "arrière-grand-mère" simplifié dans l'approche architecturale VIPER , nous sommes revenus à l'approche qui fonctionnait bien dans le grand projet précédent de notre entreprise. Cette approche n'a pas de nom. Il se trouve en surface. Lorsque nous avions du temps libre, il a été compilé dans une bibliothèque RouteComposer distincte qui a complètement remplacé les coordinateurs et s'est avérée plus flexible.


Quelle est cette approche? En cela, pour me fier à la pile (arborescence), je tord les contrôleurs tels quels. Afin de ne pas créer d'entités inutiles qui doivent être surveillées. N'enregistrez pas ou ne suivez pas les conditions.


Examinons de plus près les entités UIKit et essayons de comprendre ce que nous avons en fin de compte et avec quoi nous pouvons travailler:


  1. La pile du contrôleur est un arbre. Il existe un contrôleur de vue racine qui a des contrôleurs de vue enfant. Les contrôleurs de vue présentés de façon modale sont un cas particulier des contrôleurs de vue enfants, car ils ont également une liaison avec le contrôleur de vue généré. Tout est prêt à l'emploi.
  2. J'ai besoin de créer des entités de contrôleurs. Ils ont tous des constructeurs différents; ils peuvent être créés à l'aide de fichiers Xib ou de storyboards. Ils ont des paramètres d'entrée différents. Mais ils sont unis en ce qu'ils doivent être créés. Donc, ici, nous pouvons utiliser le modèle Factory , qui sait comment créer le contrôleur de vue souhaité. Chaque usine est facile à couvrir avec des tests unitaires complets et elle est indépendante des autres.
  3. Nous divisons les contrôleurs de vue en 2 classes: 1. Il suffit de visualiser les contrôleurs, 2. Les contrôleurs de vue de conteneur (Container View Controller) . Les contrôleurs de vue de conteneur diffèrent des contrôleurs ordinaires en ce qu'ils peuvent contenir des contrôleurs de vue enfant - également des conteneurs ou des contrôleurs simples. De tels contrôleurs de vue sont disponibles UINavigationController : UINavigationController , UITabBarController et ainsi de suite, mais peuvent également être créés par l'utilisateur. Si nous l'ignorons, nous pouvons trouver les propriétés suivantes dans tous les conteneurs: 1. Ils ont une liste de tous les contrôleurs qu'ils contiennent. 2. Un ou plusieurs contrôleurs sont actuellement visibles. 3. On peut leur demander de rendre visible l'un de ces contrôleurs. C'est tout ce que les contrôleurs UIKit peuvent faire . Ils ont juste différentes méthodes pour cela. Mais il n'y a que 3 tâches.
  4. Pour incorporer un contrôleur de vue créé en usine, la méthode de vue parent du contrôleur est UINavigationController.pushViewController(...) , UITabBarController.selectedViewController = ... , UIViewController.present(...) etc. Vous remarquerez peut-être que 2 contrôleurs de vue sont toujours requis, un déjà sur la pile et un qui doit être intégré à la pile. Enveloppez-le dans un wrapper et appelez-le Action (Action) . Chaque action est facile à couvrir avec des tests unitaires complets et chacune est indépendante des autres.
  5. D'après ce qui précède, il s'avère qu'en utilisant des entités préparées, vous pouvez créer une chaîne de configuration Factory -> Action -> Factory -> Action -> Factory, et après l'avoir terminée, vous pouvez créer une arborescence de vues de contrôleurs de toute complexité. Il vous suffit de spécifier le point d'entrée. Ces points d'entrée sont généralement le rootViewController appartenant à l' UIWindow ou le contrôleur de vue actuel, qui est la branche la plus extrême de l'arborescence. Autrement dit, une telle configuration est correctement écrite comme: Démarrage de ViewController -> Action -> Factory -> ... -> Factory .
  6. En plus de la configuration, vous aurez besoin d'une entité qui sait comment démarrer et créer la configuration fournie. Nous l'appellerons routeur . Il n'a pas d'état, il ne contient aucun lien. Il a une méthode à laquelle la configuration est passée et il effectue séquentiellement les étapes de configuration.
  7. Ajoutez des responsabilités au routeur en ajoutant des classes Interceptors à la chaîne de configuration. Les intercepteurs sont possibles de 3 types: 1. Lancé avant de démarrer la navigation. Nous supprimons les tâches d'authentification des utilisateurs dans le système et les autres tâches asynchrones en elles. 2. Exécutez au moment de la création du contrôleur de vue pour définir les valeurs. 3. Effectué après navigation et exécution de diverses tâches analytiques. Chaque entité est facilement couverte par des tests unitaires et ne sait pas comment elle sera utilisée dans la configuration. Elle n'a qu'une seule responsabilité et elle la remplit. Autrement dit, la configuration pour une navigation complexe peut ressembler à [Tâche de pré-navigation ...] -> Démarrage de ViewController -> Action -> (Factory + [ContextTask ...]) -> ... -> (Factory + [ContextTask ...]) -> [Post NavigationTask ...] . C'est-à-dire que toutes les tâches seront exécutées par le routeur de manière séquentielle, effectuant à leur tour de petites entités atomiques facilement lisibles.
  8. La dernière tâche qui ne peut pas être résolue par la configuration reste - c'est l'état de l'application pour le moment. Que se passe-t-il si nous devons construire non pas toute la chaîne de configuration, mais seulement une partie, car l'utilisateur l'a partiellement dépassée? Cette question peut toujours être répondue sans ambiguïté par l'arborescence des contrôleurs de vue. Parce que si une partie de la chaîne est déjà construite, elle est déjà dans l'arborescence. Cela signifie que si chaque usine de la chaîne peut répondre à la question de savoir si elle est construite ou non, le routeur sera en mesure de comprendre quelle partie de la chaîne doit être terminée. Bien sûr, ce n'est pas la tâche de l'usine, donc une autre entité atomique est introduite - le Finder, et toute configuration ressemble à ceci: [Tâche de pré-navigation ...] -> Démarrage de ViewController -> Action -> (Finder / Factory + [ContextTask ...]) -> ... -> (Finder / Factory + [ContextTask ...]) -> [Post NavigationTask ...] . Si le routeur commence à le lire depuis la fin, l'un des Finders lui dira qu'il est déjà construit et le routeur commencera à partir de ce point à reconstituer la chaîne. Si aucun d'entre eux ne se trouve dans l'arborescence, vous devez créer la chaîne entière à partir du contrôleur initial.
    image
  9. La configuration doit être fortement typée. Par conséquent, chaque entité fonctionne avec un seul type de vue de contrôleur; un type de données et de configuration repose entièrement sur la capacité de swift à travailler avec les types associés . Nous voulons compter sur le compilateur, pas sur le runtime. Un développeur peut intentionnellement affaiblir la frappe, mais pas l'inverse.

Un exemple d'une telle 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() 

Les éléments décrits ci-dessus couvrent l'ensemble de la bibliothèque et décrivent l'approche. Il ne nous reste plus qu'à fournir les configurations de chaîne que le routeur exécutera lorsque l'utilisateur cliquera sur un bouton ou qu'un événement externe se produira. S'il s'agit de différents types d'appareils, par exemple iPhone ou iPad, nous fournirons différentes configurations de transition en utilisant le polymorphisme. Si nous avons des tests A / B, la même chose. Nous n'avons pas besoin de penser à l'état de l'application au moment de démarrer la navigation, nous devons nous assurer que la configuration est écrite correctement au départ, et nous sommes sûrs que le routeur la construira d'une manière ou d'une autre.


L'approche décrite est plus compliquée qu'une certaine abstraction ou un certain modèle, mais nous n'avons pas encore rencontré le problème où cela ne serait pas suffisant. Bien sûr, RouteComposer nécessite une étude et une compréhension de son fonctionnement. Cependant, c'est beaucoup plus facile que d'apprendre les bases d'AutoLayout ou de RunLoop. Pas de mathématiques supérieures.


La bibliothèque, ainsi que l'implémentation du routeur qui lui est fourni, n'utilise aucune astuce objective avec le runtime et suit entièrement tous les concepts de Cocoa Touch, aidant seulement à diviser le processus de composition en étapes et à les exécuter dans la séquence donnée. La bibliothèque est testée avec les versions iOS 9 à 12.


Plus de détails peuvent être trouvés dans les articles précédents:
Composition des UIViewControllers et navigation entre eux (et pas seulement) / magazine geek
Exemples de configuration d'UIViewControllers utilisant RouteComposer / Geek Magazine


Merci de votre attention. Je me ferai un plaisir de répondre aux questions dans les commentaires.

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


All Articles