//

Kotlin Multiplatform Mobile – Ein praktischer Einstieg

23.5.2022 | 10 Minuten Lesezeit

Mit Kotlin Multiplatform Mobile (KMM) steht ein weiteres Cross-Platform Framework in den Startlöchern. Diesmal geht JetBrains ins Rennen und versucht natürlich alles besser zu machen als alle anderen. Die Frage ist aber: Brauchen wir das wirklich? Ist es einfach nur eine weitere Kopie in neuem Gewand?
Um das herauszufinden, habe ich eine kleine App gebaut und wir schauen uns gemeinsam an, wie das Ganze funktioniert. Hier findet ihr die Beispiel-App, auf die ich mich im Beitrag beziehe.

Grundlegender Aufbau von Kotlin Multiplatform Mobile

Starten wir zunächst mit dem Aufbau einer Kotlin Multiplatform Mobile-App. Ein KMM-Projekt basiert in erster Linie auf Modularisierung. Folgt man dem Standardweg von Kotlin, erhält man folgende Struktur:

Wir haben also ein Android-Modul, ein iOS-Modul und ein shared-Modul. In den Android- bzw. IOS-User-Interface-Modulen implementieren wir hauptsächlich Elemente für das User Interface, während wir die Business- und Datenlogik im shared-Modul umsetzen. Eine strikte Regel für die Abgrenzung gibt es jedoch nicht. Erscheint es für den Anwendungsfall sinnvoll, einen Teil der Businesslogik in die UI-Module zu ziehen, ist das mit Kotlin Multiplatform Mobile ohne Weiteres möglich. Die Implementierungen werden dann nicht zwischen den Systemen geteilt. Diese Flexibilität ist ein großer Vorteil von Kotlin Multiplatform Mobile.

Innerhalb des shared-Moduls erfolgt eine Aufteilung in common, iOS und Android Packages. Der größte Teil der Entwicklung findet im common Package statt. Hier liegt in der Regel die komplette Implementierung der Business- und Datenlogik. Also alles von der Implementierung der Datenbank, über die Remoteschnittstelle, bis hin zur Domänenlogik. In die beiden systemspezifischen Packages innerhalb des shared-Moduls kommen nur die Implementierungen, die wir für die jeweiligen Systeme separat entwickeln müssen. Das sind meist nur Treiberimplementierungen oder Ähnliches.

Neben den oben genannten Modulen gibt es dann auch das passende Pendant für die Tests. Auch hier erfolgt der Hauptteil im CommonTest Package während wir in den systemspezifischen Packages nur das Setup und die Treiber implementieren.

Damit wissen wir nun, wie der ganz grobe Aufbau der App ist. Wie das Ganze im Detail funktioniert, schauen wir uns in den kommenden Abschnitten an.

expect / actual – was ist das?

Bevor wir uns auf konkrete Implementierungen stürzen, müssen wir vorher noch eine wichtige Funktionalität betrachten. Ich habe im letzten Abschnitt erwähnt, dass wir für manche Anwendungsfälle separate Implementierungen benötigen. Wir brauchen also einen Mechanismus, der eine Weiche darstellt, um jeweils die spezifische Implementierung für die passende Plattform zu kompilieren.
Hierzu kommt das Feature expect / actual zum Einsatz.

expect / actual
1// Common
2expect fun sharedFunction(parameter: String): String
3
4
5// iOS
6actual fun sharedFunction(parameter: String): String {
7    returnthis is the iOS implementation”
8}
9
10
11// Android
12actual fun sharedFunction(parameter: String): String {
13    returnthis is the Android implementation”
14}
15

Im common Package definieren wir, was implementiert werden soll, ohne jedoch eine direkte Implementierung an dieser Stelle zu erstellen. Das Keyword expect sorgt dafür, dass der Compiler eine Implementierung erwartet. Sie muss den gleichen Namen aufweisen und den gleichen Package-Pfad besitzen. Danach können wir die Methode oder Klasse in dem jeweiligen plattformspezifischen Package implementieren.

Codesharing zwischen iOS und Android

Kommen wir mal zu den harten Fakten. Was lässt sich denn jetzt wirklich alles teilen? Wie viel müssen wir ggf. doch parallel implementieren?

User Interface

Wie schon etwas weiter oben beschrieben sind wir absolut frei, was wir teilen wollen. Ob wir nur eine einzige Methode teilen oder die kompletten Daten und Domänenlogik ist uns komplett frei gestellt. Was wir jedoch nicht teilen sollten, ist das User Interface und seine Komponenten. Demzufolge müssen wir auch die UI-Tests jeweils plattformspezifisch entwickeln, was durchaus Sinn ergibt.

UI-State

Da der State in der Beispielanwendung lediglich eine Dataclass ist, kann man ihn durchaus teilen. Aber auch hier merkt man schon, dass wir für die jeweilige Plattform kleine Abweichungen einbauen müssen. Für iOS benötigen wir einen eigenen Konstruktor. So muss man es zwar dennoch doppelt schreiben, hat es aber zumindest in einer Datei, was sicherlich Geschmackssache ist.

UI-State
1data class BicycleSharingSystemListState(
2    val isLoading: Boolean = false,
3    val country: String = "",
4    val bicycleSharingSystems: List<BicycleSharingSystem> = listOf(),
5) {
6    // Need secondary constructor to initialize with no args in SwiftUI
7    constructor() : this(
8        isLoading = false,
9        country = "",
10        bicycleSharingSystems = listOf(),
11    )
12}
13

Remote-Zugriff

Für die Umsetzung der Remote-Schnittstelle haben wir Ktor als Framework verwendet, was auch JetBrains offiziell als Lösung vorgeschlägt.

Hierfür benötigen wir für iOS und Android zunächst eine separate Implementierung des Treibers. Es existieren also in der commonMain eine KtorClientFactory-Klasse, die wir als expect deklarieren, sowie jeweils eine actual-Klasse für Android und iOS. Deren Implementierungen sehen dann wie folgt aus:

KtorClientFactory
1expect class KtorClientFactory() {
2    fun build(): HttpClient
3}
4
5
6iOS
7actual class KtorClientFactory {
8    actual fun build(): HttpClient {
9        return HttpClient(Ios) {
10            install(JsonFeature) {
11                serializer = KotlinxSerializer(
12                    kotlinx.serialization.json.Json {
13                        ignoreUnknownKeys = true
14                        useAlternativeNames = false
15
16Android
17actual class KtorClientFactory {
18    actual fun build(): HttpClient {
19        return HttpClient(Android) {
20            install(JsonFeature) {
21                serializer = KotlinxSerializer(
22                    kotlinx.serialization.json.Json {
23                        ignoreUnknownKeys = true
24

Hier ist zu erkennen, dass die Implementierungen jeweils recht ähnlich sind. Der wichtigste Unterschied ist, dass bei der jeweiligen Plattform die passende Client-Engine (iOS respektive Android) an den HttpClient übergeben wird. Zur Serialisierung der Klassen nutzt KMM in beiden Fällen die KotlinX Serialization.

Mit der Implementierung der KtorClientFactory ist die Grundlage für die Remote-Anbindung bereits eingearbeitet. Das ist gleichzeitig die einzige Stelle, an der wir zwischen iOS und Android unterscheiden müssen. Die restliche Implementierung erfolgt wieder als komplett geteilte Codebasis.

Asynchronität

Neben vielen Klassen und Methoden, die problemlos zwischen iOS und Android teilbar sind, gibt es auch solche, bei denen das nicht ohne Weiteres machbar ist. Ein Beispiel ist die Verwendung von Coroutines. Um Asynchronität zu implementieren, bietet Kotlin für Android die kotlinx.coroutines-Bibliothek an. Das ist unter anderem für die Implementierung der Remote-Anbindung nötig. iOS kann diese Funktionalität jedoch nicht nutzen.

Zur Lösung dieses Problems gibt es verschiedene Ansätze:

KMP Beta

Zum einen kann man ganz entspannt einen Kaffee trinken und warten, bis KMM in die Beta kommt . Hier wird laut JetBrains ein neuer Kotlin/Native-Memory-Manager zum Einsatz kommen. Damit sollen die Differenzen zwischen den beiden System begradigt werden und das Arbeiten mit Asynchronität deutlich vereinfacht werden.

FlowHelper

Wer nicht warten kann, hat auch jetzt schon Möglichkeiten. In unserer Beispiel-App haben wir das ganze mit einem Wrapper gelöst. Der FlowHelper kann unterscheiden, ob es eine iOS-App oder eine Android-App ist.

Flow Helper
1fun <T> Flow<T>.asCommonFlow(): CommonFlow<T> = CommonFlow(this)
2
3class CommonFlow<T>(private val origin: Flow<T>) : Flow<T> by origin {
4    fun collectCommon(
5        coroutineScope: CoroutineScope? = null, // 'viewModelScope' on Android and 'nil' on iOS
6        callback: (T) -> Unit, // callback on each emission
7    ) {
8        onEach {
9            callback(it)
10        }.launchIn(coroutineScope ?: CoroutineScope(Dispatchers.Main))
11    }
12}
13

Für Android gibt man dem FlowHelper einfach den Coroutinescope mit und setzt den Callback auf null. Der Rest funktioniert dann wie gewohnt mit der coroutinex.flow dependency.

collectCommon Implementierung Android
1searchBicycleSharingSystems.execute(
2    country = state.value.country,
3).collectCommon(viewModelScope) { dataState ->
4    state.value = state.value.copy(isLoading = dataState.isLoading)
5
6    dataState.data?.let { bicycleSharingSystems ->
7        appendBicycleSharingSystem(bicycleSharingSystems)
8        saveAllBicycleSharingSystems(bicycleSharingSystems)
9    }
10}
11

Bei iOS gibt man einen Callback mit. Man implementiert hier eine Funktion, die KMM dann anstelle der Couroutines ausführt.

collectCommon Implementierung iOS
1searchBicycleSharingSystems.execute(
2    country: currentState.country
3).collectCommon(
4    coroutineScope: nil,
5    callback: {
6        >>> Here you can write your code
7    })
8

Datenbankzugriff

Für die Datenbankanbindung haben wir SQLDelight genommen. Das war zum Zeitpunkt der Erstellung das einzige Framework, das sowohl für Android wie auch für iOS eine Implementierung zur Verfügung stellte. Neben SQL Delight gibt es inzwischen auch die Möglichkeit, Realm als Datenbank zu nutzen.

Grundlegende Implementierung

Ähnlich der Remote-Schnittstelle mit Ktor bedarf es hier ebenfalls einer plattformspezifischen Implementierung der Treiber. Das haben wir ähnlich der Implementierung der PeopleInSpace App von John O’Reilly umgesetzt.

Platform Module für die Datenbank
1expect fun platformModule(): Module
2

In den jeweiligen plattformspezifischen Modulen wird der passende Treiber genutzt und das Datenbankschema wie auch der Datenbankname übergeben.

Platform Module für die Datenbank – iOS
1actual fun platformModule() = module {
2    single {
3        val driver = NativeSqliteDriver(KmmBikeShareDatabase.Schema, "BikeShareDb.db")
4        KmmBikeShareDatabaseWrapper(KmmBikeShareDatabase(driver))
5    }
6}
7
Platform Module für die Datenbank – Android
1actual fun platformModule() = module {
2    single {
3        val driver =
4            AndroidSqliteDriver(KmmBikeShareDatabase.Schema, get(), "BikeShareDb.db")
5        KmmBikeShareDatabaseWrapper(KmmBikeShareDatabase(driver))
6    }
7}
8

Das Datenbankschema wird zunächst in SQLDelight-Dateien definiert. Hier wird klassisch erst die Tabelle mit den Attributen und deren Eigenschaften definiert. Danach erfolgen die konkreten Datenbankabfragen (CRUD-Methoden), die später für die App zum Einsatz kommen. Innerhalb der build.gradle.kts wird die SQLDelight-Erweiterung definiert und sowohl der Datenbankname, als auch das Verzeichnis zu den oben beschriebenen Datenbankschemata hinterlegt. Um das anschließend nutzen zu können, müssen wir diesen Abschnitt zunächst kompilieren. Erfolgt eine Änderung an dem Schema, muss ein Rebuild erfolgen, damit die Änderungen wirksam werden.

SQLDelight erzeugt aus den Dateien einige neue Dateien, darunter die Implementierung der Datenbank, die die notwendigen Methoden und Verknüpfungen für die Entitäten und die Queries enthält. Außerdem sind hier weitere grundlegende Methoden inbegriffen, zum Beispiel getLastInsertRowId, die die zuletzt eingefügte ID zurückgibt. Das ist später sehr nützlich, um bei einem Insert-Befehl die ID zurückzugeben, um damit weiterzuarbeiten.

Die eigentliche Implementierung besteht aus dem Mapper, dem Datenbankmodell und den jeweiligen Repositories.

Test

Ein großer Vorteil der geteilten Codebasis ist, dass die Tests ebenfalls geteilt werden können.

In der App haben wir Tests für die lokalen Datenbankimplementierungen und für die Interactors erstellt. In diesem Abschnitt geht es also nur um die Tests, die sich innerhalb der geteilten Codebasis befinden.

Als Grundlage für die Tests haben wir den BaseTest eingebaut. Er ist im commonMain Package ebenfalls als expect deklariert, da es hier nötig ist, eine Unterscheidung zwischen Android und iOS zu treffen. Der BaseTest ist grundlegend so aufgebaut, dass es möglich ist, darin eine Suspend Function aufzurufen. Dazu muss er in einem CoroutineScope laufen. Außerdem müssen die Module für die Dependency Injection angelegt werden. Diese beiden Aufgaben sorgen dafür, dass die Basisklasse separat für Android und iOS gehandhabt werden muss. Eine weitere wichtige Aufgabe in Bezug auf Koin und den CoroutineScope ist es, diese zu starten und vor allem auch wieder zu stoppen.

BaseTest expect im Common Main
1expect abstract class BaseTest() : KoinTest {
2    fun <T> runTest(block: suspend CoroutineScope.() -> T)
3}
4

Android

Unter Android gestaltet sich das unkompliziert. Hier können wir eine KoinTestRule erzeugen, die eine finished– und eine starting-Methode beinhaltet, die KMM dann automatisch ausführt. Der Coroutine Scope wird ebenfalls selbstständig gestartet und anschließend auch wieder beendet.

BaseTest unter Android
1@RunWith(AndroidJUnit4::class)
2actual abstract class BaseTest : KoinTest {
3    @get:Rule
4    val koinTestRule = KoinTestRule.create {
5        androidContext(ApplicationProvider.getApplicationContext())
6        modules(
7            network,
8            ...
9        )
10    }
11    actual fun <T> runTest(
12        block: suspend CoroutineScope.() -> T
13    ) { runBlocking { block(
14}
15

iOS

Für iOS müssen wir die gleiche Funktionalität erzeugen. Aktuell gib es hierfür noch keine spezielle Bibliothek, die das gewährleistet. Um die Funktionalität dennoch zu erzeugen, wurde eine Implementierung benutzt, die aus dem touchlab/KaMPkit-Repository stammt.

Für iOS wird der Dispatcher händisch in die BaseTest-Klasse eingebracht und erzeugt ebenfalls eine Methode, mit der die Tests ausführbar sind. Auch hier ist zu sehen, dass Koin zunächst gestartet wurde und später wieder beendet wird. Ebenfalls wird ein CFRunLoop gestartet, der eine Basisimplementierung unter iOS ist. Dieser wird zum Ende hin ebenfalls beendet.

BaseTest unter iOS
1actual abstract class BaseTest : KoinTest {
2
3    @OptIn(DelicateCoroutinesApi::class)
4    actual fun <T> runTest(block: suspend CoroutineScope.() -> T) {
5        var error: Throwable? = null
6
7        GlobalScope.launch(Dispatchers.Main) {
8            try {
9                initKoin()
10                block()
11            } catch (t: Throwable) {
12                error = t
13            } finally {
14                stopKoin()
15                CFRunLoopStop(CFRunLoopGetCurrent())
16            }
17        }
18        CFRunLoopRun()
19        error?.also { throw it }
20    }
21}
22

Die Testklassen erben nun vom BaseTest. Innerhalb der Testklasse erzeugen wir zunächst eine Methode, die vor jedem Test läuft und einen Basissatz an Daten in der Datenbank implementiert. Sie ist mit @BeforeTest annotiert und nutzt die runTest-Methode aus dem BaseTest.

An den Tests wird nun noch die Annotation @Test notiert. Ebenso wird hier die runTest-Methode aus der BaseTest-Klasse wieder verwendet. Die Daten, die die Setup-Methode erzeugt, stehen anschließend vor jedem der Tests zur Verfügung. Die restliche Testimplementierung erfolgt wiederum nach dem Aufbau, wie er von der klassischen Android-Entwicklung bekannt ist.

Conclusion

Damit sind die wichtigsten Aspekte von Kotlin Multiplatform Mobile aufgezeigt. Wenn wir einmal auf die Ausgangsfrage zurückkommen, ob JetBrains nur eine weitere Kopie eines bestehenden Crossplatform Frameworks gebaut hat, kann man das ganz klar verneinen.
KMM verfolgt einen deutlich anderen und vor allem freieren Weg, der auch jetzt schon viel Anklang in der Welt der Mobile-App-Entwicklung erhält.
Es gibt bereits jetzt für alle wichtigen Teilbereiche, die in der App-Entwicklung nötig sind, einsatzbereite Frameworks. Wir können von der Businesslogik, über die Remoteanbindung bis zu den Tests alles teilen.

Dennoch gibt es auch Hürden, die man nehmen muss. Die Ersteinrichtung vor allem auf iOS-Seite ist mitunter schwierig. Auch sind die externen Bibliotheken mitunter noch nicht für KMM ausgelegt. Und auch die kleinen Schwierigkeiten mit dem Memory-Modell lassen noch Raum für Verbesserung.

Alles in allem sollte man dieses Framework nicht aus dem Blick verlieren. Für den entsprechenden Use Case kann und wird sich KMM durchaus relevant einsetzen lassen.

Beitrag teilen

Gefällt mir

1

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen

Wir helfen Deinem Unternehmen

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.