Reactive Clean Architecture mit Android Architekturkomponenten

N26
12 min readOct 25, 2017

Dieser Artikel ist eine Adaption des Originals “Reactive Clean Architecture with Android Architecture Components” von Lucia Payo ins Deutsche

Wir haben uns gefragt, was wohl geschieht, wenn wir Reactive Clean Architecture und die vor Kurzem veröffentlichten Android Architekturkomponenten einfach zusammenfügen? Genau das haben wir bei N26 ausprobiert und das Ergebnis kann sich sehen lassen:

  • Durch eine klare Trennung der Belange können die Funktionen leichter im Code gesteuert werden.
  • Zudem entsteht so ein einheitliches Konzept, nach dem bestimmte Funktionen aufgebaut werden müssen, sodass die gegenseitige Prüfung der Codes einfacher gestaltet wird.
  • Durch Anwendung dieser Architektur haben wir mehr Inhalte erstellt, die mehrfach verwendet werden können und die Softwareentwicklung beschleunigen.
  • Durch reaktive Programmierung verleihen wir unserem Prozess der Codeerstellung einen höheren Grad an Abstraktion und entlasten uns zugleich ein wenig.

In diesem ersten Beitrag erklären wir dir die grundlegenden Prinzipien der Softwarearchitektur sowie ihre Funktionsweise. Da der Artikel eher theoretisch als praktisch gestaltet ist, haben wir beschlossen, noch einen zweiten Beitrag zu verfassen, der Schritt für Schritt erklärt, wie die Architektur in der Praxis angewandt wird. Zu Demonstrationszwecken haben wir zudem eine kleine App entwickelt, die du in diesem Repository findest.

Was genau bedeutet eigentlich „reaktiv“?

Wir haben festgestellt, dass man RxJava auf verschiedene Arten nutzen kann. Der direkteste Weg führt über asynchrone Aufrufe, bei denen du dich für einen sogenannten Observable registrierst, der schließlich die Werte onNext oder onError ausgibt und den Vorgang danach abschließt. Ein typisches Beispiel dafür sind Observables, die über Retrofit abgerufen werden. Das ist wie ein herkömmlicher Callback auf Steroiden: Du kannst deine Threads kontrollieren, in denen Befehle ganz unkompliziert ausgeführt werden, während dir eine Reihe hochleistungsfähiger Operatoren ermöglicht, deine Ergebnisse zu kombinieren oder zu verändern. Eine tolle Möglichkeit, RxJava zu nutzen — es wird allerdings noch besser.

In den meisten Apps existiert ein Datensatz, der die Kerninformation der App darstellt. Auf diesen Daten basieren alle Funktionen. Bei N26 arbeiten wir zum Beispiel mit verschiedenen Tabs im Hauptfenster, die deine Kredite, Versicherungen, Kontobewegungen etc. anzeigen. In all diesen Views werden die Benutzerdaten angezeigt, die wir aus dem Backend erhalten. Diese Daten bilden das Herzstück der App, auf dem alle Funktionen basieren.

Das N26 Insurance Feature

Was wäre, wenn wir uns über einen unbegrenzten Zeitraum hinweg für diesen Datenstrom registrieren könnten und stets eine aktualisierte Version erhalten würden, wenn sich die Informationen ändern?

Der Unterschied zum vorher beschriebenen Szenario besteht darin, dass der Datenstrom nie abreißt. Man kann sich für diese Daten also einmal registrieren und solange Updates erhalten, bis man den registrierten Observable wieder kündigt. Anders ausgedrückt: Nicht der Datenerzeuger legt fest, wann der Datenstrom versiegt, sondern der Benutzer entscheidet, wann er durch Kündigung des Observables keine Events mehr empfängt. Genau das meinen wir mit dem Begriff „reaktiv“ — es ist die Kernidee, die hinter der in dieser Beitragsserie vorgestellten Architektur steckt.

Reactive Clean Architecture

Die meisten Entwickler sind mit dem Konzept der Clean Architecture vertraut. Sie ist unheimlich nützlich, wenn es um die Trennung von Belangen in einer App geht. Wir haben uns an den grundlegenden Richtlinien für die Clean Architecture orientiert, um unsere Funktionen in drei Schichten aufzuteilen.

Schichten der Clean Architecture

Datenschicht

Die Aufgabe dieser Schicht ist die Handhabung und Verwaltung der verschiedenen Datenquellen. Sie besteht aus drei Hauptkomponenten: dem Repository, den Netzwerkdiensten und dem reaktiven Speicher.

Grundsätzlich geht es darum, Daten aus der externen Quelle durch die Netzwerkdienste zu schleusen, sie im reaktiven Speicher abzulegen (dieser kann als interne Datenquelle betrachtet werden) und die Daten anschließend durch das Repository an die oberste Schicht weiterzuleiten.

Hauptbestandteile der Datenschicht

Das Repository

Das Repository dient als Einsprungpunkt und Schnittstelle nach außen. Nachfolgend sind die vier wichtigsten Vorgänge aufgeführt, die in dieser Schicht ablaufen:

  • Get: Sendet einen unbegrenzten Datenstrom zurück, der Updates für einen festgelegten Datentyp ausgibt, jedoch nicht das tatsächliche Vorhandensein eines Wertes garantiert. Der Datenstrom ist so gestaltet, dass direkt nach der Registrierung des Observables Informationen ausgegeben werden. Wenn der Speicher leer ist, wird der Wert NONE ausgegeben. Darüber hinaus ist der Datenstrom so konzipiert, dass er niemals Fehlermeldungen anzeigen oder abgeschlossen sein kann.
  • Fetch: Dieser Vorgang ruft Daten aus externen Quellen ab (normalerweise APIs) und speichert diese direkt nach dem Empfang. Dieser Vorgang versorgt die „Get“-Funktion mit Daten. Hier kann die Instanz Completable zurückgegeben werden, um anzuzeigen, ob der „Fetch“-Vorgang fehlgeschlagen ist oder abgeschlossen wurde. Bitte beachte, dass es keine onNext-Events in Completable-Instanzen geben kann. Das bedeutet, dass per „Fetch“ abgerufene Daten in keiner Form durch den „Fetch“-Vorgang zurückgeschickt werden können.
  • Request: Dies stellt eine Alternative zum „Fetch“-Vorgang dar, wenn ein unbegrenzter Datenstrom nicht benötigt wird. Zurückgegeben wird eine Single-Instanz, mit der die Daten im nächsten onNext-Event zurückgeschickt werden. Nachfolgend wird der Prozess abgeschlossen bzw. bei Problemen ein Fehler angezeigt.
  • Push, Delete: Bei diesem Vorgang werden Daten an externe Quellen geschickt bzw. Daten von externen Quellen gelöscht. Gibt eine Single- oder Completable-Instanz aus, je nachdem, ob Daten aus diesen Vorgängen zurückgesendet werden.

Der reaktive Speicher

Der reaktive Speicher ist die zugrundeliegende Komponente des „Get“-Vorgangs. Er speichert die Daten und sorgt für einen ununterbrochenen Datenstrom. Es handelt sich um den komplexesten Teil der ganzen Architektur, der mit Sorgfalt gestaltet werden muss. Diese Schicht speichert die Daten, wobei der Zugriff asynchron erfolgt. Daher muss sie sich als ausreichend widerstandsfähig bei nebenläufigen Prozessen erweisen.

Abhängig von der Art der Funktionen in der App gibt es viele verschiedene Wege, diese Komponente einzurichten. Um die Funktionsfähigkeit der Architektur zu gewährleisten, sollte die Schnittstelle folgende Elemente enthalten:

public interface ReactiveStore<Key, Value> {Flowable<Option<Value>> getSingular(@NonNull final Key key);Flowable<Option<List<Value>>> getAll();void storeSingular(@NonNull final Value model);void storeAll(@NonNull final List<Value> modelList);
}
  • Zwei verschiedene Arten von „Get“-Methoden: Sowohl getSingular(id), um einen Datenstrom zu erhalten, der Updates für ein bestimmtes Einzelelement mit einer bestätigten ID ausgibt, als auch getAll(), um einen Datenstrom zu erhalten, der Updates ausgibt, wenn Änderungen an einem beliebigen Element im Speicher vorgenommen werden.
  • Zwei Arten von „Store“-Methoden: storeSingular(object) und storeAll().
  • Wenn storeSingular(object) abgerufen wird, sollte sowohl getSingular(id)als auch getAll() ausgelöst werden.
  • Wenn storeAll() abgerufen wird, sollten neben getAll() auch alle vorhandenen getSingular(id)-Vorgänge ausgelöst werden.
  • Sowohl getAll() als auch getSingular(id) werden direkt nach der Registrierung eines Observables ausgelöst.
  • Sowohl getAll() als auch getSingular(id) geben einen NONE-Wert aus, wenn der Speicher leer ist.

Die Datenströme werden auf Anfrage erstellt. Auf diese Weise wissen wir, wenn Observables für bestimmte Daten registriert werden und aktualisieren den Datenstrom ausschließlich in diesen Fällen.

Solange die Datenströme kontinuierlich aktualisiert werden, können dem Prozess auch andere Vorgänge hinzugefügt werden, darunter replaceAll() oder clear().

Mithilfe dieser Komponente können wir den Prozess der Datenspeicherung in einem oder mehreren Speichertypen koordinieren. Der einfachste Speichertyp würde über einen Memory Cache verfügen. Es kann sich jedoch auch um eine Datenbank, ein Dateisystem, SharedPreferences-Funktionen oder andere Speicherarten handeln, die in den Bereich der persistenten Datenspeicherung fallen. Auch eine Kombination von zwei oder mehreren dieser Methoden ist möglich, zum Beispiel die Nutzung eines Memory Cache zusätzlich zu einer Datenbank. Die Möglichkeiten sind vielfältig: Wir müssen nur den Speicherprozess der Daten steuern und die Datenströme entsprechend mit Informationen versorgen.

Wir möchten auch einige Lösungen mit dir teilen, die wir bei N26 für einen reaktiven Speicher entwickelt haben. Diese stellen wir dir demnächst in verschiedenen Blogeinträgen vor, also bleib uns treu!

Zuordnen geht vor Speichern

Geplant ist, die von den obersten Schichten verwendeten Objekte direkt im Speicher abzulegen. Diese übergeordneten Schichten erstellen aus den Objekten konkrete Anwendungsfälle („Use Cases“). Daher müssen wir gewährleisten, dass die Daten bereits in gutem Zustand gespeichert werden.

Die meisten Apps erhalten ihre Daten aus einer API. Diese basiert auf einem Servicevertrag, der genau darlegt, welche Daten in welchem Format von der API geliefert werden. In 99% aller Fälle wird dieser Vertrag erfüllt — aber was passiert, wenn nicht? Wir könnten natürlich stundenlang nach einem Fehler suchen, davon ausgehend, dass sich ein Bug in den Code der App eingeschlichen hat. Das tatsächliche Problem sind jedoch oft fehlende Feldwerte in der API-Antwort.

Was wäre, wenn wir den Einsprungpunkt der App so gestalten, dass diverse Beschränkungen oder Bedingungen überprüft werden und eine Meldung mit einer ausführlichen Fehlerbeschreibung ausgegeben wird, wenn diese nicht erfüllt sind?

Dies kann zum Beispiel durch die Festlegung zweier POJOs erfolgen. Dabei stellt eines das Objekt aus der externen Quelle dar und wird von uns als unverarbeitetes Objekt („Raw“) bezeichnet. Das zweite POJO ist das zugeordnete Objekt, welches bereits geprüft wurde und bereit zur Speicherung ist und daher als sicheres Objekt („Safe“) bezeichnet wird. Indem wir das „Raw“-Objekt vor dem Speichern in ein „Safe“-Objekt umwandeln, schaffen wir bereits einen ersten Abwehrmechanismus.

Umwandlung von „Raw“-Objekten in „Safe“-Objekte

Unter anderem beseitigen wir in der N26 App soweit es geht die Null-Werte in unserem Code. Stattdessen nutzen wir Options, um das mögliche Fehlen eines Objektes anzuzeigen.

Genauer gesagt verwenden wir die Options-Bibliothek von Tomek Polański.

Der Mapper ist der beste Ort, um einen null-Wert zu beseitigen. Hier können alle Parameter überprüft werden und Option<Parameter> zugeordnet werden, wenn ein Parameter fehlen sollte, der nicht unbedingt erforderlich ist. Sollte ein Parameter hingegen zwingend notwendig für die korrekte Ausführung eines Vorganges sein, kann auch eine Ausnahme erstellt werden.

Domänenschicht

Diese Schicht ist den Daten übergeordnet und dafür zuständig, die in das Repository laufenden Prozesse zu koordinieren. Sie kann ebenfalls Daten zuordnen, um die aus der Datenschicht stammenden Objekte vorzubereiten. So können die Daten von der Darstellungsschicht unkompliziert verarbeitet werden. Wir nennen die Hauptkomponente in dieser Schicht ReactiveInteractor. Wie du im unten abgebildeten Codeausschnitt sehen kannst, haben wir verschiedene Typen dieser Komponente definiert, um die Art der verschiedenen Vorgänge zu beschreiben.

interface SendInteractor<Params, Result> {    Single<Result> getSingle(Option<Params> params);
}
interface DeleteInteractor<Params, Result> {Single<Result> getSingle(Option<Params> params);
}
interface RetrieveInteractor<Params, Object> { Flowable<Object> getBehaviorStream(Option<Params> params);
}
interface RefreshInteractor<Params> { Completable getSingle(Option<Params> params);
}
interface RequestInteractor<Params, Result> { Single<Result> getSingle(Option<Params> params);
}

Sie alle legen einen Option<Params>-Wert als Input fest. So können wir, wenn nötig, Input hinzufügen oder auch einen NONE-Wert festlegen, wenn der jeweilige Vorgang keinen Input erfordert.

Die Komponenten senden je nach Art des Vorgangs unterschiedliche Arten reaktiver Objekte zurück:

  • SendInteractor, DeleteInteractor und RequestInteractor senden eine Single-Instanz zurück, da diese Vorgänge ein Ergebnis liefern und danach abgeschlossen werden.
  • RetrieveInteractor sendet ein Flowable-Signal zurück, da es sich um einen als endlos konzipierten Vorgang handelt. Letzterer kann als Domainvariante des „Get“-Vorgangs in der Datenschicht betrachtet werden, obwohl ein paar signifikante Unterschiede bestehen. Man kann den „Get“-Vorgang der Datenschicht als eine Art „Leitung“ zu verschiedenen Speichertypen verstehen. Er garantiert jedoch nicht, dass sich letztendlich im Speicher tatsächlich ein Wert befindet, weshalb er in einem solchen Fall — wie wir bereits gesehen haben — diesen Status durch die Ausgabe eines NONE-Wertes meldet. Der RetrieveInteractor unterscheidet sich davon, denn hier muss das Vorhandensein eines Wertes bestätigt werden. Daher muss der Interactor alle möglichen Vorkehrungen treffen, um diesen Wert zu erhalten oder bei einem fehlgeschlagenen Vorgang eine Fehlermeldung ausgeben. Dies bedeutet für gewöhnlich, dass ein „Fetch“-Vorgang ausgelöst wird, wenn der Flowable aus dem „Get“-Vorgang der Datenschicht einen NONE-Wert ausgibt.
  • Der RefreshInteractor ist dafür zuständig, die für die Aktualisierung der Datenschicht notwendigen Aktionen durchzuführen. Aufgrund der Art der Datenschicht löst dieser Vorgang den Flowable des RetrieveInteractor aus.

Die ReactiveInteractors können miteinander kombiniert und ineinander verschachtelt werden. So entstehen praktisch mehrere Interactor-Ebenen. Die unterste Interactor-Ebene greift direkt auf das Repository zu. Ein darüber liegender Interactor könnte einen oder mehrere dieser tieferliegenden Interactors nutzen und die Ergebnisse entsprechend zuordnen. Dahinter steckt die Idee, dass jeder Interactor nur eine einzige Aufgabe ausführt. So lassen sich wie bei einem Puzzle einzelne Teile zu einem großen Ganzen zusammensetzen.

Die Domänenschicht wird zu 100% mit Java erstellt, daher finden sich hier keine Objekte, die mit Android Frameworks gestaltet wurden.

Darstellungsschicht

Hierbei handelt es sich um die letzte Schicht, die alle von den Views visualisierten Objekte erstellt sowie die dort stattfindenden Vorgänge ausführt. Es ist auch die Schicht, in der die Android Architecture Components genutzt werden, und zwar insbesondere die Komponenten LiveData und ViewModel.

Das Muster für die Kommunikation mit den Views ist MVVM. Hierbei versorgt das ViewModel die Views mit einem LiveData<ViewEntity>-Objekt zur Verarbeitung. Die Anzeigeelemente in den Views sollten so konzipiert sein, dass sie den aktuellen Status einer speziellen View so akkurat wie möglich wiedergeben. Vor einiger Zeit habe ich einen Blogeintragverfasst, der Möglichkeiten zur Erstellung sicherer Anzeigeelemente beschreibt, denn diese Dinge sind beim Umgang mit RxJava und MVVM zwingend zu beachten.

Bei MVVM-Mustern ist das ViewModel die Komponente, die mit den Views interagiert. Daher liegen insbesondere bei komplexen Bildschirmansichten sehr große ViewModels zugrunde. Wir haben versucht, unsere ViewModels zu vereinfachen, indem wir einige Aufgaben anderen Komponenten zugeteilt haben:

  • Mapper und Umwandlungstools: Sie wandeln die Objekte aus der Domänenschicht in Anzeigeelemente um.
  • Provider: Gelegentlich benötigen wir Elemente aus dem Framework, um unsere Views zu erstellen, z.B. einen bestimmten String. In diesem Fall erstellen wir einen StringProvider, um einen gesonderten Zugang zu dieser Ressource zu schaffen.
  • Utilities: Hier gibt es nicht viel zu sagen. Gemeint sind die gewöhnlichen Utility-Klassen, die Hilfsfunktionen beinhalten.

Das ViewModel sollte keine der Aufgaben übernehmen, die von den oben genannten Komponenten ausgeführt werden. Stattdessen sollte das ViewModel die Erstellung von Anzeigeeinheiten in den Views koordinieren und alle Teile zusammenfügen. Nachdem wir diese Vorkehrungen getroffen hatten, bemerkten wir zahlreiche Vorteile: Die sichtbarste Verbesserung ergab sich durch die deutlich verringerte Größe der ViewModels. Wir konnten alle Vorgänge leichter testen, da jede Komponentenklasse einen klar definierten und beschränkten Verantwortungsbereich besaß. So konnten die Inputs/Outputs schneller herausgestellt sowie die Erwartungen an jede Komponentenklasse eindeutig formuliert werden. Selbstverständlich wurde so die gesamte Darstellungsschicht besser verständlich und einfacher lesbar.

Die Bedeutung gut strukturierter Anzeigeelemente

Häufig begehen wir den Fehler, ein Objekt an die Views zu senden, obwohl dieses nicht dafür geeignet ist. Für gewöhnlich wollen wir keine Zeit darauf verwenden, ein zusätzliches, auf die Views zugeschnittenes Objekt zu erstellen. Daher senden wir einfach das Objekt aus unserer Daten- oder Domänenschicht direkt weiter. Derartige Handlungen sollten vermieden werden, da die View das Objekt ansonsten abschließend verändern muss, um es verarbeiten zu können. So entsteht Code, der ungeprüfte Strukturen enthält.

Aus eigener Erfahrung können wir sagen, dass die Gestaltung der Anzeigeelemente der wichtigste Schritt in der Darstellungsschicht ist. Die Erstellung dieser POJOs ist gewissermaßen das Hauptziel der Darstellungsschicht, denn so wird bestimmt, wie lesbar, verständlich und überprüfbar die gesamte Schicht schlussendlich ist.

Verwende daher ausreichend Zeit für die Gestaltung der Anzeigeelemente. Du hast sicherlich keine Lust, mit einem mangelhaft gestalteten Anzeigeelement arbeiten zu müssen. Wenn die Einrichtung der Darstellungsschicht bereits fortgeschritten ist, sind nachträgliche Anpassungen oft äußerst mühsam.

Asynchroner Charakter der Architektur

Im Umgang mit dieser Art von Architektur ist es wichtig, sich deren asynchronen Charakter vor Augen zu führen. Die Architektur nutzt in großem Stil Datenströme, daher müssen wir uns mit der asynchronen Programmierung vertraut machen. Mit RxJava können wir dies einfach und leistungsstark umsetzen. Große Macht geht jedoch bekanntlich mit großer Verantwortung einher.

Die Architektur beruht auf dem Konzept der ereignisorientierten Programmierung. Du erhältst Events, die sich durch die Datenströme kombinieren, umwandeln oder anderweitig nutzen lassen. Diese Events sind jedoch nur von kurzer Dauer und nicht modifizierbar („Immutable“). Sie existieren zu einem festgelegten Zeitpunkt als Teil einer ganz bestimmten Funktion in der Kette, bis sie in ein anderes Objekt umgewandelt und weitergeschickt werden. VERMEIDE daher:

  • Veränderungen des Objekts im Event — erstelle stattdessen stets ein neues Objekt. Das kannst du durch die konsequente Erstellung ausschließlich nicht modifizierbarer Objekte erreichen. Allgemein stellt dies auch eine bewährte Vorgehensweise dar.
  • Speicherung des Objekts in einer lokalen/globalen Variable. Das Objekt dient ausschließlich der vorliegenden Funktion und sollte nicht außerhalb der Funktion existieren.

Zu guter Letzt sollten alle unnötigen internen Zwischenstadien vermieden werden, die wir gelegentlich erstellen, da wir uns von ihnen eine Erleichterung unserer Arbeit erhoffen. Dazu gehören zum Beispiel Variablen in der jeweiligen Klasse, durch die wir das aktuelle Ergebnis eines Vorganges speichern, um es mit späteren Ergebnissen zu vergleichen oder interne Flags, die anzeigen, dass ein Vorgang bereits ausgeführt wurde. Wenn wir diese Zwischenstadien erstellen, geben wir Kontrolle über das System ab, da wir den asynchronen Charakter unseres Codes ignorieren. Leider können wir ihn nicht wegignorieren, sondern stellen uns sogar selbst eine Falle, da Zwischenstadien fruchtbaren Boden für einen Fehlerstatus oder Wettlaufsituationen bieten. RxJava stellt eine Memory bereit — nutze sie also. Sie wurde speziell dafür entwickelt, in einem reaktiven, asynchronen Programmierumfeld zu arbeiten.

Hier endet nun unser erster Beitrag zu diesem Thema. Wenn du bereit bist, die Architektur anhand eines Praxisbeispiels in Aktion zu erleben, dann findest du hier unseren Folgebeitrag!

--

--

N26

We’re Europe’s first mobile bank. Follow us for articles about banking, technology and expat life.