Erfahrung mit der Verwendung von "Koordinatoren" in einem echten "iOS" -Projekt

Die Welt der modernen Programmierung ist reich an Trends, und dies gilt in zweifacher Hinsicht für die Programmierwelt der "iOS" -Anwendungen. Ich hoffe, ich irre mich nicht sehr, wenn ich behaupte, dass eines der „modischsten“ Architekturmuster der letzten Jahre der „Koordinator“ ist. So hat unser Team vor einiger Zeit den unwiderstehlichen Wunsch verwirklicht, diese Technik an sich selbst auszuprobieren. Darüber hinaus ergab sich ein sehr guter Fall - eine signifikante Änderung der Logik und eine vollständige Neuplanung der Navigation in der Anwendung.

Das Problem


Es kommt häufig vor, dass die Controller zu viel übernehmen: Befehle direkt an den UINavigationController , mit den Controllern ihrer Brüder kommunizieren (sogar initialisieren und an den Navigationsstapel übergeben) - im Allgemeinen gibt es viel zu tun als sie nicht einmal ahnen sollten.

Eine Möglichkeit, dies zu vermeiden, ist genau der „Koordinator“. Darüber hinaus ist es, wie sich herausstellte, sehr bequem zu arbeiten und sehr flexibel: Die Vorlage kann Navigationsereignisse sowohl kleiner Module (die möglicherweise nur einen einzigen Bildschirm darstellen) als auch der gesamten Anwendung verwalten (relativ gesehen direkt von dort aus einen eigenen „Flow“ starten) UIApplicationDelegate ).

Die Geschichte


Martin Fowler nannte dieses Muster in seinem Buch Patterns of Enterprise Application Architecture Application Controller . Und sein erster Popularisierer in der "iOS" -Umgebung heißt Sorush Khanlu : Alles begann mit seinem Bericht über "NSSpain" im Jahr 2015. Dann erschien auf seiner Website ein Übersichtsartikel , der mehrere Fortsetzungen hatte (zum Beispiel diese ).

Und dann folgten viele Überprüfungen (die Abfrage „ios-Koordinatoren“ liefert Dutzende von Ergebnissen unterschiedlicher Qualität und Detailgenauigkeit), darunter sogar einen Leitfaden zu Ray Wenderlich und einen Artikel von Paul Hudson über sein „Hacking with Swift“ als Teil einer Reihe von Materialien zur Behebung des Problems "Massiver" Controller.

Mit Blick auf die UINavigationController ist das bemerkenswerteste Diskussionsthema das Problem der Zurück-Schaltfläche im UINavigationController , deren Klicken nicht von unserem Code verarbeitet wird, sondern nur einen Rückruf erhalten kann .

Warum ist das eigentlich ein Problem? Koordinatoren benötigen wie alle Objekte, um im Speicher zu existieren, ein anderes Objekt, um sie zu „besitzen“. In der Regel generieren einige Koordinatoren beim Aufbau eines Navigationssystems mithilfe von Koordinatoren andere und halten eine starke Verbindung zu ihnen. Beim „Verlassen der Verantwortungszone“ des Ursprungskoordinators kehrt die Steuerung zum Ursprungskoordinator zurück, und der vom Urheber belegte Speicher muss freigegeben werden.

Sorush hat seine eigene Vision zur Lösung dieses Problems und stellt auch einige würdige Ansätze fest . Aber wir werden darauf zurückkommen.

Erster Ansatz


Bevor ich anfange, den realen Code zu zeigen, möchte ich klarstellen, dass, obwohl die Prinzipien vollständig mit denen übereinstimmen, die wir im Projekt entwickelt haben, Auszüge aus dem Code und Beispiele seiner Verwendung vereinfacht und reduziert werden, wo immer sie ihre Wahrnehmung nicht beeinträchtigen.

Als wir anfingen, mit Koordinatoren in einem Team zu experimentieren, hatten wir dafür nicht viel Zeit und Handlungsspielraum: Es war notwendig, mit den vorhandenen Prinzipien und dem Navigationsgerät zu rechnen. Die erste Implementierungsoption für Koordinatoren basierte auf einem gemeinsamen „Router“, der dem UINavigationController gehört und von diesem betrieben wird. Er weiß, wie man mit Instanzen des UIViewController alles macht, was für die Navigation benötigt wird - Push / Pop, Present / Dism plus Manipulationen mit dem Root-Controller . Ein Beispiel für die Schnittstelle eines solchen Routers:

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

Eine bestimmte Implementierung wird mit einer Instanz von UINavigationController initialisiert und enthält an sich nichts besonders UINavigationController . Einzige Einschränkung: Sie können keine anderen Instanzen des UINavigationController als Argumente an die Schnittstellenmethoden übergeben (aus offensichtlichen Gründen: Der UINavigationController kann den UINavigationController in seinem Stapel enthalten - dies ist eine UIKit Einschränkung).

Der Koordinator benötigt wie jedes Objekt einen Eigentümer - ein anderes Objekt, das einen Link dazu speichert. Eine Verknüpfung zur Wurzel kann von dem Objekt gespeichert werden, das sie generiert, aber jeder Koordinator kann auch andere Koordinatoren generieren. Daher wurde eine Basisschnittstelle geschrieben, um einen Verwaltungsmechanismus für die generierten Koordinatoren bereitzustellen:

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

Einer der impliziten Vorteile von Koordinatoren ist die Kapselung von Wissen über bestimmte Unterklassen des UIViewController . Um die Interaktion zwischen Router und Koordinatoren sicherzustellen, haben wir die folgende Schnittstelle eingeführt:

 protocol Presentable { func presented() -> UIViewController } 

Dann sollte jeder spezifische Koordinator von Coordinator erben und die Presentable Schnittstelle implementieren, und die Router-Schnittstelle sollte die folgende Form annehmen:

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

(Der Ansatz mit Presentable ermöglicht es Ihnen auch, Koordinatoren in Modulen zu verwenden, die so geschrieben sind, dass sie direkt mit Instanzen des UIViewController , ohne sie (Module) einer radikalen Verarbeitung zu UIViewController .)

Ein kurzes Beispiel dafür:

 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) 

Nächste Annäherung


Und dann kam eines Tages der Moment für eine völlige Veränderung der Navigation und der absoluten Meinungsfreiheit! Der Moment, in dem uns nichts daran hinderte, die Navigation auf den Koordinatoren mithilfe der begehrten start() -Methode zu implementieren - eine Version, die ursprünglich durch ihre Einfachheit und Prägnanz faszinierte.

Die oben genannten Coordinator sind offensichtlich nicht überflüssig. Die gleiche Methode muss jedoch zur allgemeinen Benutzeroberfläche hinzugefügt werden:

 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" bietet nicht die Möglichkeit, abstrakte Klassen zu deklarieren (da es sich mehr auf einen protokollorientierten Ansatz als auf einen klassischeren, objektorientierten Ansatz konzentriert), sodass die start() -Methode mit einer leeren Implementierung oder einem leeren Schub belassen werden kann es gibt so etwas wie fatalError(_:file:line:) (zwingt, diese Methode mit Erben zu überschreiben). Persönlich bevorzuge ich die erste Option.

Swift hat jedoch die großartige Möglichkeit, Protokollmethoden Standardimplementierungsmethoden hinzuzufügen. Der erste Gedanke bestand natürlich nicht darin, eine Basisklasse zu deklarieren, sondern Folgendes zu tun:

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

Protokollerweiterungen können jedoch keine gespeicherten Felder deklarieren, und Implementierungen dieser beiden Methoden sollten offensichtlich auf einer privaten Eigenschaft vom gespeicherten Typ basieren.

Die Basis eines bestimmten Koordinators sieht folgendermaßen aus:

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

Alle Abhängigkeiten, die für die Funktion des Koordinators erforderlich sind, können dem Initialisierer hinzugefügt werden. In der UINavigationController eine Instanz von UINavigationController .

Wenn dies der Root-Koordinator ist, dessen Aufgabe es ist, den Root- UIViewController , kann der Koordinator beispielsweise eine neue Instanz des UINavigationController mit einem leeren Stapel akzeptieren.

Bei der Verarbeitung von Ereignissen (dazu später mehr) kann der Koordinator diesen UINavigationController an andere von ihm generierte Koordinatoren weitergeben. Und sie können mit dem aktuellen Navigationsstatus auch das tun, was sie benötigen: "Push", "Present" und zumindest den gesamten Navigationsstapel ersetzen.

Mögliche Verbesserungen der Benutzeroberfläche


Wie sich später herausstellte, wird nicht jeder Koordinator andere Koordinatoren generieren, daher sollten nicht alle von einer solchen Basisklasse abhängen. Daher schlug einer der Kollegen aus dem zugehörigen Team vor, die Vererbung zu beseitigen und die Abhängigkeitsmanagerschnittstelle als externe Abhängigkeit einzuführen:

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

Behandlung von benutzergenerierten Ereignissen


Nun, der Koordinator hat ein neues Mapping erstellt und irgendwie initiiert. Höchstwahrscheinlich schaut der Benutzer auf den Bildschirm und sieht eine Reihe von visuellen Elementen, mit denen er interagieren kann: Schaltflächen, Textfelder usw. Einige von ihnen lösen Navigationsereignisse aus und müssen vom Koordinator gesteuert werden, der diesen Controller generiert hat. Um dieses Problem zu lösen, verwenden wir die traditionelle Delegation .

Angenommen, es gibt eine Unterklasse von UIViewController :

 final class SomeViewController: UIViewController { } 

Und der Koordinator, der es dem Stapel hinzufügt:

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

Wir delegieren die Verarbeitung der entsprechenden Controller-Ereignisse an denselben Koordinator. Hier wird tatsächlich das klassische Schema verwendet:

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

Umgang mit der Return-Taste


Eine weitere gute Rezension der diskutierten Architekturvorlage wurde von Paul Hudson auf seiner Website „Hacking with Swift“ veröffentlicht, man könnte sogar einen Leitfaden sagen. Es enthält auch eine einfache, unkomplizierte Erklärung einer ihrer möglichen Lösungen für das oben erwähnte Problem mit der Schaltfläche "Zurück": Der Koordinator erklärt sich (falls erforderlich) zum Delegierten der an ihn übergebenen UINavigationController Instanz und überwacht das für uns interessante Ereignis.

Dieser Ansatz hat einen kleinen Nachteil: Nur der NSObject kann ein UINavigationController Delegat sein.

Es gibt also einen Koordinator, der einen anderen Koordinator hervorbringt. Durch Aufrufen von start() dem UINavigationController Stack eine Art UIViewController UINavigationController . Durch Drücken der Zurück-Taste in der UINavigationBar Sie dem ursprünglichen Koordinator UINavigationBar dass der generierte Koordinator seine Arbeit beendet hat („Ablauf“). Zu diesem Zweck haben wir ein weiteres Delegierungstool eingeführt: Jedem generierten Koordinator wird ein Delegat zugewiesen, dessen Schnittstelle vom generierenden Koordinator implementiert wird:

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

Im obigen MainCoordinator tut der MainCoordinator nichts: Er startet einfach den Fluss eines anderen Koordinators - im wirklichen Leben ist er natürlich nutzlos. In unserer Anwendung empfängt der MainCoordinator Daten von außen, anhand derer er bestimmt, in welchem ​​Zustand sich die Anwendung befindet - autorisiert, nicht autorisiert usw. - und welcher Bildschirm angezeigt werden muss. Abhängig davon wird ein Fluss des entsprechenden Koordinators gestartet. Wenn der ursprüngliche Koordinator seine Arbeit beendet hat, erhält der Hauptkoordinator über den CoordinatorFlowListener ein Signal dazu und startet beispielsweise den Fluss eines anderen Koordinators.

Fazit


Die gewohnte Lösung hat natürlich eine Reihe von Nachteilen (wie jede Lösung für jedes Problem).

Ja, Sie müssen viel Delegierung verwenden, aber es ist einfach und hat eine einzige Richtung: vom generierten zum generierten (vom Controller zum Koordinator, vom generierten zum generierten Koordinator).

Ja, um Speicherlecks zu UINavigationController , müssen Sie jedem Koordinator eine UINavigationController mit nahezu identischer Implementierung hinzufügen. (Der erste Ansatz hat diesen Nachteil nicht, sondern teilt großzügiger sein internes Wissen über die Ernennung eines bestimmten Koordinators.)

Der größte Nachteil dieses Ansatzes ist jedoch, dass die Koordinatoren im wirklichen Leben leider etwas mehr über die Welt um sie herum wissen, als wir möchten. Genauer gesagt müssen sie logische Elemente hinzufügen, die von externen Bedingungen abhängen, die dem Koordinator nicht direkt bekannt sind. Grundsätzlich ist dies tatsächlich der onFlowFinished(coordinator:) , wenn die Methode start() onFlowFinished(coordinator:) oder der Rückruf onFlowFinished(coordinator:) . An diesen Stellen kann alles passieren, und es wird immer ein „fest codiertes“ Verhalten sein: Hinzufügen eines Controllers zum Stapel, Ersetzen des Stapels, Zurückkehren zum Root-Controller - was auch immer. Und das alles hängt nicht von den Kompetenzen des aktuellen Controllers ab, sondern von den äußeren Bedingungen.

Trotzdem ist der Code „hübsch“ und prägnant, es ist wirklich schön, damit zu arbeiten, und die Navigation durch den Code ist viel einfacher. Es schien uns, dass es mit den erwähnten Mängeln durchaus möglich ist, zu existieren, wenn man sich ihrer bewusst ist.
Vielen Dank für das Lesen zu diesem Ort! Ich hoffe, sie haben etwas Nützliches für sich gelernt. Und wenn Sie plötzlich "mehr als ich" wollen, dann ist hier ein Link zu meinem Twitter .

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


All Articles