Android Coden
Android 10 min lesen

Kotlin Multiplatform bewusst einsetzen

Du lernst, wann KMP in Android-Projekten hilft. Der Artikel erklärt Shared Code, Grenzen und expect/actual.

Kotlin Multiplatform, oft KMP genannt, ist für dich als Android-Entwickler kein Thema, das du sofort in jedem Projekt einsetzen musst. Wichtig ist zuerst das Bewusstsein: Du solltest erkennen können, wann gemeinsamer Kotlin-Code einem Android-Projekt hilft, wann er nur zusätzliche Struktur erzeugt und wo die Grenze zwischen sauber geteiltem Fachcode und bewusst plattformspezifischem Android-Code liegt.

Was ist das?

Kotlin Multiplatform Awareness bedeutet, dass du KMP fachlich einordnen kannst, ohne daraus sofort eine vollständige iOS-, Desktop- oder Web-Strategie zu machen. Du verstehst, dass Kotlin nicht nur auf Android läuft, sondern auch in gemeinsamen Modulen verwendet werden kann, die von mehreren Plattformen genutzt werden. Für Android-Projekte heißt das: Ein Teil deiner App kann als Shared Code formuliert werden, während Android-spezifische Teile weiterhin in der Android-App bleiben.

Der Kern ist nicht „einmal schreiben, überall gleiche App bekommen“. Dieses mentale Modell führt schnell in die Irre. Eine Android-App ist mehr als Kotlin-Code: Sie hat Lifecycle, Berechtigungen, Ressourcen, Navigation, Compose-Oberflächen, Play-Store-Anforderungen, Gerätebesonderheiten und viele APIs aus dem Android-SDK. KMP nimmt dir diese Android-Arbeit nicht ab. Es gibt dir stattdessen die Möglichkeit, bestimmte Logik aus dem Android-Modul herauszulösen und in ein gemeinsames Kotlin-Modul zu legen.

Typische Kandidaten sind Domain-Modelle, Validierungsregeln, Use Cases, einfache Datenformatierung, Berechnungen, Teile der Repository-Schicht, Serialisierung, Fehler-Mapping und Schnittstellen zu Netzwerk- oder Datenquellen. Weniger geeignete Kandidaten sind Android-UI, Compose-spezifische Zustände mit Android-Lifecycle-Bezug, WorkManager-Konfigurationen, Room-DAOs, Intent-Handling oder Code, der direkt mit Context, Activity, Fragment, Ressourcen oder Systemdiensten arbeitet.

Für Lernende ist die wichtigste Frage deshalb nicht: „Wie baue ich eine komplette Multiplatform-App?“ Die bessere Frage lautet: „Welche Logik in meinem Android-Projekt ist wirklich unabhängig von Android?“ Wenn du diese Frage sauber beantworten kannst, hast du bereits den wichtigsten Teil von Kotlin Multiplatform Awareness verstanden.

In der Roadmap passt dieses Thema an eine Stelle, an der du Kotlin, Android-Grundlagen, Architektur und Tests bereits besser einordnen kannst. KMP baut auf diesen Grundlagen auf. Wenn du noch Mühe hast, ViewModel, Repository, StateFlow, suspend-Funktionen oder saubere Modulgrenzen zu unterscheiden, solltest du KMP zunächst als Architektur-Idee betrachten und nicht als Pflichttechnik. Sobald du aber erkennst, welche Teile deiner App fachlich stabil sind, wird KMP interessant.

Ein Beispiel aus dem Alltag: Deine Android-App berechnet Preise, Rabatte, Lieferzeiten oder Validierungsfehler für ein Formular. Später soll dieselbe Logik auch in einer iOS-App oder in einem Backend-Tool genutzt werden. Wenn diese Regeln nur im Android-Modul stehen, entstehen schnell Kopien. Kopien werden irgendwann unterschiedlich gepflegt. Ein KMP-Shared-Modul kann diese Regeln zentral halten. Android ruft sie auf, eine andere Plattform ebenfalls. Die UI bleibt jeweils plattformspezifisch.

Wie funktioniert es?

KMP organisiert Code in sogenannten Source Sets. Ein Source Set ist ein Bereich für Code, der für eine bestimmte Zielgruppe kompiliert wird. Der gemeinsame Code liegt typischerweise in commonMain. Dort schreibst du Kotlin, der keine direkte Abhängigkeit zu Android-Klassen hat. Android-spezifischer Code liegt zum Beispiel in androidMain. Wenn dein Projekt später iOS unterstützt, gibt es entsprechende iOS-Source-Sets.

Das wichtigste Denkmodell ist die Trennung zwischen gemeinsamem Vertrag und plattformspezifischer Umsetzung. Im gemeinsamen Code formulierst du, was deine Logik braucht. In der Plattform-Schicht entscheidest du, wie diese Anforderung auf Android erfüllt wird. Genau hier kommen expect und actual ins Spiel.

Mit expect beschreibst du im gemeinsamen Code eine Klasse, Funktion oder Eigenschaft, deren konkrete Implementierung noch nicht dort steht. Mit actual lieferst du pro Plattform die passende Umsetzung. Dadurch kann dein Shared Code eine Fähigkeit verwenden, ohne Android direkt zu importieren. Das ist nützlich, wenn ein kleiner Teil der Logik Plattformwissen braucht, der Großteil aber gemeinsam bleiben soll.

Ein einfaches Beispiel ist die aktuelle Zeit. Fachliche Logik braucht vielleicht einen Zeitstempel, soll aber nicht direkt von Android-APIs abhängen. In commonMain definierst du eine erwartete Schnittstelle oder Funktion. In androidMain implementierst du sie mit Android- oder JVM-Mitteln. Der gemeinsame Code bleibt dadurch testbarer und klarer.

Trotzdem solltest du expect/actual nicht als Werkzeug betrachten, mit dem du jede Plattformdifferenz verdeckst. Wenn du zu viele Details darüber abstrahierst, wird dein Shared-Modul schwer verständlich. Dann steht dort ein scheinbar allgemeiner Code, der in Wahrheit voller Plattformkompromisse steckt. Gute KMP-Module teilen nicht alles. Sie teilen bewusst ausgewählte Regeln und Verträge.

In modernen Android-Projekten begegnet dir KMP meistens als zusätzliches Gradle-Modul. Deine Android-App hängt davon ab, ähnlich wie sie von einem normalen Kotlin- oder Java-Modul abhängen kann. Der Unterschied liegt in der Multiplatform-Konfiguration und in den Source Sets. Android bleibt weiterhin Android: Du verwendest ViewModels, Compose, Navigation, Hilt oder andere lokale Muster wie gewohnt. Das Shared-Modul liefert Daten, Regeln oder Use Cases, aber es steuert nicht automatisch deine gesamte App.

Für Compose ist diese Grenze besonders wichtig. Compose für Android ist eine UI-Technik, die sehr gut mit Kotlin zusammenarbeitet, aber UI-State, Preview, Ressourcen, Navigation und Lifecycle sind oft eng an Android gebunden. Du kannst fachliche Zustände und Datenmodelle teilen. Du solltest aber nicht blind versuchen, jede Composable-Funktion in Shared Code zu verschieben, nur weil beide Seiten Kotlin verwenden. Eine klare Architektur trennt Präsentation, Fachlogik und Plattformintegration.

Auch Coroutines und Flow passen gut in dieses Bild. Viele Shared-Module verwenden suspend-Funktionen oder Flow, um asynchrone Daten zu modellieren. Das kann sinnvoll sein, weil diese Konzepte aus Kotlin stammen und nicht direkt Android-spezifisch sind. Trotzdem musst du darauf achten, wer den Coroutine-Scope verwaltet. In Android gehören Lifecycle-nahe Scopes in die Android-Schicht, zum Beispiel in ein ViewModel. Das Shared-Modul sollte keine Annahmen darüber treffen, ob eine Activity sichtbar ist oder ob ein Prozess vom System beendet wird.

Für Qualität und Release-Praxis ist KMP ebenfalls relevant. Geteilter Code bedeutet: Ein Fehler im Shared-Modul betrifft potenziell mehrere Plattformen. Das ist ein Vorteil, wenn du Tests hast, weil eine korrigierte Regel überall konsistent wirkt. Es ist ein Risiko, wenn du ohne Tests arbeitest, weil ein scheinbar kleiner Fix mehr Oberflächen beeinflussen kann als erwartet. KMP verlangt deshalb mehr Disziplin bei Modulgrenzen, Versionspflege und Review.

In der Praxis

Nimm an, du baust eine Android-App mit Registrierung. Die App soll prüfen, ob ein Benutzername gültig ist. Diese Regel ist fachlich, nicht Android-spezifisch: Mindestlänge, erlaubte Zeichen und Fehlermeldungsart hängen nicht von Activity, Context oder Compose ab. Genau so ein Fall eignet sich für ein Shared-Modul.

Im gemeinsamen Modul könnte die Regel so aussehen:

package de.androidcoden.shared.validation

sealed interface UsernameResult {
    data object Valid : UsernameResult
    data object TooShort : UsernameResult
    data object InvalidCharacters : UsernameResult
}

class UsernameValidator {
    fun validate(input: String): UsernameResult {
        val trimmed = input.trim()

        if (trimmed.length < 3) {
            return UsernameResult.TooShort
        }

        val allowed = trimmed.all { char ->
            char.isLetterOrDigit() || char == '_' || char == '-'
        }

        return if (allowed) {
            UsernameResult.Valid
        } else {
            UsernameResult.InvalidCharacters
        }
    }
}

Dieser Code braucht keine Android-Klasse. Er lässt sich in commonMain testen und von Android aus verwenden. In deiner Android-App kann ein ViewModel den Validator aufrufen und das Ergebnis in UI-State übersetzen:

class RegisterViewModel(
    private val validator: UsernameValidator = UsernameValidator()
) : ViewModel() {

    private val _state = MutableStateFlow(RegisterUiState())
    val state: StateFlow<RegisterUiState> = _state.asStateFlow()

    fun onUsernameChanged(value: String) {
        val result = validator.validate(value)

        _state.update { current ->
            current.copy(
                username = value,
                usernameError = when (result) {
                    UsernameResult.Valid -> null
                    UsernameResult.TooShort -> "Mindestens 3 Zeichen"
                    UsernameResult.InvalidCharacters -> "Nur Buchstaben, Zahlen, _ und -"
                }
            )
        }
    }
}

Hier bleibt die UI-Meldung in der Android-Schicht. Das ist oft sinnvoll, weil Texte in Android über Ressourcen, Lokalisierung und UI-Kontext laufen können. Die fachliche Entscheidung liegt im Shared Code, die konkrete Darstellung in Android. Diese Trennung ist ein gutes erstes KMP-Muster.

Ein Beispiel für expect/actual entsteht, wenn dein Shared Code eine Plattforminformation braucht. Angenommen, du möchtest in einem Report anzeigen, von welcher Plattform ein Diagnoseereignis kommt. Im gemeinsamen Code definierst du nur den Vertrag:

package de.androidcoden.shared.platform

expect class PlatformInfo() {
    val name: String
}

In androidMain lieferst du die Android-Umsetzung:

package de.androidcoden.shared.platform

import android.os.Build

actual class PlatformInfo {
    actual val name: String =
        "Android ${Build.VERSION.SDK_INT}"
}

Der gemeinsame Code kann PlatformInfo().name nutzen, ohne selbst android.os.Build zu importieren. Genau das ist der Zweck von expect/actual: Der gemeinsame Code kennt die Form, die Plattform liefert den Inhalt.

Eine praktische Entscheidungsregel lautet: Verschiebe Code nur dann in ein KMP-Shared-Modul, wenn du ihn ohne Android-Imports erklären, testen und verwenden kannst. Wenn du beim Verschieben sofort Context, Ressourcen, Activity, Fragment, LifecycleOwner oder Android-Annotations brauchst, ist der Code wahrscheinlich noch nicht sauber genug getrennt. Dann solltest du zuerst deine Android-Architektur verbessern, bevor du KMP einführst.

Eine zweite Regel betrifft den Nutzen. Shared Code lohnt sich, wenn mindestens eine reale Wiederverwendung geplant ist oder wenn die Modulgrenze auch im Android-Projekt selbst Qualität bringt. Ein Shared-Modul nur deshalb anzulegen, weil KMP modern wirkt, erzeugt oft mehr Gradle-Konfiguration, mehr Build-Komplexität und mehr Fragen im Team. Für eine reine Android-App ohne absehbare zweite Plattform reicht häufig ein normales Kotlin-Modul oder eine klare Package-Struktur.

Eine typische Stolperfalle ist zu viel geteilte Oberfläche. Anfänger versuchen manchmal, nicht nur Validierung und Use Cases zu teilen, sondern auch UI-Modelle, Navigationsentscheidungen, Fehlermeldungstexte, Ressourcen-Keys und Plattformzustände. Dadurch wird das Shared-Modul nicht unabhängiger, sondern abhängig von unausgesprochenen Android-Annahmen. Besser ist ein kleiner Start: Teile eine klare Regel, schreibe Tests dafür und prüfe, ob Android dadurch einfacher wird.

Eine weitere Stolperfalle ist falsches Fehler-Mapping. Im Shared Code kann ein Repository zum Beispiel einen fachlichen Fehler NetworkUnavailable oder Unauthorized liefern. Die Android-Schicht entscheidet dann, ob daraus ein Snackbar-Text, ein Dialog, ein Retry-Button oder ein Navigationseffekt wird. Wenn du im Shared Code bereits Android-Texte oder UI-Aktionen modellierst, vermischst du Schichten. Das macht spätere Plattformen schwerer und deine Android-Tests unklarer.

Tests sind der beste Weg, dein Verständnis zu prüfen. Für den UsernameValidator brauchst du keine Android-Instrumentation. Ein normaler Unit-Test im gemeinsamen Test-Source-Set reicht:

class UsernameValidatorTest {

    private val validator = UsernameValidator()

    @Test
    fun shortNameIsRejected() {
        assertEquals(
            UsernameResult.TooShort,
            validator.validate("ab")
        )
    }

    @Test
    fun nameWithSpacesIsRejected() {
        assertEquals(
            UsernameResult.InvalidCharacters,
            validator.validate("anna maria")
        )
    }

    @Test
    fun nameWithUnderscoreIsValid() {
        assertEquals(
            UsernameResult.Valid,
            validator.validate("anna_42")
        )
    }
}

Wenn solche Tests leicht zu schreiben sind, ist dein Shared Code wahrscheinlich gut gewählt. Wenn jeder Test Android-Mocks, Context-Ersatz oder UI-Hilfen braucht, liegt zu viel Plattformlogik im gemeinsamen Bereich. Dann solltest du die Grenze neu ziehen.

Im Code-Review kannst du KMP-Fragen sehr konkret stellen. Enthält commonMain Android-Imports? Verwendet ein gemeinsames Modell Begriffe, die nur zur Android-UI passen? Gibt es Tests für die geteilte Logik? Ist expect/actual wirklich nötig, oder könnte eine einfache Schnittstelle mit Dependency Injection reichen? Hat die Android-Schicht weiterhin die Verantwortung für Lifecycle, Ressourcen und Darstellung? Solche Fragen halten KMP klein und nützlich.

Auch beim Debugging hilft diese Sicht. Wenn ein Fehler in der Anzeige liegt, prüfst du zuerst Compose, ViewModel-State und Android-Ressourcen. Wenn eine fachliche Regel falsch entscheidet, gehst du in das Shared-Modul und testest die Regel isoliert. Diese Trennung spart Zeit, weil du nicht jeden Fehler als gesamtes App-Problem behandeln musst.

Fazit

Kotlin Multiplatform Awareness heißt: Du erkennst, wann Shared Code deinem Android-Projekt echte Klarheit bringt und wann Android-spezifischer Code besser an Ort und Stelle bleibt. Starte mit kleinen, gut testbaren Regeln wie Validierung, Mapping oder Use Cases, halte UI, Lifecycle und Ressourcen in der Android-Schicht und nutze expect/actual nur für bewusst gewählte Plattformunterschiede. Prüfe dein Verständnis praktisch, indem du eine fachliche Klasse aus einem Android-Modul herauslöst, Unit-Tests dafür schreibst und im Code-Review erklärst, warum genau dieser Code geteilt werden darf.

Quellen (2)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.