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) {
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)
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) {
"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) {
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) {
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)
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 .