Es gibt keine einzelne Modularisierungsstrategie, die für alle Projekte geeignet ist. Aufgrund der Flexibilität von Gradle gibt es nur wenige Einschränkungen hinsichtlich der Organisation eines Projekts. Auf dieser Seite finden Sie eine Übersicht über einige allgemeine Regeln und gängige Muster, die Sie bei der Entwicklung von Android-Apps mit mehreren Modulen anwenden können.
Prinzip der hohen Kohäsion und geringen Kopplung
Eine Möglichkeit, eine modulare Codebasis zu charakterisieren, besteht darin, die Eigenschaften Kopplung und Kohäsion zu verwenden. Die Kopplung gibt an, inwieweit Module voneinander abhängig sind. Kohäsion misst in diesem Zusammenhang, wie die Elemente eines einzelnen Moduls funktional zusammenhängen. Im Allgemeinen sollten Sie eine lose Kopplung und eine hohe Kohäsion anstreben:
- Geringe Kopplung bedeutet, dass Module so unabhängig wie möglich voneinander sein sollten, damit Änderungen an einem Modul keine oder nur minimale Auswirkungen auf andere Module haben. Module sollten keine Kenntnisse über die Funktionsweise anderer Module haben.
- Hohe Kohäsion bedeutet, dass Module eine Sammlung von Code umfassen sollten, die als System fungiert. Sie sollten klar definierte Verantwortlichkeiten haben und sich an die Grenzen bestimmter Fachkenntnisse halten. Sehen wir uns eine Beispielanwendung für E-Books an. Es ist möglicherweise nicht angemessen, Buch- und zahlungsbezogenen Code im selben Modul zu mischen, da es sich um zwei verschiedene funktionale Bereiche handelt.
Arten von Modulen
Wie Sie Ihre Module organisieren, hängt hauptsächlich von der Architektur Ihrer App ab. Nachfolgend finden Sie einige gängige Modultypen, die Sie in Ihre App einfügen können, wenn Sie unserer empfohlenen App-Architektur folgen.
Datenmodule
Ein Datenmodul enthält in der Regel ein Repository, Datenquellen und Modellklassen. Die drei Hauptaufgaben eines Datenmoduls sind:
- Alle Daten und Geschäftslogik einer bestimmten Domain kapseln: Jedes Datenmodul sollte für die Verarbeitung von Daten verantwortlich sein, die eine bestimmte Domain repräsentieren. Es kann viele Arten von Daten verarbeiten, solange sie in Beziehung zueinander stehen.
- Repository als externe API bereitstellen: Die öffentliche API eines Datenmoduls sollte ein Repository sein, da sie dafür verantwortlich ist, die Daten für den Rest der App bereitzustellen.
- Alle Implementierungsdetails und Datenquellen nach außen hin verbergen: Datenquellen sollten nur über Repositorys aus demselben Modul zugänglich sein.
Sie bleiben nach außen hin verborgen. Sie können dies mit dem Sichtbarkeits-Schlüsselwort
privateoderinternalvon Kotlin erzwingen.
Funktionsmodule
Ein Feature ist ein isolierter Teil der Funktionalität einer App, der in der Regel einem Bildschirm oder einer Reihe von eng verwandten Bildschirmen entspricht, z. B. einem Registrierungs- oder Abrechnungsvorgang. Wenn Ihre App eine Navigationsleiste unten hat, ist es wahrscheinlich, dass jedes Ziel ein Feature ist.
Funktionen sind mit Bildschirmen oder Zielen in Ihrer App verknüpft. Daher haben sie wahrscheinlich eine zugehörige Benutzeroberfläche und ViewModel, um ihre Logik und ihren Status zu verarbeiten. Eine einzelne Funktion muss nicht auf eine einzelne Ansicht oder ein einzelnes Navigationsziel beschränkt sein. Funktionsmodule sind von Datenmodulen abhängig.
App-Module
App-Module sind ein Einstiegspunkt in die Anwendung. Sie hängen von Funktionsmodulen ab und bieten in der Regel die Root-Navigation. Ein einzelnes App-Modul kann dank Build-Varianten in eine Reihe verschiedener Binärdateien kompiliert werden.
Wenn Ihre App auf mehrere Gerätetypen ausgerichtet ist, z. B. Android Auto, Wear oder TV, definieren Sie für jeden Gerätetyp ein App-Modul. So lassen sich plattformspezifische Abhängigkeiten trennen.
Häufig verwendete Module
Häufig verwendete Module, auch als Kernmodule bezeichnet, enthalten Code, der häufig von anderen Modulen verwendet wird. Sie reduzieren Redundanz und stellen keine bestimmte Ebene in der Architektur einer App dar. Im Folgenden finden Sie Beispiele für gängige Module:
- UI-Modul: Wenn Sie benutzerdefinierte UI-Elemente oder ein aufwendiges Branding in Ihrer App verwenden, sollten Sie Ihre Widget-Sammlung in einem Modul für alle Funktionen kapseln, die wiederverwendet werden sollen. So können Sie die Benutzeroberfläche für verschiedene Funktionen einheitlich gestalten. Wenn Ihr Theming beispielsweise zentralisiert ist, können Sie bei einem Rebranding eine aufwendige Umgestaltung vermeiden.
- Analysemodul: Das Tracking wird oft durch geschäftliche Anforderungen bestimmt, wobei die Softwarearchitektur kaum berücksichtigt wird. Analytics-Tracker werden häufig in vielen unabhängigen Komponenten verwendet. Wenn das bei Ihnen der Fall ist, kann es sinnvoll sein, ein separates Analysemodul zu verwenden.
- Netzwerkmodul: Wenn viele Module eine Netzwerkverbindung benötigen, sollten Sie ein Modul in Betracht ziehen, das einen HTTP-Client bereitstellt. Das ist besonders nützlich, wenn Ihr Client eine benutzerdefinierte Konfiguration erfordert.
- Utility-Modul: Dienstprogramme, auch als Helfer bezeichnet, sind in der Regel kleine Codeabschnitte, die in der gesamten Anwendung wiederverwendet werden. Beispiele für Dienstprogramme sind Testhilfen, eine Funktion zur Währungsformatierung, ein 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 erforderlich sind und nicht während der Laufzeit der Anwendung. Testmodule werden erstellt, um testspezifischen Code von der Hauptanwendung zu trennen. So lässt sich der Modulcode einfacher verwalten und pflegen.
Anwendungsfälle für Testmodule
Die folgenden Beispiele veranschaulichen Situationen, in denen die Implementierung von Testmodulen besonders vorteilhaft sein kann:
Gemeinsamer Testcode: Wenn Ihr Projekt mehrere Module enthält und ein Teil des Testcodes für mehr als ein Modul gilt, können Sie ein Testmodul erstellen, um den Code freizugeben. So können Sie Duplikate reduzieren und Ihren Testcode leichter pflegen. Gemeinsamer Testcode kann Hilfsklassen oder ‑funktionen wie benutzerdefinierte Assertions oder Matcher sowie Testdaten wie simulierte JSON-Antworten enthalten.
Sauberere Build-Konfigurationen: Mit Testmodulen können Sie sauberere Build-Konfigurationen erstellen, da sie eine eigene
build.gradle-Datei haben können. Sie müssen diebuild.gradle-Datei Ihres App-Moduls nicht mit Konfigurationen überladen, die nur für Tests relevant sind.Integrationstests: In Testmodulen können Integrationstests gespeichert werden, mit denen Interaktionen zwischen verschiedenen Teilen Ihrer App getestet werden, z. B. Benutzeroberfläche, Geschäftslogik, Netzwerkanfragen und Datenbankabfragen.
Große Anwendungen: Testmodule sind besonders nützlich für große Anwendungen mit komplexen Codebasen und mehreren Modulen. In solchen Fällen können Testmodule die Codeorganisation und Wartungsfreundlichkeit verbessern.
Kommunikation zwischen Modulen
Module sind selten völlig unabhängig und sind oft auf andere Module angewiesen 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 erwünscht, z. B. aufgrund von Architekturvorgaben. Das kann auch unmöglich sein, z. B. bei zyklischen Abhängigkeiten.
Um dieses Problem zu beheben, können Sie ein drittes Modul mediating zwischen zwei anderen Modulen einfügen. Das Vermittlermodul kann auf Nachrichten von beiden Modulen warten und sie bei Bedarf weiterleiten. In unserer Beispiel-App muss auf dem Abrechnungsbildschirm bekannt sein, welches Buch gekauft werden soll, obwohl das Ereignis auf einem separaten Bildschirm ausgelöst wurde, der zu einer anderen Funktion gehört. In diesem Fall ist der Mediator das Modul, das den Navigationsgraphen enthält (in der Regel ein App-Modul). Im Beispiel verwenden wir die Navigation, um die Daten von der Startseite zur Funktion für die Kaufabwicklung zu übergeben. Dazu nutzen wir die Komponente Navigation.
navController.navigate("checkout/$bookId")
Das Ziel für die Kaufabwicklung erhält eine Buch-ID als Argument, mit der es Informationen zum Buch abruft. Mit dem Handle für den gespeicherten Status können Sie Navigationsargumente in der ViewModel eines Zielfeatures abrufen.
class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {
val uiState: StateFlow<CheckoutUiState> =
savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
// produce UI state calling bookRepository.getBook(bookId)
}
…
}
Sie sollten keine Objekte als Navigationsargumente übergeben. Verwenden Sie stattdessen einfache IDs, mit denen Funktionen auf die gewünschten Ressourcen in der Datenschicht zugreifen und sie laden können. So halten Sie die Kopplung gering und verstoßen nicht gegen das Prinzip der Single Source of Truth.
Im folgenden Beispiel hängen beide Funktionsmodule vom selben Datenmodul ab. So lässt sich die Datenmenge, die das Vermittlermodul weiterleiten muss, minimieren und die Kopplung zwischen den Modulen gering halten. Anstatt Objekte zu übergeben, sollten Module primitive IDs austauschen und die Ressourcen aus einem gemeinsamen Datenmodul laden.
Inversion von Abhängigkeiten
Bei der Abhängigkeitsinversion wird der Code so organisiert, 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ängen 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.
Beispiel
Stellen Sie sich ein Funktionsmodul vor, das eine Datenbank benötigt. Das Funktionsmodul ist unabhängig davon, wie die Datenbank implementiert wird, sei es eine lokale Room-Datenbank oder eine Remote-Firestore-Instanz. Sie 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 App. Mit anderen Worten: Sie legt die Regeln für die Interaktion mit der Datenbank fest. So kann das Funktionsmodul jede Datenbank verwenden, ohne dass die zugrunde liegenden Implementierungsdetails bekannt sein müssen.
Das konkrete Implementierungsmodul enthält die tatsächliche Implementierung der APIs, die im Abstraktionsmodul definiert sind. Dazu ist das Implementierungsmodul auch vom Abstraktionsmodul abhängig.
Dependency Injection
Sie fragen sich jetzt vielleicht, wie das Funktionsmodul mit dem Implementierungsmodul verbunden ist. Die richtige Antwort ist Dependency Injection (Abhängigkeitsinjektion). Das Funktionsmodul erstellt die erforderliche Datenbankinstanz nicht direkt. Stattdessen wird angegeben, welche Abhängigkeiten benötigt werden. Diese Abhängigkeiten werden dann extern bereitgestellt, in der Regel im App-Modul.
releaseImplementation(project(":database:impl:firestore"))
debugImplementation(project(":database:impl:room"))
androidTestImplementation(project(":database:impl:mock"))
Vorteile
Die Vorteile der Trennung von APIs und ihren Implementierungen sind:
- Austauschbarkeit: Durch die klare Trennung von API- und Implementierungsmodulen können Sie mehrere Implementierungen für dieselbe API entwickeln und zwischen ihnen wechseln, ohne den Code ändern zu müssen, der die API verwendet. Das kann besonders in Szenarien von Vorteil sein, in denen Sie in verschiedenen Kontexten unterschiedliche Funktionen oder Verhaltensweisen bereitstellen möchten. Beispiel: eine Mock-Implementierung für Tests im Vergleich zu einer echten Implementierung für die Produktion.
- Entkopplung: Durch die Trennung sind Module, die Abstraktionen verwenden, nicht von einer bestimmten Technologie abhängig. Wenn Sie Ihre Datenbank später von Room zu Firestore ändern möchten, ist das einfacher, da die Änderungen nur im jeweiligen Modul (Implementierungsmodul) erfolgen und sich nicht auf andere Module auswirken, die die API Ihrer Datenbank verwenden.
- Testbarkeit: Durch die Trennung von APIs und ihren Implementierungen können Tests erheblich erleichtert werden. Sie können Testläufe für die API-Verträge schreiben. Sie können auch verschiedene Implementierungen verwenden, um verschiedene Szenarien und Grenzfälle zu testen, einschließlich Mock-Implementierungen.
- Verbesserte Build-Leistung: Wenn Sie eine API und ihre Implementierung in separate Module aufteilen, müssen die Module, die vom API-Modul abhängen, bei Änderungen im Implementierungsmodul nicht neu kompiliert werden. Dies führt zu kürzeren Build-Zeiten und einer höheren Produktivität, insbesondere bei großen Projekten, in denen die Build-Zeiten erheblich sein können.
Wann sollten Sie die Geräte trennen?
Es ist in den folgenden Fällen von Vorteil, Ihre APIs von ihren Implementierungen zu trennen:
- Vielfältige Funktionen: Wenn Sie Teile Ihres Systems auf verschiedene Arten implementieren können, ermöglicht eine klare API den Austausch verschiedener Implementierungen. Beispielsweise kann es sich um ein Rendering-System handeln, das OpenGL oder Vulkan verwendet, oder um ein Abrechnungssystem, das mit Google Play oder Ihrer internen Abrechnungs-API funktioniert.
- Mehrere Anwendungen: Wenn Sie mehrere Anwendungen mit gemeinsamen Funktionen für verschiedene Plattformen entwickeln, können Sie gemeinsame APIs definieren und plattformspezifische Implementierungen 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 korrekt zu verwenden. Sie müssen sich nicht um die Implementierungsdetails anderer Module kümmern.
- Große Codebasis: Wenn die Codebasis groß oder komplex ist, wird der Code durch die Trennung der API von der Implementierung übersichtlicher. So können Sie die Codebasis in detailliertere, verständlichere und wartungsfreundlichere Einheiten unterteilen.
Implementierung
So implementieren Sie die Abhängigkeitsinversion:
- Abstraktionsmodul erstellen: Dieses Modul sollte APIs (Schnittstellen und Modelle) enthalten, die das Verhalten Ihrer Funktion definieren.
- Implementierungsmodule erstellen: Implementierungsmodule sollten auf dem API-Modul basieren und das Verhalten einer Abstraktion implementieren.
Abbildung 10. Implementierungsmodule hängen vom Abstraktionsmodul ab. - Module auf hoher Ebene von Abstraktionsmodulen abhängig machen: Anstatt direkt von einer bestimmten Implementierung abhängig zu sein, sollten Sie Ihre Module von Abstraktionsmodulen abhängig machen. Module auf hoher Ebene müssen keine Implementierungsdetails kennen, sondern nur den Vertrag (die API).
Abbildung 11. Module auf hoher Ebene hängen von Abstraktionen ab, nicht von der Implementierung. - Implementierungsmodul bereitstellen: Schließlich müssen Sie die eigentliche Implementierung für Ihre Abhängigkeiten bereitstellen. Die genaue Implementierung hängt von der Einrichtung Ihres Projekts ab. Das App-Modul ist dafür aber in der Regel gut geeignet.
Geben Sie die Implementierung als Abhängigkeit für die ausgewählte Build-Variante oder das Testquellenset an.
Abbildung 12. Das App-Modul enthält die tatsächliche Implementierung.
Allgemeine Best Practices
Wie bereits erwähnt, gibt es nicht nur eine richtige Art, eine App mit mehreren Modulen zu entwickeln. Genauso wie es viele Softwarearchitekturen gibt, gibt es auch zahlreiche Möglichkeiten, eine App zu modularisieren. Die folgenden allgemeinen Empfehlungen können Ihnen jedoch helfen, Ihren Code lesbarer, wartungsfreundlicher und testbarer zu machen.
Konfiguration konsistent halten
Jedes Modul verursacht Konfigurationsaufwand. Wenn die Anzahl Ihrer Module einen bestimmten Grenzwert erreicht, wird es schwierig, eine einheitliche Konfiguration zu verwalten. Es ist beispielsweise wichtig, dass Module Abhängigkeiten derselben Version verwenden. Wenn Sie eine große Anzahl von Modulen aktualisieren müssen, nur um eine Abhängigkeitsversion zu erhöhen, ist das nicht nur mit Aufwand verbunden, sondern birgt auch das Risiko potenzieller Fehler. Um dieses Problem zu beheben, 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 Ihre Abhängigkeiten deklarieren. Die Datei ist für alle Module in einem Projekt verfügbar.
- Verwenden Sie Konventions-Plug-ins, um Build-Logik zwischen Modulen zu teilen.
So wenig wie möglich preisgeben
Die öffentliche Schnittstelle eines Moduls sollte minimal sein und nur das Wesentliche enthalten. Es sollten keine Implementierungsdetails nach außen dringen. Beschränken Sie alles so weit wie möglich. Verwenden Sie den Sichtbarkeitsbereich private oder internal von Kotlin, um die Deklarationen modulbezogen zu machen. Verwenden Sie beim Deklarieren von Abhängigkeiten in Ihrem Modul vorzugsweise implementation anstelle von api. Letzteres macht transitive Abhängigkeiten für die Nutzer Ihres Moduls verfügbar. Die Verwendung von kann die Build-Zeit verbessern, da die Anzahl der Module, die neu erstellt werden müssen, reduziert wird.
Kotlin- und Java-Module bevorzugen
Android Studio unterstützt drei wichtige Arten von Modulen:
- App-Module sind ein Einstiegspunkt für Ihre Anwendung. Sie können Quellcode, Ressourcen, Assets und eine
AndroidManifest.xmlenthalten. 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), das strukturell mit App-Modulen identisch ist. Es wird jedoch in eine AAR-Datei 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 App-Modulen kapseln und wiederverwenden.
- Kotlin- und Java-Bibliotheken enthalten keine Android-Ressourcen, Assets oder Manifestdateien.
Da Android-Module mit Overhead verbunden sind, sollten Sie möglichst Kotlin oder Java verwenden.