In den letzten Jahren haben wir gemeinsame Ansätze für die Erstellung von Android-Anwendungen entwickelt. Reine Architektur, Architekturmuster (MVC, MVP, MVVM, MVI), das Repository-Muster und andere. Es gibt jedoch noch keine allgemein akzeptierten Ansätze zum Organisieren der Navigation innerhalb der Anwendung. Heute möchte ich mit Ihnen über die Vorlage „Koordinator“ und die Möglichkeiten ihrer Anwendung bei der Entwicklung von Android-Anwendungen sprechen.
Das Koordinatormuster wird häufig in iOS-Anwendungen verwendet und wurde von Soroush Khanlou eingeführt, um die Navigation der Anwendung zu vereinfachen. Es wird angenommen, dass Sorushs Arbeit auf dem Application Controller-Ansatz basiert, der in den Patterns of Enterprise Application Architecture von Martin Fowler beschrieben ist.
Die Vorlage „Koordinator“ dient zur Lösung der folgenden Aufgaben:
- Kampf mit dem Massive View Controller-Problem (das Problem wurde bereits auf dem Hub geschrieben - Anmerkung des Übersetzers), das sich oft mit dem Aufkommen von Gott-Aktivität (Aktivität mit vielen Verantwortlichkeiten) manifestiert.
- Trennung der Navigationslogik in eine separate Einheit
- Wiederverwendung von Anwendungsbildschirmen (Aktivität / Fragmente) aufgrund schwacher Verbindung mit der Navigationslogik
Bevor Sie sich jedoch mit der Vorlage vertraut machen und versuchen, sie zu implementieren, werfen wir einen Blick auf die in Android-Anwendungen verwendeten Navigationsimplementierungen.
Die Navigationslogik ist in der Aktivität / im Fragment beschrieben
Da das Android SDK erfordert, dass Context eine neue Aktivität öffnet (oder FragmentManager, um der Aktivität ein Fragment hinzuzufügen), wird die Navigationslogik häufig direkt in der Aktivität / dem Fragment beschrieben. Sogar die Beispiele in der Dokumentation für das Android SDK verwenden diesen Ansatz.
class ShoppingCartActivity : Activity() { override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { val intent = Intent(this, CheckoutActivity::class.java) startActivity(intent) } } }
Im obigen Beispiel ist die Navigation eng mit der Aktivität verbunden. Ist es bequem, solchen Code zu testen? Man könnte argumentieren, dass wir die Navigation in eine separate Entität trennen und sie beispielsweise Navigator nennen können, der implementiert werden kann. Mal sehen:
class ShoppingCartActivity : Activity() { @Inject lateinit var navigator : Navigator override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { navigator.showCheckout(this) } } } class Navigator { fun showCheckout(activity : Activity){ val intent = Intent(activity, CheckoutActivity::class.java) activity.startActivity(intent) } }
Es stellte sich nicht schlecht heraus, aber ich will mehr.
Navigation mit MVVM / MVP
Ich beginne mit der Frage: Wo würden Sie die Navigationslogik platzieren, wenn Sie MVVM / MVP verwenden?
In der Ebene unter dem Präsentator (nennen wir es Geschäftslogik)? Keine gute Idee, da Sie Ihre Geschäftslogik höchstwahrscheinlich in anderen Präsentationsmodellen oder Präsentatoren wiederverwenden werden.
In der Ansichtsebene? Möchten Sie wirklich Ereignisse zwischen die Präsentation und das Präsentations- / Präsentationsmodell werfen? Schauen wir uns ein Beispiel an:
class ShoppingCartActivity : ShoppingCartView, Activity() { @Inject lateinit var navigator : Navigator @Inject lateinit var presenter : ShoppingCartPresenter override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { presenter.checkoutClicked() } } override fun navigateToCheckout(){ navigator.showCheckout(this) } } class ShoppingCartPresenter : Presenter<ShoppingCartView> { ... override fun checkoutClicked(){ view?.navigateToCheckout(this) } }
Wenn Sie MVVM bevorzugen, können Sie SingleLiveEvents oder EventObserver verwenden
class ShoppingCartActivity : ShoppingCartView, Activity() { @Inject lateinit var navigator : Navigator @Inject lateinit var viewModel : ViewModel override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { viewModel.checkoutClicked() } viewModel.navigateToCheckout.observe(this, Observer { navigator.showCheckout(this) }) } } class ShoppingCartViewModel : ViewModel() { val navigateToCheckout = MutableLiveData<Event<Unit>> fun checkoutClicked(){ navigateToCheckout.value = Event(Unit)
Oder fügen Sie einen Navigator in ein Ansichtsmodell ein, anstatt EventObserver wie im vorherigen Beispiel gezeigt zu verwenden
class ShoppingCartViewModel @Inject constructor(val navigator : Navigator) : ViewModel() }
Bitte beachten Sie, dass dieser Ansatz auf den Präsentator angewendet werden kann. Wir ignorieren auch einen möglichen Speicherverlust im Navigator, wenn eine Verbindung zum Aktivator besteht.
Koordinator
Wo platzieren wir die Navigationslogik? Geschäftslogik? Wir haben diese Option bereits in Betracht gezogen und sind zu dem Schluss gekommen, dass dies nicht die beste Lösung ist. Das Auslösen von Ereignissen zwischen der Ansicht und dem Ansichtsmodell funktioniert möglicherweise, sieht jedoch nicht nach einer eleganten Lösung aus. Darüber hinaus ist die Ansicht weiterhin für die Navigationslogik verantwortlich, obwohl wir sie zum Navigator gebracht haben. Nach der Ausschlussmethode haben wir weiterhin die Möglichkeit, die Navigationslogik in das Präsentationsmodell einzufügen, und diese Option erscheint vielversprechend. Aber sollte sich das Ansichtsmodell um die Navigation kümmern? Ist das nicht nur eine Ebene zwischen der Ansicht und dem Modell? Deshalb sind wir auf die Idee eines Koordinators gekommen.
"Warum brauchen wir eine andere Abstraktionsebene?" - Du fragst. Lohnt sich die Komplexität des Systems? In kleinen Projekten kann sich die Abstraktion tatsächlich zum Zwecke der Abstraktion herausstellen. In komplexen Anwendungen oder bei Verwendung von A / B-Tests kann der Koordinator jedoch nützlich sein. Angenommen, ein Benutzer kann ein Konto erstellen und sich anmelden. Wir haben bereits eine Logik, in der wir überprüfen müssen, ob sich der Benutzer angemeldet hat, und entweder den Anmeldebildschirm oder den Hauptbildschirm der Anwendung anzeigen müssen. Der Koordinator kann mit dem gegebenen Beispiel helfen. Beachten Sie, dass der Koordinator nicht dazu beiträgt, weniger Code zu schreiben, sondern den Code der Navigationslogik aus der Ansicht oder dem Ansichtsmodell abzurufen.
Die Idee des Koordinators ist äußerst einfach. Er weiß nur, welcher Anwendungsbildschirm als nächstes geöffnet werden soll. Wenn ein Benutzer beispielsweise auf die Zahlungsschaltfläche für eine Bestellung klickt, erhält der Koordinator das entsprechende Ereignis und weiß, dass der nächste Schritt darin besteht, den Zahlungsbildschirm zu öffnen. In iOS wird der Koordinator als Service-Locator verwendet, um ViewController zu erstellen und den Backstack zu steuern. Dies ist genug für den Koordinator (denken Sie an das Prinzip der alleinigen Verantwortung). In Android-Anwendungen erstellt das System Aktivitäten, wir haben viele Tools zum Implementieren von Abhängigkeiten und es gibt einen Backstack für Aktivitäten und Fragmente. Kehren wir nun zur ursprünglichen Idee des Koordinators zurück: Der Koordinator weiß nur, welcher Bildschirm als nächstes angezeigt wird.
Beispiel: Nachrichtenanwendung mit einem Koordinator
Lassen Sie uns zum Schluss direkt über die Vorlage sprechen. Stellen Sie sich vor, wir müssen eine einfache Nachrichtenanwendung erstellen. Die Anwendung verfügt über zwei Bildschirme: "Liste der Artikel" und "Artikeltext", die durch Klicken auf ein Listenelement geöffnet werden.

class NewsFlowCoordinator (val navigator : Navigator) { fun start(){ navigator.showNewsList() } fun readNewsArticle(id : Int){ navigator.showNewsArticle(id) } }
Ein Skript (Flow) enthält einen oder mehrere Bildschirme. In unserem Beispiel besteht das Nachrichtenszenario aus zwei Bildschirmen: "Artikelliste" und "Artikeltext". Der Koordinator war sehr einfach. Wenn die Anwendung gestartet wird, rufen wir NewsFlowCoordinator # start () auf, um eine Liste der Artikel anzuzeigen. Wenn ein Benutzer auf ein Listenelement klickt, wird die NewsFlowCoordinator # readNewsArticle (id) -Methode aufgerufen und ein Bildschirm mit dem vollständigen Text des Artikels angezeigt. Wir arbeiten immer noch mit dem Navigator (darüber werden wir etwas später sprechen), an den wir das Öffnen des Bildschirms delegieren. Der Koordinator hat keinen Status, er hängt nicht von der Implementierung des Backends ab und implementiert nur eine Funktion: Er bestimmt, wohin er als nächstes gehen soll.
Aber wie kann man den Koordinator mit unserem Präsentationsmodell verbinden? Wir folgen dem Prinzip der Abhängigkeitsinversion: Wir übergeben das Lambda an das Ansichtsmodell, das aufgerufen wird, wenn der Benutzer auf den Artikel tippt.
class NewsListViewModel( newsRepository : NewsRepository, var onNewsItemClicked: ( (Int) -> Unit )? ) : ViewModel() { val newsArticles = MutableLiveData<List<News>> private val disposable = newsRepository.getNewsArticles().subscribe { newsArticles.value = it } fun newsArticleClicked(id : Int){ onNewsItemClicked!!(id)
onNewsItemClicked: (Int) -> Unit ist ein Lambda, das ein ganzzahliges Argument hat und Unit zurückgibt. Bitte beachten Sie, dass Lambda möglicherweise null ist. Dadurch können wir den Link löschen, um Speicherverluste zu vermeiden. Der Ersteller des Ansichtsmodells (z. B. ein Dolch) muss einen Link zur Koordinatormethode übergeben:
return NewsListViewModel( newsRepository = newsRepository, onNewsItemClicked = newsFlowCoordinator::readNewsArticle )
Zuvor haben wir den Navigator erwähnt, der den Bildschirmwechsel durchführt. Die Implementierung des Navigators liegt in Ihrem Ermessen, da dies von Ihrem spezifischen Ansatz und Ihren persönlichen Vorlieben abhängt. In unserem Beispiel verwenden wir eine Aktivität mit mehreren Fragmenten (ein Bildschirm - ein Fragment mit einem eigenen Präsentationsmodell). Ich gebe eine naive Implementierung eines Navigators:
class Navigator{ var activity : FragmentActivity? = null fun showNewsList(){ activty!!.supportFragmentManager .beginTransaction() .replace(R.id.fragmentContainer, NewsListFragment()) .commit() } fun showNewsDetails(newsId: Int) { activty!!.supportFragmentManager .beginTransaction() .replace(R.id.fragmentContainer, NewsDetailFragment.newInstance(newsId)) .addToBackStack("NewsDetail") .commit() } } class MainActivity : AppCompatActivity() { @Inject lateinit var navigator : Navigator override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) navigator.activty = this } override fun onDestroy() { super.onDestroy() navigator.activty = null
Die obige Implementierung des Navigators ist nicht ideal, aber die Hauptidee dieses Beitrags besteht darin, einen Koordinator in das Muster einzuführen. Da Navigator und Koordinator keinen Status haben, können sie innerhalb der Anwendung deklariert werden (z. B.
Singleton- Bereiche in einem Dolch) und in Anwendung # onCreate () instanziiert werden.
Fügen wir unserer Anwendung eine Autorisierung hinzu. Wir definieren einen neuen Anmeldebildschirm (LoginFragment + LoginViewModel, der Einfachheit halber lassen wir die Wiederherstellung und Registrierung von Passwörtern weg) und LoginFlowCoordinator. Warum nicht neue Funktionen zu NewsFlowCoordinator hinzufügen? Wir möchten keinen Gott-Koordinator, der für die gesamte Navigation in der Anwendung verantwortlich ist. Das Autorisierungsskript gilt auch nicht für das Newsreader-Szenario, oder?
class LoginFlowCoordinator( val navigator: Navigator ) { fun start(){ navigator.showLogin() } fun registerNewUser(){ navigator.showRegistration() } fun forgotPassword(){ navigator.showRecoverPassword() } } class LoginViewModel( val usermanager: Usermanager, var onSignUpClicked: ( () -> Unit )?, var onForgotPasswordClicked: ( () -> Unit )? ) { fun login(username : String, password : String){ usermanager.login(username, password) ... } ... }
Hier sehen wir, dass es für jedes UI-Ereignis ein entsprechendes Lambda gibt, jedoch kein Lambda für den Rückruf einer erfolgreichen Anmeldung. Dies ist auch ein Implementierungsdetail und Sie können das entsprechende Lambda hinzufügen, ich habe jedoch eine bessere Idee. Fügen wir einen RootFlowCoordinator hinzu und abonnieren die Modelländerungen.
class RootFlowCoordinator( val usermanager: Usermanager, val loginFlowCoordinator: LoginFlowCoordinator, val newsFlowCoordinator: NewsFlowCoordinator, val onboardingFlowCoordinator: OnboardingFlowCoordinator ) { init { usermanager.currentUser.subscribe { user -> when (user){ is NotAuthenticatedUser -> loginFlowCoordinator.start() is AuthenticatedUser -> if (user.onBoardingCompleted) newsFlowCoordinator.start() else onboardingFlowCoordinator.start() } } } fun onboardingCompleted(){ newsFlowCoordinator.start() } }
Somit ist RootFlowCoordinator anstelle von NewsFlowCoordinator der Einstiegspunkt unserer Navigation. Konzentrieren wir uns auf den RootFlowCoordinator. Wenn der Benutzer angemeldet ist, prüfen wir, ob er das Onboarding abgeschlossen hat (dazu später mehr), und beginnen mit dem Skript für Nachrichten oder Onboarding. Bitte beachten Sie, dass LoginViewModel nicht an dieser Logik beteiligt ist. Wir beschreiben das Onboarding-Szenario.

class OnboardingFlowCoordinator( val navigator: Navigator, val onboardingFinished: () -> Unit // this is RootFlowCoordinator.onboardingCompleted() ) { fun start(){ navigator.showOnboardingWelcome() } fun welcomeShown(){ navigator.showOnboardingPersonalInterestChooser() } fun onboardingCompleted(){ onboardingFinished() } }
Onboarding wird durch Aufrufen von OnboardingFlowCoordinator # start () gestartet, das WelcomeFragment (WelcomeViewModel) anzeigt. Nach dem Klicken auf die Schaltfläche "Weiter" wird die OnboardingFlowCoordinator # welcomeShown () -Methode aufgerufen. Daraufhin wird der folgende Bildschirm PersonalInterestFragment + PersonalInterestViewModel angezeigt, auf dem der Benutzer Kategorien interessanter Nachrichten auswählt. Nach Auswahl der Kategorien tippt der Benutzer auf die Schaltfläche "Weiter" und die OnboardingFlowCoordinator # onboardingCompleted () -Methode wird aufgerufen, die den Aufruf an RootFlowCoordinator # onboardingCompleted () weiterleitet und NewsFlowCoordinator startet.
Mal sehen, wie der Koordinator die Arbeit mit A / B-Tests vereinfachen kann. Ich werde einen Bildschirm mit einem Angebot zum Kauf in der Anwendung hinzufügen und es einigen Benutzern anzeigen.

class NewsFlowCoordinator ( val navigator : Navigator, val abTest : AbTest ) { fun start(){ navigator.showNewsList() } fun readNewsArticle(id : Int){ navigator.showNewsArticle(id) } fun closeNews(){ if (abTest.isB){ navigator.showInAppPurchases() } else { navigator.closeNews() } } }
Auch hier haben wir der Ansicht oder ihrem Modell keine Logik hinzugefügt. Haben Sie beschlossen, InAppPurchaseFragment zum Onboarding hinzuzufügen? Dazu müssen Sie nur den Onboarding-Koordinator ändern, da das Einkaufsfragment und sein Ansichtsmodell völlig unabhängig von anderen Fragmenten sind und wir es in anderen Szenarien frei wiederverwenden können. Der Koordinator hilft auch bei der Implementierung des A / B-Tests, bei dem zwei Onboarding-Szenarien verglichen werden.
Vollständige Quellen finden
Sie auf dem Github , und für die Faulen habe ich eine
Videodemo vorbereitet
Nützlicher Rat: Mit Kotlin können Sie eine bequeme DSL erstellen, um Koordinatoren in Form eines Navigationsdiagramms zu beschreiben.
newsFlowCoordinator(navigator, abTest) { start { navigator.showNewsList() } readNewsArticle { id -> navigator.showNewsArticle(id) } closeNews { if (abTest.isB){ navigator.showInAppPurchases() } else { navigator.closeNews() } } }
Zusammenfassung:
Der Koordinator hilft dabei, die Navigationslogik auf die getestete lose gekoppelte Komponente zu bringen. Im Moment gibt es keine produktionsbereite Bibliothek, ich habe nur das Konzept der Problemlösung beschrieben. Ist der Koordinator auf Ihre Bewerbung anwendbar? Ich weiß nicht, es hängt von Ihren Anforderungen ab und davon, wie einfach es sein wird, es in die vorhandene Architektur zu integrieren. Es kann nützlich sein, eine kleine Anwendung mit einem Koordinator zu schreiben.
FAQ:
Der Artikel erwähnt nicht die Verwendung eines Koordinators mit einem MVI-Muster. Ist es möglich, einen Koordinator für diese Architektur zu verwenden? Ja, ich habe einen
separaten Artikel .
Google hat kürzlich den Navigationscontroller als Teil von Android Jetpack eingeführt. In welcher Beziehung steht der Koordinator zur Google-Navigation? Sie können den neuen Navigationscontroller anstelle des Navigators in den Koordinatoren oder direkt im Navigator verwenden, anstatt Fragmenttransaktionen manuell zu erstellen.
Und wenn ich keine Fragmente / Aktivitäten verwenden und mein eigenes Back-End schreiben möchte, um die Ansichten zu verwalten, kann ich in meinem Fall den Koordinator verwenden? Ich habe auch darüber nachgedacht und arbeite an einem Prototyp. Ich werde darüber in meinem Blog schreiben. Es scheint mir, dass die Zustandsmaschine die Aufgabe erheblich vereinfachen wird.
Ist der Koordinator an den Ansatz der Einzelaktivitätsanwendung gebunden? Nein, Sie können es in verschiedenen Szenarien verwenden. Die Implementierung des Übergangs zwischen Bildschirmen ist im Navigator ausgeblendet.
Mit dem beschriebenen Ansatz erhalten Sie einen riesigen Navigator. Wir haben irgendwie versucht, von God-Object'a wegzukommen? Wir müssen den Navigator nicht in einer Klasse beschreiben. Erstellen Sie mehrere kleine unterstützte Navigatoren, z. B. einen separaten Navigator für jedes Benutzerszenario.
Wie arbeite ich mit kontinuierlichen Übergangsanimationen? Beschreiben Sie Übergangsanimationen im Navigator. Dann weiß die Aktivität / das Fragment nichts über den vorherigen / nächsten Bildschirm. Woher weiß der Navigator, wann die Animation gestartet werden soll? Angenommen, wir möchten eine Animation des Übergangs zwischen den Fragmenten A und B anzeigen. Wir können das Ereignis onFragmentViewCreated (v: View) mit FragmentLifecycleCallback abonnieren. Wenn dieses Ereignis auftritt, können wir mit Animationen auf die gleiche Weise arbeiten wie direkt im Fragment: OnPreDrawListener hinzufügen um zu warten, bis es fertig ist, und startPostponedEnterTransition () aufzurufen. Auf ungefähr die gleiche Weise können Sie einen animierten Übergang zwischen Aktivitäten mit ActivityLifecycleCallbacks oder zwischen ViewGroup mit OnHierarchyChangeListener implementieren. Vergessen Sie nicht, Ereignisse später abzubestellen, um Speicherverluste zu vermeiden.