Techblog

Android App Modularisierung mit Kotlin-DSL und KOIN

Von Andreas Spindler
5. März 2021

In diesem Artikel möchte ich einen Weg aufzeigen, wie man ein Multi-Modul-Projekt mit Gradle Kotlin-DSL und der Service-Locator-Bibliothek KOIN konfigurieren könnte.

Ich setze auf ein Grundverständnis über Modularisierung in Android Projekten, Kotlin-DSL und KOIN auf. Falls Du nicht damit vertraut bist, empfehle ich die folgenden Artikel als Einführung:

Unsere drei Herausforderungen

In unserem neuen Projekt waren wir in der komfortablen Situation, dass wir kein Legacy-Projekt zum Refactoring bekamen. Wir bauten etwas völlig Neues auf einer grünen Wiese. Und was macht man bei einem Projekt auf der grünen Wiese? Man verwendet die neuesten Entwicklungsansätze. In unserem Fall war das ein Multi-Modul-Projekt mit Gradle Kotlin-DSL und KOIN als Service Locator.

Nebenbemerkung: Wir haben uns gegen Dagger 2 entschieden, weil alle unsere Devs die Annotations-Magic, die Dagger mit sich bringt, nicht mochten. KOIN ist viel einfacher zu bedienen und leichter zu verstehen, was es zu unserem Werkzeug der Wahl machte.

Bei der Einrichtung des Projekts als Multi-Modul-Projekt standen wir mit Kotlin-DSL und KOIN vor drei großen Herausforderungen:

  • Herausforderung 1: Wie kann man mit Kotlin-DSL möglichst viel redundante Konfiguration in der build.gradle.kts für jedes Modul vermeiden?
  • Herausforderung 2: Wie kann man KOIN verwenden, um den Dependency-Tree in einem Multi-Modul-Projekt einzurichten?
  • Herausforderung 3: Wie kann man alle Environment-Variablen im App-Modul haben und sie trotzdem in den Sub-Modulen verwenden?

Hier unsere Lösungen, der Reihe nach.

Herausforderung 1: Vermeide so viel redundante Konfiguration wie möglich in der build.gradle.kts

In einem Projekt mit mehreren Modulen haben wir normalerweise ein App-Modul und mehrere android-library-Module, die jeweils ihre eigenen gradle.build (gradle.build.kts im Fall von Kotlin-DSL) Dateien haben. Mehrere gradle-Dateien zu haben, bringt eine Menge redundanten Code mit sich. Denke an den android {..}-Konfigurationsblock, wo man in jeder gradle-Datei die sdk-Versionen, Compile- und Testoptionen, buildTypes usw. konfigurieren muss. Mit Groovy wäre das besonders einfach gewesen: Wir extrahieren alles in eine separate Datei, z.B. base.gradle, und wenden es einfach auf die build.gradle Dateien an, wo es benötigt wird:

  1. //base.gradle
  2. android {
  3.     defaultConfig {
  4.         applicationId "com.company.app"
  5.         minSdkVersion 23
  6.         targetSdkVersion 30
  7.     }
  8.     compileOptions {
  9.         sourceKompatibilität JavaVersion.VERSION_1_8
  10.         targetKompatibilität JavaVersion.VERSION_1_8
  11.     }
  12. }
  13.  
  14. //app:build.gradle
  15. anwenden von: "../base.gradle"
  16. android {
  17.     signingConfig {
  18.         //..unsere signingConfig
  19.     }
  20. }
  21. //Ergebnis app:build.gradle
  22. android {
  23.     defaultConfig {
  24.         applicationId "com.company.app"
  25.         minSdkVersion 23
  26.         targetSdkVersion 30
  27.     }
  28.     compileOptions {
  29.         sourceKompatibilität JavaVersion.VERSION_1_8
  30.         targetKompatibilität JavaVersion.VERSION_1_8
  31.     }
  32.     signingConfig {
  33.         //..unsere Signierkonfiguration
  34.     }
  35. }

Der Inhalt unserer base.gradle wird mit dem Inhalt der build.gradle zusammengeführt, und das war es.

Leider funktioniert das nicht mit Kotlin-DSL.

Kotlin-DSL ist stark typisiert, und daher können wir den Konfigurationsblock android { } nicht einfach außerhalb des Kontext verwenden.

Lösung: Konfiguration über ein Gradle-Plugin bereitstellen

Glücklicherweise ist es sehr einfach, ein Gradle-Plugin zu schreiben. Und das ist der Weg, wie wir diese Herausforderung lösen werden und eine einzige Datei (eigentlich ist es eine Klasse) haben, in der wir unsere gemeinsame Android-Konfiguration konfigurieren können.

Das Gradle Plug-in erstellen

Wir erzeugen ein neues Modul buildSrc und fügen einen Ordner java hinzu. Dort erstellen wir ein neues Package gradle . Innerhalb dieses Package erstellen wir eine neue Kotlin-Klasse namens AndroidBasePlugin. Die Struktur sollte wie folgt aussehen:

Die Klasse AndroidBasePlugin muss eine Unterklasse von Plugin<Gradle> sein. Als solche muss man eine Methode fun apply(target: Project) implementieren. Die Methode apply wird während der Projektbewertungsphase von Gradles Build-Lebenszyklus aufgerufen. Aus dem "target" müssen wir uns dann die Android-Extension holen, um sie zu konfigurieren. Es gibt mehrere spezifische Erweiterungsunterklassen, die bestimmte Modultypen repräsentieren, z. B. ein app, android-library, ein dynamic-feature Modul und so weiter. Aber all diese teilen sich eine gemeinsame Basisklasse namens BaseExtension. Als erstes holen wir uns die aktuelle "android"-Extension und überprüfen, ob es sich um eine BaseExtension handelt. Sobald wir sie haben, können wir einfach unsere gemeinsam genutzte Konfiguration anwenden.

  1. class AndroidBasePlugin : Plugin<Project> {
  2.     override fun apply(target: Project) {
  3.         val extension = target.extensions.getByName("android")
  4.         if (extension ist BaseExtension) {
  5.             extension.apply {
  6.                 // hier können wir unsere Konfiguration vornehmen, die in
  7.                 // den android {}-Block gehören
  8.                 compileSdkVersion(30)
  9.                 buildToolsVersion(30.0.2)
  10.  
  11.                 defaultConfig {
  12.                     minSdkVersion(23)
  13.                     targetSdkVersion(30)
  14.                 }
  15.                 //und so weiter
  16.             }
  17.         }
  18.       //hier könnte man sogar allgemeine Dependencies hinzufügen
  19.     }
  20. }

Registrieren des Plug-Ins

Als Nächstes müssen wir das Plug-in registrieren. Dies geschieht in der build.gradle.kts in unserem buildSrc-Modul. Füge einfach den folgenden Konfigurationsblock hinzu:

  1. gradlePlugin {
  2.     plugins {
  3.         register("android-base-plugin") {
  4.             id = "android-base-plugin"
  5.             implementationClass = "gradle.AndroidBasePlugin"
  6.         }
  7.     }
  8. }

Nun ist das Plug-in bereit, in anderen gradle-Dateien verwendet zu werden.

Das Plug-in verwenden

Der letzte Schritt besteht darin, das Plug-in in allen build.gradle.kts-Dateien zu verwenden, die die Standardkonfiguration übernehmen sollen. Füge einfach das Plugin mit seiner Id in den plugins{}-Block ein:

  1. ///z.B. app:build.gradle.kts, feature:build.gradle.kts, etc.
  2. plugins {
  3.     id("com.android.anwendung")
  4.     kotlin("android")
  5.     kotlin("android.extensions")
  6.     `android-base-plugin` //<= hier ist es
  7. }

 

Das war's! Wir haben jetzt alle gemeinsam genutzten Android-Konfigurationen aus unserem Plug-in in unseren Modulen.

Herausforderung 2: Wie verwendet man KOIN, um den Abhängigkeits-Baum in einem Multi-Modul-Projekt einzurichten?

In der Software-Entwicklung hat man in der Regel eine Art von Schichtenarchitektur. Zum Beispiel könnte man eine Präsentationsschicht, eine Domänenschicht, eine Datenschicht usw. haben. Als wir mit dem Projektaufbau angefangen haben, hatten wir zwei Möglichkeiten der Modularisierung: nach Schichten oder nach Features. Beide haben ihre Vor- und Nachteile. Da wir nicht wussten, welche Art von Features zu erwarten waren, entschieden wir uns, unsere Module nach Schichten zu erstellen. So hatten wir am Ende diese Module: app, repo, data und networking. Die Abhängigkeiten sind wie folgt:

Natürlich wollten wir diesen Abhängigkeitsgraphen auch in unseren KOIN-Modulen abbilden. Wir wollten nicht, dass das App-Modul z.B. das Daten- oder Netzwerk-Modul kennt. Leider ist es nicht möglich, diese Art von Abhängigkeit mit den KOIN-Modulen zu erstellen. Denn in der Anwendung-Klasse muss man KOIN initialisieren und ein Array der Module bereitstellen, damit KOIN den Objektgraphen erstellen kann:

  1. fun onCreate() {
  2.   startKoin {
  3.     androidContext(this@Application)
  4.       androidLogger(Level.INFO)
  5.       modules(
  6.         listOf(
  7.           //Ihre Module
  8.         )
  9.       )
  10.    }
  11. }

In KOIN gibt es leider keine Möglichkeit, ein Modul aus einem anderen Modul zu referenzieren. Deshalb sahen wir nur diese zwei Möglichkeiten:

  • Option 1: Statische "Initializer"-Klasse für jedes Modul erstellen, um die abhängigen Module zu KOIN hinzuzufügen
  • Option 2: Dem App-Modul alle vorhandenen Module bekannt machen

Lösung: Die Regel brechen

Dies war definitiv eine der am emotionalsten diskutierten Entscheidungen, die das Team zu treffen hatte. Am Ende entschieden wir uns für Option 2:

  1. fun onCreate() {
  2.   startKoin {
  3.     androidContext(this@Application)
  4.       androidLogger(Level.INFO)
  5.       modules(
  6.         listOf(
  7.           appModule,
  8.           domainModule,
  9.           repoModule,
  10.           dataModule,
  11.           networkModule
  12.         )
  13.       )
  14.    }
  15. }

Moment! Das App-Modul kennt die unteren Schichten?

Es gibt auf Reddit einige Diskussionen darüber, ob das App-Modul in einer Clean Code-Architektur das Daten- und Netzwerkmodul kennen sollte. Wir haben uns dafür entschieden, dass das app-Modul alle anderen Module kennt, auch wenn sich das in der Tat ein wenig seltsam anfühlt. Aber der Vorteil, den wir hinsichtlich höherer Flexibilität und Konfigurierbarkeit haben, war die Entscheidung wert. Bis jetzt haben wir keine Nachteile oder Probleme mit diesem Ansatz erlebt. Sollte es sich als Irrglaube herausstellen, werde ich auf jeden Fall einen weiteren Artikel darüber schreiben.

Herausforderung 3: Wie kann man alle Environment-Variablen im App-Modul haben und sie trotzdem in den Modulen verwenden?

Alle Apps, die ich im Laufe der Jahre entwickelt habe, kommunizieren mit einem Backend. Aber während man noch in der Entwicklung ist, möchte man natürlich nicht, dass die App schon mit den Produktionsservern spricht. In der Regel gibt es eine "Dev-Umgebung" und vielleicht noch mehrere Umgebungen für Entwicklungs-, Test- und Integrationszwecke.  Sagen wir, wir haben drei Umgebungen: dev, test und prod.  Nehmen wir außerdem an, wir haben drei Module: networking, analytics und authorization. Das Modul networking benötigt eine baseUrl, die Module analytics und authorization benötigen unterschiedliche API-Keys für jede Umgebung. Normalerweise würde man in jedes der Module build.gradle.kt verschiedene productFlavors mit einer bestimmten flavourDimension anlegen, etwa so:

  1. android {
  2.     flavorDimensions("environment")
  3.     productFlavors {
  4.         create("dev") {
  5.             dimension = "environment"
  6.             applicationIdSuffix = ".dev"
  7.         }
  8.         create("test") {
  9.             dimension = "environment"
  10.             applicationIdSuffix = ".test"
  11.         }
  12.         create("prod") {
  13.             dimension = "environment"
  14.         }
  15.     }
  16. }

Dann fügt man entweder eine config.xml mit den Konfigurationswerten (baseUrl, api-keys, etc.) in den jeweiligen "Flavour-Ordnern" der Projektstruktur ein oder lässt die Build-Pipeline die kritischeren Werte während des Builds bereitstellen. Allerdings hätten wir dann mindestens dreimal die gleiche Konfiguration gehabt. Es ist auf jeden Fall eine Diskussion im Entwicklungsteam wert, wie man die Flavours in einem Projekt strukturieren will. Denn es gibt Vor- und Nachteile, ob die Modulkonfiguration im app-Modul oder getrennt in eigenen Modulen liegt. Wir haben uns entschieden, dass wir es im app-Modul haben wollen und lieber die einzelnen Module konfigurieren. Die Herausforderung war: Wie bekommen wir die Konfiguration in die KOIN-Module? In der KOIN-Dokumentation über Module wird ein Modul als globales val definiert.

  1. /// im Modul networking
  2. val networkingModule = module {
  3.   //Retrofit braucht eine baseUrl
  4.   single {
  5.     Retrofit.Builder()
  6.        .baseUrl(baseUrl)
  7.        //anderer Konfigurationskram
  8.        .build()
  9.        .create(MyApi::class.java)
  10.   }
  11. }
  12.  
  13. //in der Klasse app:MyApplication
  14. fun onCreate(){
  15.   startKoin {
  16.     androidContext(this@MyApplication)
  17.     androidLogger(Level.DEBUG)
  18.     modules(
  19.       listOf(
  20.         networkModule
  21.         //unsere anderen Module
  22.       )
  23.     )
  24.   }
  25. }

Lösung: Denke außerhalb der Dokumentation

Die Lösung ist so offensichtlich, dass wir sie zunächst nicht gesehen haben. Niemand hat gesagt, dass man ein val verwenden muss, um das Modul bereitzustellen. Wir konnten auch einfach einen fun erstellen, die das Modul zurückgibt:

  1. /// im Modul Netzwerk
  2. fun getNetworkModule(
  3.   val baseUrl: String
  4. ) = module {
  5.   //Retrofit benötigt eine baseUrl
  6.   single {
  7.     Retrofit.Builder()
  8.        .baseUrl(baseUrl)
  9.        //anderer Konfigurationskram
  10.        .build()
  11.        .create(MyApi::class.java)
  12.   }
  13. }
  14.  
  15. //in der Klasse app:MyApplication
  16. fun onCreate(){
  17.   startKoin {
  18.     androidContext(this@MyApplication)
  19.     androidLogger(Level.DEBUG)
  20.     modules(
  21.       listOf(
  22.         //getString() holt den flavor/buidType spezifischen String, in diesem Fall
  23.         // die baseUrl aus der `config.xml`
  24.         getNetworkModule(getString(R.string.baseUrl))
  25.         //unsere anderen Module
  26.       )
  27.     )
  28.   }
  29. }

Und damit ist es geschafft! Du hast nun die volle Flexibilität, KOIN-Module zu konfigurieren. Netter Nebeneffekt: Du kannst das KOIN-Modul innerhalb des Projektmoduls auch in mehrere Module aufteilen und diese in dieser neuen Funktion laden, indem Du einfach loadKoinModules(otherModules) am Anfang Ihrer Funktion aufrufst, bevor Du den module{} builder aufrufst.

Fazit

Bislang sind wir mit diesem Setup sehr zufrieden. Seit wir das Projekt vor etwa sechs Monaten aufgesetzt haben, hatten wir zu keinem Zeitpunkt das Gefühl, etwas ändern zu müssen. Seitdem haben wir auch einige neue Module hinzugefügt. Unser gewähltes Setup hat sich dabei als sehr flexibel erwiesen und hat uns bisher in keiner Weise eingeschränkt. Ich würde das nächste Projekt höchstwahrscheinlich ähnlich aufsetzen.

Vielen Dank fürs Lesen. Ich hoffe, ich konnte dich inspirieren oder sogar bei der Lösung ähnlicher Herausforderungen helfen.

Neuen Kommentar schreiben

Public Comment form

  • Zulässige HTML-Tags: <a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd><p><h1><h2><h3>

Plain text

  • Keine HTML-Tags erlaubt.
  • Internet- und E-Mail-Adressen werden automatisch umgewandelt.
  • HTML - Zeilenumbrüche und Absätze werden automatisch erzeugt.

ME Landing Page Question