Routing für iOS: Universelle Navigation ohne Umschreiben der Anwendung

In jeder Anwendung, die aus mehr als einem Bildschirm besteht, muss die Navigation zwischen seinen Komponenten implementiert werden. Dies sollte anscheinend kein Problem sein, da es in UIKit recht komfortable Containerkomponenten wie UINavigationController und UITabBarController sowie flexible modale Methoden zur Bildschirmanzeige gibt: Verwenden Sie einfach die richtige Navigation zum richtigen Zeitpunkt.

Sobald die Anwendung jedoch über eine Push-Benachrichtigung oder einen Link auf einen Bildschirm wechselt, wird alles etwas komplizierter. Ab sofort gibt es viele Fragen:

  • Was tun mit dem View-Controller, der jetzt auf dem Bildschirm angezeigt wird?
  • Wie wechsle ich den Kontext (z. B. aktiver Tab in UITabBarController)?
  • Hat der aktuelle Navigationsstapel den richtigen Bildschirm?
  • Wann sollte die Navigation ignoriert werden?




Bei der iOS-Entwicklung sind wir bei Badoo auf all diese Probleme gestoßen. Aus diesem Grund haben wir unsere Lösungsmethoden in eine Bibliothek von Komponenten für die Navigation formalisiert, die wir in allen neuen Produkten verwenden. In diesem Artikel werde ich genauer auf unseren Ansatz eingehen. Ein Beispiel für die Anwendung der beschriebenen Praktiken ist in einem kleinen Demo-Projekt zu sehen .

Unser Problem


Häufig werden Navigationsprobleme durch Hinzufügen einer globalen Komponente gelöst, die die Struktur der Bildschirme in der Anwendung kennt und entscheidet, was in diesem oder jenem Fall zu tun ist. Die Struktur der Bildschirme gibt Auskunft über das Vorhandensein eines Containers in der aktuellen Hierarchie der Controller und Abschnitte der Anwendung.

Badoo hatte eine ähnliche Komponente. Ähnlich funktionierte es mit der ziemlich alten Bibliothek von Facebook, die jetzt nicht mehr in ihrem öffentlichen Repository zu finden ist. Die Navigation basierte auf URLs, die mit Anwendungsbildschirmen verknüpft waren. Grundsätzlich war die gesamte Logik in einer Klasse enthalten, die an das Vorhandensein einer Tab-Leiste und an einige andere badoo-spezifische Funktionen gebunden war. Die Komplexität und Konnektivität dieser Komponente war so hoch, dass die Lösung von Aufgaben, die eine Änderung der Navigationslogik erforderten, ein Vielfaches länger dauern konnte als geplant. Die Testbarkeit dieser Klasse warf auch große Fragen auf.

Diese Komponente wurde erstellt, als wir nur eine Anwendung hatten. Wir konnten uns nicht vorstellen, dass wir in Zukunft mehrere Produkte entwickeln würden, die sich stark voneinander unterscheiden ( Bumble , Lumen und andere). Aus diesem Grund war der Navigator aus unserer ausgereiftesten Anwendung - Badoo - nicht in anderen Produkten einsetzbar und jedes Team musste sich etwas Neues einfallen lassen.

Leider wurden auch für bestimmte Anwendungen neue Ansätze geschärft. Mit der zunehmenden Anzahl von Projekten wurde das Problem offensichtlich und es entstand die Idee, eine Bibliothek zu erstellen, die bestimmte Komponenten, einschließlich der universellen Navigationslogik, bereitstellt. Dies würde dazu beitragen, die Implementierungszeit für ähnliche Funktionen in neuen Produkten zu minimieren.

Wir implementieren einen universellen Router


Die Hauptaufgaben, die der globale Navigator löst, sind nicht so viele:

  1. Suchen Sie den aktuell aktiven Bildschirm.
  2. Vergleichen Sie irgendwie den Typ des aktiven Bildschirms und seinen Inhalt mit dem, was angezeigt werden muss.
  3. Führen Sie den Übergang nach Bedarf durch (Reihenfolge der Übergänge).

Vielleicht sieht die Formulierung der Aufgaben etwas abstrakt aus, aber es ist diese Abstraktion, die es ermöglicht, die Logik zu universalisieren.

1. Aktive Bildschirmsuche


Die erste Aufgabe scheint ganz einfach zu sein: Sie müssen nur die gesamte Hierarchie der Bildschirme durchgehen und den obersten UIViewController finden .



Die Oberfläche unseres Objekts könnte ungefähr so ​​aussehen:

protocol TopViewControllerProvider { var topViewController: UIViewController? { get } } 

Es ist jedoch nicht klar, wie das Stammelement der Hierarchie bestimmt werden soll und was mit Containerbildschirmen wie UIPageViewController und anwendungsspezifischen Containern zu tun ist.

Die einfachste Möglichkeit, das Root-Element zu bestimmen, besteht darin, den Root-Controller vom aktiven Bildschirm aus abzurufen:

 UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController 

Dieser Ansatz funktioniert möglicherweise nicht immer mit Anwendungen, in denen mehrere Fenster vorhanden sind. Dies ist jedoch ein eher seltener Fall, und das Problem kann gelöst werden, indem das gewünschte Fenster explizit als Parameter übergeben wird.

Das Problem mit Containerbildschirmen kann gelöst werden, indem ein spezielles Protokoll erstellt wird, das eine Methode zum Abrufen eines aktiven Bildschirms enthält, oder Sie können das oben angegebene Protokoll verwenden. Alle in der Anwendung verwendeten Container-Controller müssen dieses Protokoll implementieren. Für einen UITabBarController könnte eine Implementierung beispielsweise so aussehen:

 extension UITabBarController: TopViewControllerProvider { var topViewController: UIViewController? { return self.selectedViewController } } 

Es bleibt nur, die gesamte Hierarchie zu durchlaufen und den oberen Bildschirm zu erhalten. Wenn der nächste Controller TopViewControllerProvider implementiert, wird der Bildschirm über die deklarierte Methode angezeigt. Andernfalls wird der Controller, der darauf angezeigt wird, modal überprüft (falls vorhanden).

2. Aktueller Kontext


Die Aufgabe, den aktuellen Kontext zu bestimmen, sieht viel komplizierter aus. Wir wollen den Bildschirmtyp und möglicherweise die darauf angezeigten Informationen bestimmen. Es erscheint logisch, eine Struktur zu erstellen, die diese Informationen enthält.

Aber welche Typen sollten Objekteigenschaften haben? Unser letztendliches Ziel ist es, den Kontext mit dem, was gezeigt werden muss, zu vergleichen, damit das Equatable- Protokoll implementiert werden kann. Dies kann durch generische Typen implementiert werden:

 struct ViewControllerContext<ScreenType: Equatable, InfoType: Equatable>: Equatable { let screenType: ScreenType let info: InfoType? } 

Aufgrund der Besonderheiten von Swift bestehen jedoch gewisse Einschränkungen für die Verwendung dieses Typs. Um Probleme zu vermeiden, sieht diese Struktur in unseren Anwendungen etwas anders aus:

 protocol ViewControllerContextInfo { func isEqual(to info: ViewControllerContextInfo?) -> Bool } struct ViewControllerContext: Equatable { public let screenType: String public let info: ViewControllerContextInfo? } 

Eine weitere Option ist die Nutzung der neuen Swift-Funktion „ Undurchsichtige Typen“ . Sie ist jedoch erst ab iOS 13 verfügbar, was für viele Produkte immer noch nicht akzeptabel ist.

Die Implementierung des Kontextvergleichs ist ziemlich offensichtlich. Um die isEqual-Funktion nicht für Typen zu schreiben, die Equatable bereits implementieren, können Sie einen einfachen Trick ausführen, diesmal mit den Vorteilen von Swift:

 extension ViewControllerContextInfo where Self: Equatable { func isEqual(to info: ViewControllerContextInfo?) -> Bool { guard let info = info as? Self else { return false } return self == info } } 

Toll, wir haben ein Objekt zum Vergleichen. Aber wie kann man es einem UIViewController zuordnen ? Eine Möglichkeit besteht darin, verknüpfte Objekte zu verwenden , die in einigen Fällen eine nützliche Funktion der Sprache Objective C. Zum einen ist sie jedoch nicht sehr explizit, und zum anderen möchten wir in der Regel nur den Kontext einiger Anwendungsbildschirme vergleichen. Daher sieht das Erstellen eines Protokolls gut aus:

 protocol ViewControllerContextHolder { var currentContext: ViewControllerContext? { get } } 


und seine Implementierung nur in den erforderlichen Bildschirmen. Wenn der aktive Bildschirm dieses Protokoll nicht implementiert, kann sein Inhalt als unbedeutend betrachtet und bei der Anzeige eines neuen Protokolls nicht berücksichtigt werden.

3. Ausführung des Übergangs


Mal sehen, was wir schon haben. Die Möglichkeit, jederzeit Informationen über den aktiven Bildschirm in Form einer bestimmten Datenstruktur abzurufen. Informationen, die extern über eine offene URL, eine Push-Benachrichtigung oder eine andere Methode zum Starten der Navigation empfangen werden, können in eine Struktur desselben Typs konvertiert werden und als Navigationsabsicht dienen. Wenn auf dem oberen Bildschirm bereits die erforderlichen Informationen angezeigt werden, können Sie die Navigation einfach ignorieren oder den Bildschirminhalt aktualisieren.



Aber was ist mit dem Übergang selbst?

Es ist logisch, eine Komponente (nennen wir sie einen Router ) zu erstellen, die die anzuzeigenden Elemente aufnimmt, mit den bereits angezeigten Elementen vergleicht und einen Übergang oder eine Abfolge von Übergängen ausführt. Der Router kann auch allgemeine Logik zum Verarbeiten und Validieren von Informationen und des Anwendungsstatus enthalten. Hauptsache, Sie sollten keine domänen- oder anwendungsspezifische Logik in diese Komponente aufnehmen. Wenn Sie diese Regel einhalten, kann sie für verschiedene Anwendungen wiederverwendet und einfach gewartet werden.

Die grundlegende Schnittstellendeklaration eines solchen Protokolls sieht folgendermaßen aus:

 protocol ViewControllerContextRouterProtocol { func navigateToContext(_ context: ViewControllerContext, animated: Bool) } 

Sie können die obige Funktion verallgemeinern, indem Sie eine Folge von Kontexten übergeben. Dies wird keine wesentlichen Auswirkungen auf die Implementierung haben.

Es ist ziemlich offensichtlich, dass der Router eine Controller-Fabrik benötigt, da nur Navigationsdaten an seinem Eingang empfangen werden. Es ist notwendig, innerhalb der Fabrik separate Bildschirme und möglicherweise sogar ganze Module zu erstellen, die auf dem übertragenen Kontext basieren. Über das Feld screenType können Sie im Infofeld festlegen, welches Bild Sie erstellen möchten - mit welchen Daten Sie es vorab füllen müssen:

 protocol ViewControllersByContextFactory { func viewController(for context: ViewControllerContext) -> UIViewController? } 

Wenn es sich bei der Anwendung nicht um einen Snapchat-Klon handelt, ist die Anzahl der zum Anzeigen des neuen Controllers verwendeten Methoden höchstwahrscheinlich gering. Daher ist für die meisten Anwendungen die Aktualisierung des UINavigationController- Stacks und die Anzeige eines modalen Bildschirms ausreichend. In diesem Fall können Sie eine Aufzählung mit möglichen Typen definieren, zum Beispiel:

 enum NavigationType { case modal case navigationStack case rootScreen } 

Die Art des Bildschirms hängt davon ab, wie er angezeigt wird. Wenn dies eine Sperrbenachrichtigung ist, muss sie modal angezeigt werden. Möglicherweise muss über den UINavigationController ein weiterer Bildschirm zu einem vorhandenen Navigationsstapel hinzugefügt werden .

Es ist besser, nicht im Router selbst zu entscheiden, wie ein bestimmter Bildschirm angezeigt werden soll. Wenn wir eine Router-Abhängigkeit unter dem ViewControllerNavigationTypeProvider- Protokoll hinzufügen und die gewünschten anwendungsspezifischen Methoden implementieren, erreichen wir dieses Ziel:

 protocol ViewControllerNavigationTypeProvider { func navigationType(for context: ViewControllerContext) -> NavigationType } 

Was aber, wenn wir in einer der Anwendungen eine neue Art der Navigation einführen wollen? Müssen Sie eine neue Option zu enum hinzufügen, und alle anderen Anwendungen wissen davon? Möglicherweise ist dies in einigen Fällen genau das, wonach wir streben. Wenn Sie sich jedoch an das Open-Closed-Prinzip halten , können Sie zur Erhöhung der Flexibilität das Protokoll eines Objekts eingeben, das Übergänge ausführen kann:

 protocol ViewControllerContextTransition { func navigate(from source: UIViewController?, to destination: UIViewController, animated: Bool) } 

Dann wird ViewControllerNavigationTypeProvider folgendermaßen aussehen :

 protocol ViewControllerContextTransitionProvider { func transition(for context: ViewControllerContext) -> ViewControllerContextTransition } 

Jetzt sind wir nicht mehr auf einen festen Satz von Bildschirmanzeigetypen beschränkt, sondern können die Navigationsfunktionen erweitern, ohne Änderungen am Router selbst vorzunehmen.

Manchmal müssen Sie keinen neuen UIViewController erstellen, um zu einem Bildschirm zu wechseln - wechseln Sie einfach zu einem vorhandenen. Das offensichtlichste Beispiel ist das Wechseln der Registerkarten in einem UITabBarController . Ein weiteres Beispiel ist der Übergang zu einem vorhandenen Element im angezeigten Controller-Stapel, anstatt einen neuen Bildschirm mit demselben Inhalt zu erstellen. Dazu können Sie im Router vor dem Anlegen eines neuen UIViewControllers zunächst prüfen, ob der Kontext einfach umgeschaltet werden kann.

Wie kann man dieses Problem lösen? Mehr Abstraktionen!

 protocol ViewControllerContextSwitcher { func canSwitch(to context: ViewControllerContext) -> Bool func switchContext(to context: ViewControllerContext, animated: Bool) } 

Bei Registerkarten kann dieses Protokoll von einer Komponente implementiert werden, die weiß, was im UITabBarViewController enthalten ist, und kann ViewControllerContext einer bestimmten Registerkarte zuordnen und Registerkarten wechseln.



Eine Reihe solcher Objekte kann als Abhängigkeit an den Router übergeben werden.

Zusammenfassend sieht der Kontextverarbeitungsalgorithmus folgendermaßen aus:

 func navigateToContext(_ context: ViewControllerContext, animated: Bool) { let topViewController = self.topViewControllerProvider.topViewController if let contextHolder = topViewController as? ViewControllerContextHolder, contextHolder.currentContext == context { return } if let switcher = self.contextSwitchers.first(where: { $0.canSwitch(to: context) }) { switcher.switchContext(to: context, animated: animated) return } guard let viewController = self.viewControllersFactory.viewController(for: context) else { return } let navigation = self.transitionProvider.navigation(for: context) navigation.navigate(from: self.topViewControllerProvider.topViewController, to: viewController, animated: true) } 


Es ist praktisch, das Router-Abhängigkeitsdiagramm in Form eines UML-Diagramms darzustellen:



Der resultierende Router kann für Übergänge verwendet werden, die automatisch oder durch Benutzeraktionen initiiert werden. Wenn in unseren Produkten die Navigation nicht automatisch erfolgt, werden Standardsystemfunktionen verwendet und die meisten Module kennen die Existenz eines globalen Routers nicht. Es ist nur wichtig, sich an die Implementierung des ViewControllerContextHolder- Protokolls zu erinnern, wenn dies erforderlich ist, damit der Router immer die Informationen herausfinden kann, die der Benutzer zum aktuellen Zeitpunkt sieht.

Vor- und Nachteile


Vor kurzem haben wir damit begonnen, die beschriebene Navigationsverwaltungsmethode in Badoo-Produkten einzuführen. Obwohl sich herausstellte, dass die Implementierung etwas komplizierter war als die im Demo-Projekt vorgestellte Option, sind wir mit den Ergebnissen zufrieden. Lassen Sie uns die Vor- und Nachteile des beschriebenen Ansatzes bewerten.

Zu den Vorteilen gehören:

  • Universalität
  • Relativ einfache Implementierung im Vergleich zu den Optionen im Abschnitt "Alternativen".
  • Mangel an Einschränkungen für die Architektur der Anwendung und die Implementierung der konventionellen Navigation zwischen Bildschirmen.

Die Nachteile sind teilweise eine Folge der Vorteile.

  • Controller müssen wissen, welche Informationen sie anzeigen. Wenn wir die Architektur der Anwendung berücksichtigen, sollte der UIViewController der Anzeigeebene zugewiesen werden und die Geschäftslogik sollte nicht in dieser Ebene gespeichert werden. Die Datenstruktur, die den Navigationskontext enthält, muss dort aus der Geschäftslogikebene implementiert werden, die Controller speichern jedoch diese Informationen, was nicht sehr korrekt ist.
  • Die Quelle der Wahrheit über den Status der Anwendung ist die Hierarchie der angezeigten Bildschirme, die in einigen Fällen eine Einschränkung darstellen kann.


Alternativen


Eine Alternative zu diesem Ansatz könnte darin bestehen, manuell eine Hierarchie der aktiven Module zu erstellen. Ein Beispiel für eine solche Lösung ist die Implementierung des Koordinatormusters, bei dem die Koordinatoren eine Baumstruktur bilden, die als Wahrheitsquelle für die Bestimmung des aktiven Bildschirms dient, und die Logik der Entscheidung, diesen oder jenen Bildschirm anzuzeigen oder nicht, in den Koordinatoren selbst enthalten ist.

Ähnliche Ideen finden Sie in der RIBs- Architektur, die von unserem Android-Team verwendet wird.

Solche Alternativen bieten eine flexiblere Abstraktion, erfordern jedoch eine einheitliche Architektur und können für viele Anwendungen zu umständlich sein.

Wenn Sie einen anderen Lösungsansatz gewählt haben, zögern Sie nicht, in den Kommentaren darüber zu sprechen.

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


All Articles