Architektur und Design von Android-Anwendungen (meine Erfahrung)

Habr, hallo!

Heute möchte ich über die Architektur sprechen, der ich in meinen Android-Anwendungen folge. Ich nehme Clean Architecture als Basis und verwende die Android-Architekturkomponenten (ViewModel, LiveData, LiveEvent) + Kotlin Coroutines als Tools. Im Anhang befindet sich ein fiktiver Beispielcode, der auf GitHub verfügbar ist .

Haftungsausschluss


Ich möchte meine Entwicklungserfahrung teilen, ich gebe in keiner Weise vor, dass meine Lösung die einzig wahre und fehlerfreie ist. Die Architektur der Anwendung ist eine Art Modell, das wir zur Lösung eines bestimmten Problems auswählen. Für das ausgewählte Modell ist die Angemessenheit der Anwendung auf eine bestimmte Aufgabe wichtig.

Problem: Warum brauchen wir Architektur?


Die meisten Projekte, an denen ich teilgenommen habe, haben das gleiche Problem: Die Anwendungslogik befindet sich in der Android-Umgebung, was zu einer großen Menge an Code in Fragment and Activity führt. Somit ist der Code von Abhängigkeiten umgeben, die überhaupt nicht benötigt werden, Unit-Tests werden fast unmöglich sowie die Wiederverwendung. Fragmente werden im Laufe der Zeit zu Gottobjekten, selbst kleine Änderungen führen zu Fehlern, die Unterstützung eines Projekts wird teuer und emotional teuer.

Es gibt Projekte, die überhaupt keine Architektur haben (hier ist alles klar, es gibt keine Fragen an sie), es gibt Projekte mit einem Anspruch auf Architektur, aber genau die gleichen Probleme treten dort trotzdem auf. Jetzt ist es in Mode, Clean Architecture in Android zu verwenden. Ich habe oft gesehen, dass Clean Architecture darauf beschränkt ist, Repositorys und Skripte zu erstellen, die diese Repositorys aufrufen und nichts anderes tun. Noch schlimmer: Solche Skripte geben Modelle aus aufgerufenen Repositorys zurück. Und in einer solchen Architektur macht es überhaupt keinen Sinn. Und weil Da Skripte einfach die erforderlichen Repositorys aufrufen, ruht die Logik häufig auf dem ViewModel oder, noch schlimmer, in Fragmenten und Aktivitäten. All dies wird dann zu einem Chaos, das nicht automatisch getestet werden kann.

Der Zweck von Architektur und Design


Der Zweck der Architektur besteht darin, unsere Geschäftslogik von den Details zu trennen. Mit Details meine ich zum Beispiel externe APIs (wenn wir einen Client für einen REST-Service entwickeln), Android - eine Umgebung (Benutzeroberfläche, Services) usw. Im Kern verwende ich eine saubere Architektur, jedoch mit meinen Implementierungsannahmen.

Der Zweck des Entwurfs besteht darin, die Benutzeroberfläche, die API, die Geschäftslogik und die Modelle so miteinander zu verknüpfen, dass sich all dies für automatische Tests eignet, lose gekoppelt ist und leicht erweitert werden kann. Im Design verwende ich Android-Architekturkomponenten.

Architektur sollte für mich folgende Kriterien erfüllen:

  1. Die Benutzeroberfläche ist so einfach wie möglich und hat nur drei Funktionen:
  2. Präsentieren Sie dem Benutzer Daten. Die Daten werden angezeigt. Dies ist die Hauptfunktion der Benutzeroberfläche. Hier finden Sie Widgets, Animationen, Fragmente usw.
  3. . ViewModel LiveData.
  4. . framework, . .
  5. - . .


Das schematische Diagramm der Architektur ist in der folgenden Abbildung dargestellt:

Bild

Wir bewegen uns in Schichten von unten nach oben, und die darunter liegende Schicht weiß nichts über die darüber liegende Schicht. Und die oberste Ebene bezieht sich nur auf eine Ebene, die eine Ebene tiefer liegt. Jene. Die API-Schicht kann nicht auf eine Domäne verweisen.

Eine Domänenschicht enthält eine Geschäftsentität mit einer eigenen Logik. Normalerweise gibt es Entitäten, die ohne Anwendung existieren. Beispielsweise kann es für eine Bank Darlehensunternehmen mit komplexer Logik zur Berechnung von Zinsen usw. geben.

Die Anwendungslogikschicht enthält Skripte für die Anwendung selbst. Hier werden alle Verbindungen der Anwendung bestimmt, ihr Wesen wird aufgebaut.

Die API-Ebene Android ist nur eine spezifische Implementierung unserer Anwendung in der Android-Umgebung. Im Idealfall kann diese Ebene in alles geändert werden.

, , — . . 2- . , . . TDD , . Android, API ..

Android-.

Bild

Die Logikschicht ist also der Schlüssel, es ist die Anwendung. Nur eine Logikschicht kann auf eine Domäne verweisen und mit dieser interagieren. Außerdem enthält die Logikschicht Schnittstellen, über die die Logik mit den Details der Anwendung (API, Android usw.) interagieren kann. Dies ist das sogenannte Prinzip der Abhängigkeitsinversion, das es der Logik ermöglicht, nicht von Details abhängig zu sein, sondern umgekehrt. Die Logikschicht enthält die Verwendungsszenarien der Anwendung (Anwendungsfälle), die mit unterschiedlichen Daten arbeiten, mit der Domäne, den Repositorys usw. interagieren. In der Entwicklung denke ich gerne in Skripten. Für jede Benutzeraktion oder jedes Ereignis vom System wird ein bestimmtes Skript gestartet, das Eingabe- und Ausgabeparameter sowie nur eine Methode zum Ausführen des Skripts enthält.

Jemand führt ein zusätzliches Konzept eines Interaktors ein, das mehrere Nutzungsszenarien kombinieren und zusätzliche Logik erstellen kann. Aber ich mache das nicht, ich glaube, dass jedes Skript jedes andere Skript erweitern oder einschließen kann, dies erfordert keinen Interaktor. Wenn Sie sich die UML-Schemas ansehen, sehen Sie dort den Zusammenhang zwischen Einschluss und Erweiterung.

Das allgemeine Schema der Anmeldung lautet wie folgt:

Bild

  1. Eine Android-Umgebung wird erstellt (Aktivität, Fragmente usw.).
  2. Ein ViewModel wird erstellt (eines oder mehrere).
  3. ViewModel erstellt die erforderlichen Skripts, die von diesem ViewModel ausgeführt werden können. Szenarien werden am besten mit DI injiziert.
  4. Der Benutzer legt eine Aktion fest.
  5. Jede Komponente der Benutzeroberfläche ist einem Befehl zugeordnet, den sie ausführen kann.
  6. Ein Skript wird mit den erforderlichen Parametern ausgeführt, z. B. Login.execute (Login, Passwort).
  7. DI , . ( api, ). . , , , REST JSON . , , . , . , . - . , . , , . , , .
  8. . ViewModel, UI. LiveData (.9 10).

Jene. Die Schlüsselrolle, die wir haben, ist die Logik und ihr Datenmodell. Wir haben eine doppelte Konvertierung gesehen: Die erste ist die Umwandlung des Repositorys in das Szenariodatenmodell und die zweite ist die Konvertierung, wenn das Skript die Daten als Ergebnis seiner Arbeit an die Umgebung weitergibt. Normalerweise wird das Ergebnis des Skripts an das viewModel zur Anzeige in der Benutzeroberfläche übergeben. Das Skript sollte solche Daten zurückgeben, mit denen das viewModel und die Benutzeroberfläche nichts anderes tun.

Befehle Die

Benutzeroberfläche startet die Skriptausführung mit einem Befehl. In meinen Projekten verwende ich meine eigene Implementierung von Befehlen, sie sind nicht Teil von Architekturkomponenten oder irgendetwas anderem. Im Allgemeinen ist ihre Implementierung einfach. Als tieferes Verständnis der Idee können Sie die Implementierung von Befehlen in reactiveui.net sehenfür c #. Leider kann ich meinen Arbeitscode nicht auslegen, nur eine vereinfachte Implementierung als Beispiel.

Die Hauptaufgabe des Befehls besteht darin, ein Skript auszuführen, die Eingabeparameter zu übergeben und nach der Ausführung das Ergebnis des Befehls (Daten oder Fehlermeldung) zurückzugeben. Normalerweise werden alle Befehle asynchron ausgeführt. Darüber hinaus kapselt das Team die Hintergrundberechnungsmethode. Ich verwende Coroutinen, aber sie können leicht durch RX ersetzt werden, und Sie müssen dies nur in der abstrakten Reihe von Befehlen + Anwendungsfällen tun. Als Bonus kann ein Team seinen Status melden: ob es jetzt ausgeführt wird oder nicht und ob es im Prinzip ausgeführt werden kann. Teams lösen leicht einige Probleme, z. B. das Problem des doppelten Anrufs (wenn der Benutzer während des Vorgangs mehrmals auf die Schaltfläche geklickt hat) oder die Sichtbarkeits- und Stornierungsprobleme.

Beispiel


Implementierungsfunktion: Melden Sie sich mit Login und Passwort bei der Anwendung an.
Das Fenster sollte die Anmelde- und Passworteingabefelder sowie die Schaltfläche „Login“ enthalten. Die Logik der Arbeit ist wie folgt:

  1. Die Schaltfläche "Anmelden" sollte inaktiv sein, wenn der Benutzername und das Passwort weniger als 4 Zeichen enthalten.
  2. Die Schaltfläche "Anmelden" muss während des Anmeldevorgangs inaktiv sein.
  3. Während des Anmeldevorgangs sollte eine Anzeige (Lader) angezeigt werden.
  4. Wenn die Anmeldung erfolgreich ist, sollte eine Willkommensnachricht angezeigt werden.
  5. Wenn die Anmeldung und / oder das Kennwort falsch sind, sollte über dem Anmeldefeld eine Fehlermeldung angezeigt werden.
  6. Wenn eine Fehlermeldung auf dem Bildschirm angezeigt wird, wird diese Meldung durch jede Eingabe eines Zeichens in die Anmelde- oder Kennwortfelder bis zum nächsten Versuch entfernt.

Sie können dieses Problem auf verschiedene Arten lösen, indem Sie beispielsweise alles in MainActivity einfügen.
Aber ich folge immer der Umsetzung meiner beiden Hauptregeln:

  1. Die Geschäftslogik ist unabhängig von den Details.
  2. Die Benutzeroberfläche ist so einfach wie möglich. Er kümmert sich nur um seine Aufgabe (er präsentiert die Daten, die an ihn übertragen wurden, und sendet auch Befehle vom Benutzer).

So sieht die Anwendung aus:

Bild

MainActivity sieht folgendermaßen aus:

class MainActivity : AppCompatActivity() {

   private val vm: MainViewModel by viewModel()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       bindLoginView()
       bindProgressBar()

       observeAuthorization()
       observeRefreshView()
   }

   private fun bindProgressBar() {
       progressBar.bindVisibleWithCommandIsExecuting(this, vm.loginCommand)
   }

   private fun bindLoginView() {
       loginEdit.bindAfterTextChangedWithCommand(vm.loginValidityCommand)
       passwordEdit.bindAfterTextChangedWithCommand(vm.passwordValidityCommand)

       loginButton.bindCommand(this, vm.loginCommand) {
           LoginParameters(loginEdit.text.toString(), passwordEdit.text.toString())
       }
   }

   private fun observeAuthorization() {
       vm.authorizationSuccessLive.observe(this, Observer {
           showAuthorizeSuccessMsg(it?.data)
       })
       vm.authorizationErrorLive.observe(this, Observer {
           showAuthorizeErrorMsg()
       })
   }

   private fun observeRefreshView() {
       vm.refreshLoginViewLive.observe(this, Observer {
           hideAuthorizeErrorMsg()
       })
   }

   private fun showAuthorizeErrorMsg() {
       loginErrorMsg.isInvisible = false
   }

   private fun hideAuthorizeErrorMsg() {
       loginErrorMsg.isInvisible = true
   }

   private fun showAuthorizeSuccessMsg(name : String?) {
       val msg = getString( R.string.success_login, name)
       Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
   }
}


Die Aktivität ist einfach genug, die UI-Regel wird ausgeführt. Ich habe einige einfache Erweiterungen geschrieben, z. B. bindVisibleWithCommandIsExecuting, um Befehle mit UI-Elementen zu verknüpfen und keinen Code zu duplizieren.

Der Code dieses Beispiels mit Kommentaren ist auf GitHub verfügbar . Bei Interesse können Sie ihn herunterladen und sich damit vertraut machen.

Das ist alles, danke fürs Zuschauen!

All Articles