Gängige Modularisierungsmuster

Es gibt keine einzige Modularisierungsstrategie, die für alle Projekte geeignet ist. Aufgrund der Flexibilität von Gradle gibt es nur wenige Einschränkungen im Hinblick auf die Organisation eines Projekts. Auf dieser Seite erhalten Sie einen Überblick über einige allgemeine Regeln und gängige Muster, die Sie bei der Entwicklung von mehrmoduligen Android-Apps verwenden können.

Prinzip hoher Kohäsion und niedriger Kopplung

Eine Möglichkeit zur Charakterisierung einer modularen Codebasis wäre die Verwendung der Attribute Kopplung und Kohäsion. Die Kopplung misst, inwieweit die Module voneinander abhängig sind. Kohäsion misst in diesem Kontext die funktionale Beziehung der Elemente eines einzelnen Moduls. Im Allgemeinen sollten Sie eine niedrige Kopplung und hohe Kohäsion anstreben:

  • Geringe Kopplung bedeutet, dass Module so unabhängig wie möglich voneinander sein sollten, sodass Änderungen an einem Modul keine oder nur minimale Auswirkungen auf andere Module haben. Module sollten nicht über den internen Ablauf anderer Module informiert werden.
  • Hohe Kohäsion bedeutet, dass Module eine Codesammlung umfassen sollten, die als System fungiert. Sie sollten klar definierte Verantwortlichkeiten haben und innerhalb der Grenzen eines bestimmten Domänenwissens bleiben. Nehmen wir als Beispiel eine E-Book-Anwendung. Es kann unangemessen sein, buch- und zahlungsbezogenen Code zusammen im selben Modul zu kombinieren, da es sich um zwei verschiedene funktionale Domains handelt.

Modultypen

Wie Sie Ihre Module organisieren, hängt hauptsächlich von Ihrer Anwendungsarchitektur ab. Im Folgenden finden Sie einige gängige Modultypen, die Sie in Ihre Anwendung einführen können, während Sie unserer empfohlenen Anwendungsarchitektur folgen.

Datenmodule

Ein Datenmodul enthält in der Regel ein Repository, Datenquellen und Modellklassen. Die drei Hauptaufgaben eines Datenmoduls sind:

  1. Alle Daten und Geschäftslogik einer bestimmten Domain kapseln: Jedes Datenmodul sollte für die Verarbeitung von Daten einer bestimmten Domain zuständig sein. Sie kann viele Datentypen verarbeiten, solange sie zusammenhängen.
  2. Repository als externe API freigeben: Die öffentliche API eines Datenmoduls sollte ein Repository sein, da es dafür verantwortlich ist, die Daten für den Rest der Anwendung verfügbar zu machen.
  3. Alle Implementierungsdetails und Datenquellen von außen ausblenden: Datenquellen sollten nur für Repositories über dasselbe Modul zugänglich sein. Sie bleiben nach außen verborgen. Sie können dies erzwingen, indem Sie das Sichtbarkeits-Keyword private oder internal von Kotlin verwenden.
Abbildung 1: Beispieldatenmodule und ihre Inhalte

Funktionsmodule

Eine Funktion ist ein isolierter Teil der Funktionalität einer App. Sie entspricht in der Regel einem Bildschirm oder einer Reihe eng miteinander verbundener Bildschirme, z. B. einem Registrierungs- oder Bezahlvorgang. Wenn Ihre App eine Navigationsleiste am unteren Rand hat, ist wahrscheinlich jedes Ziel eine Funktion.

Abbildung 2: Jeder Tab dieser Anwendung kann als Funktion definiert werden.

Features sind mit Bildschirmen oder Zielen in Ihrer App verknüpft. Daher ist ihnen wahrscheinlich eine UI und ein ViewModel zugeordnet, um ihre Logik und ihren Status zu verwalten. Ein einzelnes Feature muss nicht auf eine einzelne Ansicht oder ein einzelnes Navigationsziel beschränkt sein. Funktionsmodule sind von Datenmodulen abhängig.

Abbildung 3: Beispiele für Funktionsmodule und deren Inhalte

App-Module

App-Module sind ein Einstiegspunkt für die Anwendung. Sie sind von Funktionsmodulen abhängig und bieten in der Regel die Root-Navigation. Ein einzelnes Anwendungsmodul kann mithilfe von Build-Varianten in eine Reihe verschiedener Binärprogramme kompiliert werden.

Abbildung 4: Grafik zur *Demo-Version* und *vollständigen* Produkt-Flavor-Modulen.

Wenn deine App auf mehrere Gerätetypen ausgerichtet ist, z. B. Autos, Wearables oder Fernseher, definiere für jeden ein App-Modul. Dies hilft, plattformspezifische Abhängigkeiten zu trennen.

Abbildung 5: Grafik zur Wear-App-Abhängigkeit.

Gängige Module

Gängige Module, auch als Kernmodule bezeichnet, enthalten Code, der von anderen Modulen häufig verwendet wird. Sie reduzieren Redundanz und stellen keine bestimmte Schicht in der Architektur einer Anwendung dar. Im Folgenden finden Sie Beispiele für gängige Module:

  • UI-Modul: Wenn Sie in Ihrer App benutzerdefinierte UI-Elemente oder aufwendiges Branding verwenden, sollten Sie die Widget-Sammlung in einem Modul zusammenfassen, damit alle Funktionen wiederverwendet werden können. Dies könnte dazu beitragen, Ihre UI für verschiedene Funktionen einheitlich zu gestalten. Wenn das Design z. B. zentralisiert ist, können Sie mühsame Refaktorierungen vermeiden, wenn es zu einem Rebranding kommt.
  • Analysemodul: Das Tracking wird oft durch Geschäftsanforderungen vorgegeben, ohne die Softwarearchitektur zu berücksichtigen. Analytics-Tracker werden oft in vielen nicht verwandten Komponenten verwendet. Wenn das bei Ihnen der Fall ist, kann es sinnvoll sein, ein eigenes Analysemodul zu haben.
  • Netzwerkmodul: Wenn viele Module eine Netzwerkverbindung benötigen, können Sie ein Modul speziell für die Bereitstellung eines HTTP-Clients nutzen. Dies ist besonders nützlich, wenn Ihr Client eine benutzerdefinierte Konfiguration benötigt.
  • Dienstprogrammmodul: Dienstprogramme, auch als Hilfsprogramme bezeichnet, sind in der Regel kleine Codeabschnitte, die in der Anwendung wiederverwendet werden. Beispiele für Dienstprogramme sind Testhelfer, eine Währungsformatierungsfunktion, E-Mail-Validator oder ein benutzerdefinierter Operator.

Module testen

Testmodule sind Android-Module, die nur zu Testzwecken verwendet werden. Die Module enthalten Testcode, Testressourcen und Testabhängigkeiten, die nur zum Ausführen von Tests und nicht während der Laufzeit der Anwendung benötigt werden. Testmodule werden erstellt, um testspezifischen Code von der Hauptanwendung zu trennen. Dadurch wird der Modulcode einfacher verwaltet und gewartet.

Anwendungsfälle für Testmodule

Die folgenden Beispiele zeigen Situationen, in denen die Implementierung von Testmodulen besonders vorteilhaft sein kann:

  • Freigegebener Testcode: Wenn Ihr Projekt mehrere Module hat und ein Teil des Testcodes auf mehr als ein Modul anwendbar ist, können Sie ein Testmodul erstellen, um den Code freizugeben. Dies kann Duplikate reduzieren und den Testcode einfacher pflegen. Freigegebener Testcode kann Dienstprogrammklassen oder -funktionen wie benutzerdefinierte Assertions oder Matcher sowie Testdaten wie simulierte JSON-Antworten enthalten.

  • Übersichtliche Build-Konfigurationen: Testmodule ermöglichen sauberere Build-Konfigurationen, da sie eine eigene build.gradle-Datei haben können. Sie müssen die Datei build.gradle Ihres Anwendungsmoduls nicht mit Konfigurationen überladen, die nur für Tests relevant sind.

  • Integrationstests: Testmodule können zum Speichern von Integrationstests verwendet werden, mit denen Interaktionen zwischen verschiedenen Teilen Ihrer App getestet werden, einschließlich Benutzeroberfläche, Geschäftslogik, Netzwerkanfragen und Datenbankabfragen.

  • Umfangreiche Anwendungen: Testmodule sind besonders nützlich für umfangreiche Anwendungen mit komplexen Codebasis und mehreren Modulen. In solchen Fällen können Testmodule dabei helfen, die Codeorganisation und die Verwaltbarkeit zu verbessern.

Abbildung 6: Mit Testmodulen können Module isoliert werden, die andernfalls voneinander abhängig wären.

Modul-zu-Modul-Kommunikation

Module sind selten vollständig getrennt. Sie basieren oft auf anderen Modulen und kommunizieren mit ihnen. Es ist wichtig, die Kopplung gering zu halten, auch wenn Module zusammenarbeiten und häufig Informationen austauschen. Manchmal ist eine direkte Kommunikation zwischen zwei Modulen nicht wünschenswert, z. B. im Fall von Architektureinschränkungen. Außerdem ist es unter Umständen unmöglich, z. B. bei zyklischen Abhängigkeiten.

Abbildung 7: Aufgrund zyklischer Abhängigkeiten ist eine direkte bidirektionale Kommunikation zwischen Modulen nicht möglich. Ein Vermittlungsmodul ist erforderlich, um den Datenfluss zwischen zwei anderen unabhängigen Modulen zu koordinieren.

Ein drittes Modul vermittelt zwischen zwei anderen Modulen, um dieses Problem zu umgehen. Das Mediatormodul kann Nachrichten von beiden Modulen empfangen und nach Bedarf weiterleiten. In unserer Beispiel-App muss der Zahlungsbildschirm wissen, welches Buch gekauft werden soll, auch wenn das Ereignis von einem separaten Bildschirm ausgeht, der Teil einer anderen Funktion ist. In diesem Fall ist der Vermittler das Modul, zu dem die Navigationsgrafik gehört (normalerweise ein App-Modul). Im Beispiel verwenden wir die Navigation, um die Daten mithilfe der Komponente Navigation von der Startseitenfunktion an die Bezahlfunktion zu übergeben.

navController.navigate("checkout/$bookId")

Die Zielanwendung für den Bezahlvorgang empfängt eine Buch-ID als Argument, mit dem Informationen über das Buch abgerufen werden. Sie können das gespeicherte Status-Handle verwenden, um Navigationsargumente in der ViewModel eines Zielfeatures abzurufen.

class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // produce UI state calling bookRepository.getBook(bookId)
      }
      …
}

Objekte sollten nicht als Navigationsargumente übergeben werden. Verwenden Sie stattdessen einfache IDs, mit denen Funktionen auf die gewünschten Ressourcen in der Datenschicht zugreifen und diese laden können. Auf diese Weise halten Sie die Kopplung gering und verstoßen nicht gegen das Single-Source-of-Truth-Prinzip.

Im folgenden Beispiel hängen beide Funktionsmodule von demselben Datenmodul ab. Dadurch kann die Datenmenge, die das Vermittlermodul weiterleiten muss, minimiert und die Kopplung zwischen den Modulen gering gehalten werden. Anstatt Objekte zu übergeben, sollten Module einfache IDs austauschen und die Ressourcen aus einem gemeinsam genutzten Datenmodul laden.

Abbildung 8: Zwei Funktionsmodule basieren auf einem gemeinsam genutzten Datenmodul.

Abhängigkeitsinversion

Eine Abhängigkeitsinversion liegt vor, wenn Sie Ihren Code so organisieren, dass die Abstraktion von einer konkreten Implementierung getrennt ist.

  • Abstraktion: Ein Vertrag, der definiert, wie Komponenten oder Module in Ihrer Anwendung miteinander interagieren. Abstraktionsmodule definieren die API Ihres Systems und enthalten Schnittstellen und Modelle.
  • Konkrete Implementierung: Module, die vom Abstraktionsmodul abhängig sind und das Verhalten einer Abstraktion implementieren.

Module, die auf dem im Abstraktionsmodul definierten Verhalten basieren, sollten nur von der Abstraktion selbst und nicht von den spezifischen Implementierungen abhängen.

Abbildung 9: Anstelle von High-Level-Modulen, die direkt von Low-Level-Modulen abhängig sind, hängen High-Level- und Implementierungsmodule vom Abstraktionsmodul ab.

Beispiel

Stellen Sie sich ein Funktionsmodul vor, das eine Datenbank benötigt, um zu funktionieren. Das Funktionsmodul spielt keine Rolle für die Implementierung der Datenbank, z. B. eine lokale Raumdatenbank oder eine Remote-Firestore-Instanz. Er muss nur die Anwendungsdaten speichern und lesen.

Dazu hängt das Funktionsmodul vom Abstraktionsmodul und nicht von einer bestimmten Datenbankimplementierung ab. Diese Abstraktion definiert die Datenbank-API der Anwendung. Mit anderen Worten, er legt die Regeln für die Interaktion mit der Datenbank fest. So kann das Feature-Modul jede Datenbank verwenden, ohne die zugrunde liegenden Implementierungsdetails kennen zu müssen.

Das konkrete Implementierungsmodul liefert die tatsächliche Implementierung der im Abstraktionsmodul definierten APIs. Dazu hängt das Implementierungsmodul auch vom Abstraktionsmodul ab.

Abhängigkeitsinjektion

Sie fragen sich jetzt vielleicht, wie das Feature-Modul mit dem Implementierungsmodul verbunden ist. Die Antwort lautet Abhängigkeitsinjektion. Das Funktionsmodul erstellt die erforderliche Datenbankinstanz nicht direkt. Stattdessen gibt sie an, welche Abhängigkeiten es benötigt. Diese Abhängigkeiten werden dann extern bereitgestellt, in der Regel im Anwendungsmodul.

releaseImplementation(project(":database:impl:firestore"))

debugImplementation(project(":database:impl:room"))

androidTestImplementation(project(":database:impl:mock"))

Vorteile

Das Trennen von APIs und deren Implementierungen bietet folgende Vorteile:

  • Austauschbarkeit: Durch eine klare Trennung von API und Implementierungsmodulen können Sie mehrere Implementierungen für dieselbe API entwickeln und zwischen ihnen wechseln, ohne den Code zu ändern, der die API verwendet. Dies kann besonders in Szenarien von Vorteil sein, in denen Sie unterschiedliche Funktionen oder Verhaltensweisen in verschiedenen Kontexten bereitstellen möchten. Zum Beispiel eine fiktive Implementierung für Tests und eine echte Implementierung für die Produktion.
  • Entkopplung: Durch die Trennung sind Module, die Abstraktionen verwenden, nicht von einer bestimmten Technologie abhängig. Wenn Sie sich später dafür entscheiden, Ihre Datenbank von Room zu Firestore zu ändern, wäre es einfacher, da die Änderungen nur in dem jeweiligen Modul erfolgen, das den Job ausführt (Implementierungsmodul), und keine Auswirkungen auf andere Module haben, die die API Ihrer Datenbank verwenden.
  • Testbarkeit: Das Trennen von APIs von ihren Implementierungen kann das Testen erheblich vereinfachen. Sie können Testfälle für die API-Verträge schreiben. Sie können auch verschiedene Implementierungen verwenden, um verschiedene Szenarien und Grenzfälle zu testen, einschließlich simulierter Implementierungen.
  • Verbesserte Build-Leistung: Wenn Sie eine API und ihre Implementierung in verschiedene Module unterteilen, zwingen Änderungen am Implementierungsmodul das Build-System nicht dazu, die Module abhängig vom API-Modul neu zu kompilieren. Dies führt zu kürzeren Build-Zeiten und erhöhter Produktivität, insbesondere bei großen Projekten, bei denen die Build-Zeiten länger sein können.

Wann trennen

In den folgenden Fällen ist es sinnvoll, Ihre APIs von ihren Implementierungen zu trennen:

  • Unterschiedliche Funktionen: Wenn Sie Teile Ihres Systems auf mehrere Arten implementieren können, ermöglicht eine klare API die Austauschbarkeit verschiedener Implementierungen. Vielleicht haben Sie ein Renderingsystem, das OpenGL oder Vulkan verwendet, oder ein Abrechnungssystem, das mit Play oder Ihrer internen Abrechnungs-API funktioniert.
  • Mehrere Anwendungen: Wenn Sie mehrere Anwendungen mit gemeinsamen Funktionen für verschiedene Plattformen entwickeln, können Sie allgemeine APIs definieren und spezifische Implementierungen pro Plattform entwickeln.
  • Unabhängige Teams: Durch die Trennung können verschiedene Entwickler oder Teams gleichzeitig an verschiedenen Teilen der Codebasis arbeiten. Entwickler sollten sich darauf konzentrieren, die API-Verträge zu verstehen und richtig zu verwenden. Sie müssen sich keine Gedanken um die Implementierungsdetails anderer Module machen.
  • Große Codebasis: Wenn die Codebasis groß oder komplex ist, wird der Code durch eine Trennung der API von der Implementierung einfacher verwaltet. Sie können damit die Codebasis in detailliertere, verständliche und verwaltbare Einheiten unterteilen.

Implementierung

So implementieren Sie die Abhängigkeitsinversion:

  1. Abstraktionsmodul erstellen: Dieses Modul sollte APIs (Schnittstellen und Modelle) enthalten, die das Verhalten Ihrer Funktion definieren.
  2. Implementierungsmodule erstellen: Implementierungsmodule sollten auf dem API-Modul basieren und das Verhalten einer Abstraktion implementieren.
    Anstelle von High-Level-Modulen, die direkt von Low-Level-Modulen abhängig sind, hängen High-Level- und Implementierungsmodule vom Abstraktionsmodul ab.
    Abbildung 10: Implementierungsmodule sind vom Abstraktionsmodul abhängig.
  3. Übergeordnete Module von Abstraktionsmodulen abhängig machen: Anstatt deine Module direkt von einer bestimmten Implementierung abhängig zu machen, solltest du deine Module von Abstraktionsmodulen abhängig machen. Für übergeordnete Module sind keine Implementierungsdetails erforderlich, sondern nur der Vertrag (API).
    Für übergeordnete Module sind Abstraktionen erforderlich, nicht Implementierung.
    Abbildung 11: Allgemeine Module hängen von Abstraktionen ab, nicht von Implementierung.
  4. Implementierungsmodul bereitstellen: Zuletzt müssen Sie die tatsächliche Implementierung der Abhängigkeiten bereitstellen. Die spezifische Implementierung hängt von der Einrichtung Ihres Projekts ab. Normalerweise ist das App-Modul dafür jedoch gut geeignet. Geben Sie die Implementierung als Abhängigkeit für die ausgewählte Build-Variante oder einen Testquellensatz an.
    Das App-Modul liefert die tatsächliche Implementierung.
    Abbildung 12: Das App-Modul stellt die tatsächliche Implementierung bereit.

Allgemeine Best Practices

Wie zu Beginn erwähnt, gibt es nicht den einen richtigen Weg, eine Anwendung mit mehreren Modulen zu entwickeln. Genau wie es viele Softwarearchitekturen gibt, gibt es zahlreiche Möglichkeiten, eine Anwendung zu modularisieren. Dennoch können die folgenden allgemeinen Empfehlungen Ihnen helfen, Ihren Code lesbarer, wartungsfreundlicher und testbarer zu gestalten.

Konfiguration einheitlich halten

Jedes Modul führt zu einem zusätzlichen Konfigurationsaufwand. Wenn die Anzahl Ihrer Module einen bestimmten Grenzwert erreicht, wird die Verwaltung einheitlicher Konfigurationen zu einer Herausforderung. Es ist beispielsweise wichtig, dass Module Abhängigkeiten derselben Version verwenden. Wenn Sie eine große Anzahl von Modulen aktualisieren müssen, um eine Abhängigkeitsversion zu ändern, ist dies nicht nur ein Aufwand, sondern auch ein Raum für potenzielle Fehler. Zur Lösung dieses Problems können Sie eines der Gradle-Tools verwenden, um Ihre Konfiguration zu zentralisieren:

  • Versionskataloge sind eine typsichere Liste von Abhängigkeiten, die von Gradle während der Synchronisierung generiert werden. Hier können Sie alle Abhängigkeiten angeben und steht allen Modulen in einem Projekt zur Verfügung.
  • Sie können Konventionen-Plug-ins verwenden, um die Build-Logik zwischen Modulen zu teilen.

So wenig wie möglich ausstrahlen

Die öffentliche Schnittstelle eines Moduls sollte minimal sein und nur das Wesentliche enthalten. Es sollten keine Implementierungsdetails an Dritte weitergegeben werden. Erfasst alles soweit wie möglich. Verwenden Sie den Sichtbarkeitsbereich private oder internal von Kotlin, um die Deklarationen auf „privat“ zu setzen. Beim Deklarieren von Abhängigkeiten in Ihrem Modul sollten Sie implementation gegenüber api bevorzugen. Letzteres stellt den Nutzern Ihres Moduls vorübergehende Abhängigkeiten zur Verfügung. Durch die Implementierung kann die Build-Zeit verkürzt werden, da dadurch die Anzahl der Module reduziert wird, die neu erstellt werden müssen.

Kotlin- und Java-Module bevorzugen

Android Studio unterstützt drei grundlegende Modultypen:

  • App-Module sind ein Einstiegspunkt für Ihre Anwendung. Sie können Quellcode, Ressourcen, Assets und eine AndroidManifest.xml enthalten. Die Ausgabe eines App-Moduls ist ein Android App Bundle (AAB) oder ein Android Application Package (APK).
  • Bibliotheksmodule haben denselben Inhalt wie die App-Module. Sie werden von anderen Android-Modulen als Abhängigkeit verwendet. Die Ausgabe eines Bibliotheksmoduls ist ein Android-Archiv (AAR) und ist strukturell identisch mit App-Modulen. Sie werden jedoch zu einer Android-Archivdatei (AAR) kompiliert, die später von anderen Modulen als Abhängigkeit verwendet werden kann. Mit einem Bibliotheksmodul können Sie dieselbe Logik und dieselben Ressourcen in vielen Anwendungsmodulen kapseln und wiederverwenden.
  • Kotlin- und Java-Bibliotheken enthalten keine Android-Ressourcen, Assets oder Manifestdateien.

Da Android-Module mit Mehraufwand verbunden sind, empfiehlt es sich, möglichst oft Kotlin oder Java zu verwenden.