Moderne MVI-Architektur basierend auf Kotlin



In den letzten zwei Jahren haben Android-Entwickler bei Badoo einen langen, heiklen Weg von MVP zu einem völlig anderen Ansatz für die Anwendungsarchitektur zurückgelegt. ANublo und ich möchten eine Übersetzung eines Artikels unseres Kollegen Zsolt Kocsi teilen , in dem die aufgetretenen Probleme und ihre Lösung beschrieben werden.

Dies ist der erste von mehreren Artikeln, die sich mit der Entwicklung der modernen MVI-Architektur auf Kotlin befassen.

Beginnen wir von vorne: Zustandsprobleme


Zu jedem Zeitpunkt hat die Anwendung einen bestimmten Status, der ihr Verhalten und das, was der Benutzer sieht, bestimmt. Wenn Sie sich nur auf einige Klassen konzentrieren, enthält dieser Status alle Werte der Variablen - von einfachen Flags bis zu einzelnen Objekten. Jede dieser Variablen lebt ihr eigenes Leben und wird von verschiedenen Teilen des Codes gesteuert. Sie können den aktuellen Status der Anwendung nur ermitteln, indem Sie alle nacheinander überprüfen.

Wir arbeiten am Code und erstellen ein vorhandenes Modell der Systemarbeit in unseren Köpfen. Wir implementieren problemlos Idealfälle, wenn alles nach Plan verläuft, können jedoch nicht alle möglichen Probleme und Bedingungen der Anwendung berechnen. Und früher oder später wird uns eine der Bedingungen, die wir uns nicht vorgestellt haben, überholen und wir werden auf einen Fehler stoßen.

Zunächst wird der Code gemäß unseren Vorstellungen über die Funktionsweise des Systems geschrieben. Aber in Zukunft ist es notwendig, in den fünf Phasen des Debuggens alles schmerzhaft zu wiederholen und gleichzeitig das Modell des bereits erstellten Systems zu ändern, das sich in meinem Kopf entwickelt hat. Es bleibt zu hoffen, dass wir früher oder später verstehen, was schief gelaufen ist, und dass der Fehler behoben wird.

Aber das ist alles andere als immer ein Glücksfall. Je komplexer das System ist, desto wahrscheinlicher ist es, dass es auf einen unvorhergesehenen Zustand stößt, dessen Debuggen in Albträumen für lange Zeit ein Traum sein wird.

In Badoo sind alle Anwendungen im Wesentlichen asynchron - nicht nur aufgrund der umfangreichen Funktionalität, die dem Benutzer über die Benutzeroberfläche zur Verfügung steht, sondern auch aufgrund der Möglichkeit, dass Einwegdaten vom Server gesendet werden. Der Status und das Verhalten der Anwendung werden stark beeinflusst - von der Änderung des Zahlungsstatus bis hin zu neuen Übereinstimmungen und Überprüfungsanforderungen.

Infolgedessen sind wir in unserem Chat-Modul auf einige seltsame und schwer zu reproduzierende Fehler gestoßen, die allen viel Blut verdorben haben. Manchmal gelang es den Testern, sie aufzuschreiben, aber sie wurden auf dem Gerät des Entwicklers nicht wiederholt. Aufgrund des asynchronen Codes war eine vollständige Wiederholung einer Ereigniskette äußerst unwahrscheinlich. Und da die Anwendung nicht abstürzte, hatten wir nicht einmal einen Stack-Trace, der zeigte, wo die Suche gestartet werden sollte.

Clean Architecture konnte uns auch nicht helfen. Selbst nachdem wir das Chat-Modul neu geschrieben haben, haben A / B-Tests kleine, aber signifikante Abweichungen in der Anzahl der Nachrichten von Benutzern festgestellt, die das neue und das alte Modul verwenden. Wir haben entschieden, dass dies auf die schwierige Reproduzierbarkeit von Fehlern und den Zustand des Rennens zurückzuführen ist. Die Diskrepanz blieb bestehen, nachdem alle anderen Faktoren überprüft wurden. Die Interessen des Unternehmens litten darunter, dass es für Entwickler schwierig war, den Code zu pflegen.

Sie können eine neue Komponente nicht freigeben, wenn sie schlechter als die vorhandene funktioniert, aber Sie können sie auch nicht freigeben. Da ein Update erforderlich war, gab es einen Grund. Sie müssen also verstehen, warum in einem System, das völlig normal aussieht und nicht abstürzt, die Anzahl der Nachrichten sinkt.

Wo soll die Suche beginnen?

Spoiler: Dies ist nicht die Schuld von Clean Architecture - wie immer ist der menschliche Faktor schuld. Am Ende haben wir diese Fehler natürlich behoben, aber viel Zeit und Mühe darauf verwendet. Dann dachten wir: Gibt es einen einfacheren Weg, um diese Probleme zu vermeiden?

Das Licht am Ende des Tunnels ...


Modische Begriffe wie Model-View-Intent und „unidirektionaler Datenfluss“ sind uns bekannt. Wenn dies in Ihrem Fall nicht der Fall ist, empfehle ich Ihnen, sie zu googeln - es gibt viele Artikel zu diesen Themen im Internet. Android-Entwickler empfehlen insbesondere das achtteilige Material von Hannes Dorfman .

Wir haben Anfang 2017 angefangen, mit diesen Ideen aus der Webentwicklung zu spielen. Ansätze wie Flux und Redux haben sich als sehr nützlich erwiesen - sie haben uns geholfen, viele Probleme zu lösen.

Zunächst ist es sehr nützlich, alle Statuselemente (Variablen, die sich auf die Benutzeroberfläche auswirken und verschiedene Aktionen auslösen) in einem Objekt - Status - zu enthalten. Wenn alles an einem Ort gespeichert ist, ist das Gesamtbild besser sichtbar. Wenn Sie sich beispielsweise vorstellen möchten, Daten mit diesem Ansatz zu laden, benötigen Sie die Felder payload und isLoading . Wenn Sie sie betrachten, sehen Sie, wann die Daten empfangen werden ( Nutzdaten ) und ob die Animation ( isLoading ) dem Benutzer angezeigt wird.

Wenn wir uns von der parallelen Codeausführung mit Rückrufen entfernen und Änderungen im Anwendungsstatus als eine Reihe von Transaktionen ausdrücken, erhalten wir einen einzigen Einstiegspunkt. Wir präsentieren Ihnen Reducer , der von der funktionalen Programmierung zu uns gekommen ist. Es nimmt den aktuellen Status und Daten zu weiteren Aktionen ( Absicht ) und erstellt daraus einen neuen Status:

Reducer = (State, Intent) -> State

Wenn Sie das vorherige Beispiel mit dem Laden von Daten fortsetzen, erhalten Sie die folgenden Aktionen:

  • StartedLoading
  • Fertig mit Erfolg


Anschließend können Sie den Reduzierer mit den folgenden Regeln erstellen:

  1. Erstellen Sie im Fall von StartedLoading ein neues Statusobjekt, indem Sie das alte kopieren, und setzen Sie den Wert isLoading auf true.
  2. Erstellen Sie im Fall von FinishedWithSuccess ein neues Statusobjekt und kopieren Sie das alte. Dabei wird der Wert isLoading auf false und der Wert für die Nutzlast festgelegt
    Spiel hochgeladen.

Wenn wir die resultierende Statusreihe in das Protokoll ausgeben, wird Folgendes angezeigt:

  1. State ( Payload = null, isLoading = false) - der Anfangszustand.
  2. Status ( Payload = null, isLoading = true) - nach StartedLoading.
  3. Status ( Nutzlast = Daten, isLoading = false) - nach FinishedWithSuccess.

Wenn Sie diese Zustände mit der Benutzeroberfläche verbinden, sehen Sie alle Phasen des Prozesses: zuerst einen leeren Bildschirm, dann einen Ladebildschirm und schließlich die erforderlichen Daten.

Dieser Ansatz hat viele Vorteile.

  • Erstens erlauben wir durch die zentrale Änderung des Status mithilfe einer Reihe von Transaktionen nicht den Status des Rennens und viele unsichtbare nervige Fehler.
  • Zweitens können wir nach einer Reihe von Transaktionen verstehen, was passiert ist, warum es passiert ist und wie es den Status der Anwendung beeinflusst hat. Darüber hinaus ist es mit Reducer viel einfacher, sich alle Statusänderungen vor dem ersten Start der Anwendung auf dem Gerät vorzustellen.
  • Schließlich können wir eine einfache Schnittstelle erstellen. Da alle Status an einem Ort (Store) gespeichert sind, der Absichten (Intents) berücksichtigt, Änderungen mit Reducer vornimmt und eine Kette von Status demonstriert, können Sie die gesamte Geschäftslogik in den Store stellen und über die Schnittstelle Absichten starten und Status anzeigen.


Oder nicht?

... vielleicht rast der Zug auf dich zu


Reduzierstück allein reicht eindeutig nicht aus. Was ist mit asynchronen Aufgaben mit unterschiedlichen Ergebnissen? Wie kann ich auf Push vom Server reagieren? Was ist mit dem Start zusätzlicher Aufgaben (z. B. Löschen des Caches oder Laden von Daten aus der lokalen Datenbank) nach einer Statusänderung? Es stellt sich heraus, dass wir entweder nicht alle diese Logik in Reducer aufnehmen (das heißt, eine gute Hälfte der Geschäftslogik wird nicht abgedeckt, und diejenigen, die sich für die Verwendung unserer Komponente entscheiden, müssen sich darum kümmern), oder wir zwingen Reducer, alles auf einmal zu tun.

MVI Framework-Anforderungen


Natürlich möchten wir die gesamte Geschäftslogik eines einzelnen Features in eine unabhängige Komponente einbinden, mit der Entwickler aus anderen Teams problemlos arbeiten können, indem sie einfach eine Instanz davon erstellen und ihren Status abonnieren.

Außerdem:

  • Es sollte leicht mit anderen Komponenten des Systems interagieren können.
  • in seiner internen Struktur sollte es eine klare Aufgabentrennung geben;
  • Alle internen Teile der Komponente müssen vollständig deterministisch sein.
  • Die grundlegende Implementierung einer solchen Komponente sollte nur dann einfach und kompliziert sein, wenn zusätzliche Elemente benötigt werden.

Wir sind nicht sofort von Reducer zu der Lösung übergegangen, die wir heute verwenden. Jedes Team hatte Probleme mit unterschiedlichen Ansätzen, und es schien unwahrscheinlich, eine universelle Lösung zu entwickeln, die für jeden geeignet war.

Und doch passt der aktuelle Stand der Dinge zu jedem. Wir freuen uns, Ihnen MVICore vorstellen zu können! Der Quellcode der Bibliothek ist offen und auf GitHub verfügbar.

Was ist gut MVICore


  • Eine einfache Möglichkeit, Geschäftsfunktionen für reaktive Programmierung mit einem unidirektionalen Datenstrom zu implementieren.
  • Skalierung: Die Basisimplementierung enthält nur Reducer. In komplexeren Fällen können Sie zusätzliche Komponenten verwenden.
  • Eine Lösung für die Arbeit mit Ereignissen, die Sie nicht in den Status aufnehmen möchten ( SingleLiveEvent-Problem ).
  • Eine einfache API zum Binden von Funktionen (und anderen reaktiven Komponenten Ihres Systems) an die Benutzeroberfläche und untereinander mit Unterstützung für den Android-Lebenszyklus (und nicht nur).
  • Middleware-Unterstützung (siehe unten) für jede Komponente des Systems.
  • Vorgefertigter Logger und die Möglichkeit, Zeitreise-Debugging für jede Komponente durchzuführen.


Kurze Einführung in die Funktion


Da auf GitHub bereits schrittweise Anleitungen veröffentlicht wurden, werde ich detaillierte Beispiele weglassen und mich auf die Hauptkomponenten des Frameworks konzentrieren.

Feature - das zentrale Element des Frameworks, das die gesamte Geschäftslogik der Komponente enthält. Die Funktion wird durch drei Parameter definiert: Schnittstelle Funktion <Wunsch, Status, Nachrichten>

Wunsch entspricht der Absicht der Modellansicht - dies sind die Änderungen, die wir im Modell sehen möchten (da der Begriff Absicht in der Umgebung von Android-Entwicklern eine eigene Bedeutung hat, mussten wir einen anderen Namen finden). Wunsch ist der Einstiegspunkt für Feature.

Der Status ist, wie Sie bereits verstanden haben, der Status der Komponente. Staat ist nicht unveränderlich: Wir können seine internen Werte nicht ändern, aber wir können neue Staaten schaffen. Dies ist die Ausgabe: Jedes Mal, wenn wir einen neuen Status erstellen, übergeben wir ihn an den Rx-Stream.

Nachrichten - eine Komponente zur Verarbeitung von Signalen, die nicht im Status sein sollten; Nachrichten werden während der Erstellung einmal verwendet ( SingleLiveEvent-Problem ). Die Verwendung von Nachrichten ist optional (Sie können Nothing from Kotlin in der Feature-Signatur verwenden).

Auch in Feature muss Reducer vorhanden sein.

Die Funktion kann die folgenden Komponenten enthalten:

  • Akteur - führt asynchrone Aufgaben und / oder bedingte Statusänderungen basierend auf dem aktuellen Status aus (z. B. Formularvalidierung). Der Schauspieler bindet den Wunsch an eine bestimmte Effektnummer und gibt ihn dann an den Reduzierer weiter (in Abwesenheit des Schauspielers erhält der Reduzierer den Wunsch direkt).
  • NewsPublisher - Wird aufgerufen, wenn Wish zu einem Effekt wird, der das Ergebnis als neuen Status erzeugt. Basierend auf diesen Daten entscheidet er, ob Nachrichten erstellt werden sollen.
  • PostProcessor - wird auch nach dem Erstellen eines neuen Status aufgerufen und weiß auch, welche Auswirkungen zu dessen Erstellung geführt haben. Es werden bestimmte zusätzliche Aktionen (Aktionen) gestartet. Aktion - Dies sind „interne Wünsche“ (z. B. Löschen des Caches), die nicht von außen gestartet werden können. Sie werden im Akteur ausgeführt, was zu einer neuen Kette von Effekten und Zuständen führt.
  • Bootstrapper ist eine Komponente, die Aktionen selbst ausführen kann. Seine Hauptfunktion besteht darin, Feature zu initialisieren und / oder externe Quellen mit Action zu korrelieren. Diese externen Quellen können Nachrichten von einem anderen Feature oder Serverdaten sein, die den Status ohne Benutzereingriff ändern sollten.


Das Diagramm kann einfach aussehen:


oder alle oben genannten zusätzlichen Komponenten enthalten:


Das Feature selbst, das die gesamte Geschäftslogik enthält und einsatzbereit ist, sieht nirgendwo einfacher aus:



Was sonst?


Feature, der Eckpfeiler des Frameworks, arbeitet auf konzeptioneller Ebene. Aber die Bibliothek hat noch viel mehr zu bieten.

  • Da alle Komponenten von Feature deterministisch sind (mit Ausnahme von Actor, das nicht vollständig deterministisch ist, weil es mit externen Datenquellen interagiert, aber selbst damit wird der von ihm ausgeführte Zweig durch die Eingabedaten und nicht durch externe Bedingungen bestimmt), kann jede von ihnen in Middleware eingeschlossen werden. Gleichzeitig enthält die Bibliothek bereits vorgefertigte Lösungen für die Protokollierung und das Debuggen von Zeitreisen .
  • Middleware gilt nicht nur für Features, sondern auch für alle anderen Objekte, die die Consumer <T> -Schnittstelle implementieren. Dies macht sie zu einem unverzichtbaren Debugging-Tool.
  • Wenn Sie einen Debugger zum Debuggen verwenden, während Sie sich in die entgegengesetzte Richtung bewegen, können Sie das DebugDrawer- Modul implementieren.
  • Die Bibliothek enthält ein IDEA-Plugin, mit dem Vorlagen für die gängigsten Implementierungen von Feature hinzugefügt werden können, was viel Zeit spart.
  • Es gibt Hilfsklassen zur Unterstützung von Android, aber die Bibliothek selbst ist nicht an Android gebunden.
  • Es gibt eine vorgefertigte Lösung zum Binden von Komponenten an die Benutzeroberfläche und untereinander über eine elementare API (dies wird im nächsten Artikel erläutert).

Wir hoffen, dass Sie unsere Bibliothek ausprobieren und ihre Nutzung Ihnen genauso viel Freude bereiten wird wie uns - ihre Erstellung!

Am 24. und 25. November können Sie sich versuchen und sich uns anschließen! Wir werden eine mobile Einstellungsveranstaltung abhalten: An einem Tag wird es möglich sein, alle Phasen der Auswahl zu durchlaufen und ein Angebot zu erhalten. Meine Kollegen aus iOS- und Android-Teams werden kommen, um mit Kandidaten in Moskau zu kommunizieren. Wenn Sie aus einer anderen Stadt kommen, fallen für Badoo Reisekosten an. Um eine Einladung zu erhalten, durchlaufen Sie den Screening-Test unter dem Link . Viel Glück

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


All Articles