Met Kotlin Multiplatform moet je een Android-app zonder veel moeite ook voor iOS, desktops en het web kunnen uitbrengen. We bekijken de huidige stand van zaken van het project aan de hand van een eenvoudige voorbeeld-app.
Éen codebasis voor meerdere platforms
Het ontwikkelen van een app met één gemeenschappelijke codebasis voor mobiele apparaten, desktopcomputers en browsers heeft veel voordelen. De app ziet er overal hetzelfde uit, de programmalogica en -persistentie zijn identiek, algoritmen hoeven maar één keer geschreven te worden en bugs hoeven maar één keer verholpen te worden. Dat bespaart ontwikkeltijd en -middelen.

Platformoverschrijdend ontwikkelen is al lang mogelijk met verschillende frameworks en programmeertalen. Googles Flutter met Dart, Meta’s React Native met TypeScript en Microsofts .NET MAUI (voorheen Xamarin) met C# worden veel gebruikt. Swift voor Android en het Skip.tools-project zijn speciaal bedoeld voor iOS-ontwikkelaars die Android-apps willen bouwen met Apples huidige interfaceframework SwiftUI.
Als je daarentegen uit de Android-ontwikkeling komt en al ervaring hebt opgedaan met Kotlin en Compose, dan is Kotlin Multiplatform (KMP) een zinvolle uitbreiding om extra doelplatforms te ondersteunen.
Voorbereiding
Dat nog vrij jonge en dynamische project is afkomstig van de JetBrains-ontwikkelaars, die ook de leiding hebben bij Kotlin en Compose. Apps van McDonald’s en Duolingo zijn bekende voorbeelden die met KMP ontwikkeld zijn. Sinds het najaar van 2025 is ook officiële ontwikkeling voor het web mogelijk.
KMP is gebaseerd op Googles Jetpack Compose voor de grafische interface en een set platformonafhankelijke bibliotheken. Android Studio is geschikt als ontwikkelomgeving. Uitgebreide installatie-instructies voor Windows, macOS en Linux vind je in de officiële documentatie. Alle links, bestanden en info staan onderaan dit artikel.
Eerst moet je via Settings / Plugins de plug-in Kotlin Multiplatform aan de IDE toevoegen. Mocht je de plug-in niet op de lijst vinden, dan kan dat komen doordat de versie van de plug-in die op de Marketplace is gepubliceerd nog niet compatibel is met de nieuwste versie van Android Studio. In dat geval kun je de plug-in handmatig downloaden en installeren vanaf de JetBrains-website (zie de links).
Vervolgens biedt de wizard voor het aanmaken van een nieuw project in het gedeelte Generic de mogelijkheid om een Kotlin Multiplatform-project aan te maken. Daar geef je eerst een naam aan het project en selecteer je daarna de platforms die je wilt ondersteunen. Je kunt kiezen uit Android, iOS, Desktop, Web en Server.

Desktop maakt een op Java gebaseerde desktop-app die draait met een Java Runtime Environment (JRE) op Windows en macOS – het is dus geen native Windows-EXE of Mac-app. Server is bedoeld voor een op het Ktor-framework gebaseerde client-server-app, vergelijkbaar met het bekende Node.js, die communiceert met frontend-apps die in hetzelfde project zitten.
Voor de duidelijkheid beperken we ons in dit artikel tot Android, iOS en Web en kiezen we telkens de optie Share UI om Compose Multiplatform als gezamenlijke UI te gebruiken.
Nadat je op Finish hebt geklikt, gaat de wizard aan de slag en genereert hij een Hello-World-app met een op het eerste gezicht overweldigend aantal submappen. Vervolgens downloadt hij zo’n 400 MB aan bibliotheken. De eerste Gradle-sync kan daarom even duren. Op Windows kan het gebeuren dat Defender tijdens het opbouwen van het project een melding geeft en vraagt of adb.exe (de Android Debug Bridge) mag worden uitgevoerd. Dat kun je bevestigen.

Even een spoiler vooraf: als je je app ook beschikbaar wilt maken voor iPhones en iPads, ontkom je niet aan de aanschaf van Mac-hardware. Het bouwen van een iOS-app is alleen mogelijk met Xcode, en dat draait uitsluitend op Macs.
De KMP-wizard maakt een Xcode-frameworkproject aan, waarin de in Kotlin geschreven code als framework geïntegreerd wordt. Je kunt de iOS-app zeven dagen lang gratis testen in de Xcode-simulator op maximaal drie apparaten. Als je hem langer wilt gebruiken of zelfs in de App Store van Apple wilt plaatsen, heb je echter een Apple Developer-lidmaatschap nodig (99 euro per jaar).
Mocht je problemen hebben bij het bouwen van KMP op de Mac, dan helpt de tool kdoctor, die je kunt installeren met brew install kdoctor. Die geeft gedetailleerdere informatie dan de Preflight Checks die in de IDE beschikbaar zijn. Als kdoctor meldt dat er een Java Development Kit (JDK) ontbreekt, kun je die het beste installeren met brew install openjdk en de instructies volgen.
Structuur
Het grote aantal gegenereerde mappen en afhankelijkheden kan in het begin wat intimiderend overkomen. Het wordt overzichtelijker als je bij Android Studio in het projectoverzicht linksboven het filter instelt op Project Source Files. De belangrijkste map is shared/src/commonMain. Daar staan de code en bronnen die op alle platforms gemeenschappelijk moeten worden gebruikt.
Het centrale startpunt is het bestand App.kt in de map Kotlin. Dat bevat de Composable-interface. Het bestand Platform.kt definieert interfaces voor platformspecifieke code, daarover later meer. De map iosMain bevat iOS-specifieke code, geschreven in Kotlin. De Android-tegenhanger daarvan heet androidMain.
Webapps kunnen op twee manieren worden gebouwd: via WASM of als een traditionele JavaScript-app. De map wasmJsMain bevat platformspecifieke code voor de WASM-app, terwijl jsMain de Kotlin-code voor het JavaScript-project bevat. Aangezien alle gangbare browsers al een tijdje WASM ondersteunen, is er eigenlijk geen reden meer om voor de JavaScript-buildoptie te kiezen.
De map androidApp bevat het raamwerk voor een klassieke Android-app, waaronder een Activity, de pictogramresources en het bestand Android-Manifest.xml.
Het Xcode-project waarmee de app voor iOS-apparaten wordt gebouwd, zit in iosApp. Het integreert de Kotlin-implementatie als framework. Daar wordt het iOS-specifieke app-icoontje opgeslagen, dat je het beste kunt maken met de Icon Builder die bij Xcode hoort en vervolgens aan het project toevoegen.
Daarnaast is er het configuratiebestand Configuration/Config.xcconfig met informatie voor de door Gradle geactiveerde Xcode-build. Dit bevat onder andere de TEAM_ID, die nodig is om de app te ondertekenen als je hem op iOS-apparaten wilt installeren.
De map webApp bevat het raamwerk voor een webapp met index.html als startpagina. Dat is de juiste plek om een favicon aan te maken, die de browser op het tabblad en bij het aanmaken van een bladwijzer laat zien.

Voorbeeld-app
Als voorbeeldapp hebben we een Buzzword-Bingo-Compose-project gebruikt en laten we zien hoe je zo’n project naar Kotlin Multiplatform kunt converteren (de projectcode staat bij de links). Daar zijn slechts kleine aanpassingen voor nodig. Werk in eerste instantie uitsluitend in de map shared/src/common-main – platformspecifieke toevoegingen volgen later.
Voordat je begint met de conversie, is het de moeite waard om onder Settings / Editor / General / Auto-Import de selectievakjes bij Add unambiguous imports on the fly en Optimize imports on the fly aan te vinken. Daardoor worden bij het kopiëren van de code veel fouten door gewijzigde imports automatisch verholpen. Mocht je daarna nog steeds rood gemarkeerde problemen in de code zien, dan helpt de functie Auto-Resolve – te openen met Alt-Enter of op de Mac met Option-Enter – bijna altijd.
De eigenlijke code van de Android-app zit in het bestand bingo.kt met de dataklasse Bingo en in Main-Activity.kt met de Compose UI (de code staat in het oude project onder app/src/main/java, zie de links).
Het bestand bingo.kt kopieer je rechtstreeks naar de map shared/src/commonMain/kotlin. De grafische interface van de app is gebouwd met het declaratieve Compose-framework, dat de interface beschrijft met speciale functies, zogenaamde composables. De interface wordt automatisch bijgewerkt bij wijzigingen in het datamodel en past zich altijd aan de beschikbare ruimte aan.
Dat werkt ook heel goed bij multiplatformprojecten. Daarvoor kopieer je de composable AppTheme uit de onCreate ()-functie van het bestand MainActivity.kt naar de functie App() van het bestand App.kt en vervangt daar het automatisch gegenereerde MaterialTheme().
Alle andere functies uit MainActivity.kt verplaats je ook naar App.kt. Daarnaast heb je uit het oude project nog de submap ui/themes nodig met de bestanden Color.kt en Theme.kt, die het uiterlijk van de Compose-interface configureren. Daar moeten de dynamische Color Schemes, die alleen voor Android beschikbaar zijn, uit Theme.kt (regels 264–266) worden verwijderd.
Na het uitvoeren van Code / Optimize Imports en het corrigeren van het pakketpad in beide bestanden zouden er geen fouten meer moeten optreden. Dan zijn er nog maar een paar aanpassingen nodig om het project te kunnen bouwen.
In de Android-app hebben we in bingo.kt android.os.Parcelable gebruikt om de gegevensstructuur van het bingoveld beschikbaar te maken voor Compose. Dat pakket is alleen beschikbaar voor Android en veroorzaakt daarom een fout bij de Multiplatform-build. Je lost dat op door over te stappen op kotlinx.serialization.Serializable. Daarvoor voeg je in het bestand shared/build.gradle.kts onder plugins deze regel toe:
alias(libs.plugins.kotlinSerialization)
Daarnaast moet je aan het einde van gradle/libs.versions.toml het volgende toevoegen:
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
De kotlinSerialization-plug-in heeft altijd dezelfde versie als de kotlin-plug-in. Mocht je de bestanden niet kunnen vinden, dan moet je tijdelijk overschakelen van de eenvoudige weergave Project Source Files naar Project Files.
Vervolgens kun je dan @Serializable in plaats van @Parcelize voor de Bingo-klasse zetten, de :Parcelable een paar regels daaronder verwijderen en de door Android Studio voorgestelde Gradle-synchronisatie uitvoeren.
Achter de schermen genereert de Gradle-plug-in automatisch de code die nodig is voor de serialisatie.
De functies assert() en clone() zijn Java-functies en kunnen niet worden gebruikt in platformonafhankelijke code. Waarom dat zo is, daar komen we later nog op terug. Voorlopig volstaat het om de assert-regel simpelweg met // uit te commentariëren en
val selected = this.selected.map {
it.clone() }.toTypedArray()
te vervangen door
val selected = Array(numBingo) {
this.selected[it].copyOf() }
Na nog een keer Optimize Imports moeten er dan geen fouten meer optreden in bingo.kt.
We gaan verder in het bestand App.kt. In plaats van rememberSaveable staat er nu in de Composable BingoField in App.kt:
var bingo by
rememberSerializable (words) {
mutableStateOf(Bingo(words)) }
De ingebouwde woordenlijst words.txt stond in de Android-app in de map assets en werd oorspronkelijk met assets.open() in bingo.kt ingelezen. In KMP-projecten sla je generieke assets op in de map shared/src/commonMain/composeResources/files en lees je ze in App.kt asynchroon in met
suspend fun loadWords(): List<String>
{
return Res.readBytes(
"filess/words.txt"
).decodeToString().split("\n")
}
in een coroutine. In de Compose-UI in App.kt staat dan:
val words = rememberSaveable { ... }
LaunchedEffect(Unit) {
words.value = loadWords() }
Omdat het laden asynchroon gebeurt, maar er bij het opstarten al een (eenduidige) woordenlijst aanwezig moet zijn, maak je die het beste in het begin boven val words aan met
val allwords =
(0 .. < numBingo * numBingo)
.map { "$it" }
Mocht je tussendoor bij al die aanpassingen even het overzicht kwijtraken, dan helpt het om even in de repository te kijken en te vergelijken (zie de links).
Pictogrammen die op alle platforms worden gebruikt, zoals het bingo-icoontje dat verschijnt als je bingo hebt, horen thuis in de map composeResources/drawable. Daarvoor kun je het bestand ic_launcher_foreground.webp uit het oude project gewoon blijven gebruiken, je hoeft het alleen maar te hernoemen naar bingoicon.
In de composable YouWonBingoAlert() in App.kt vervang je de oude Image-vermelding door het volgende:
Image(painterResource(Res.drawable.bingoicon), contentDescription = "Bingo-icoontje")
Teksten die vertaald moeten worden, worden opgeslagen als strings.xml-bestanden in composeResources/values. De code gebruikt ze via stringResource() – de bestanden kun je uit de repository halen. De Refresh-knop in App.kt ziet er dan bijvoorbeeld zo uit:
val coroutineScope =
rememberCoroutineScope()
Button(onClick = {
coroutineScope.launch {
words.value = loadWords().shuffled()
}
}) {
Text(stringResource(Res.string.refresh)) }
Let op: je ziet de resource-mappen en -bestanden alleen als je het filter instelt op Project Non-Source Files. Nadat je een paar logregels uit het oude Android-project hebt uitgecommentarieerd, is het overzetten voltooid.
Als er dan nog fouten optreden tijdens het builden, komt dat meestal door ontbrekende imports, die je kunt toevoegen door op het rode foutpictogram te klikken of via Alt-Enter/Option-Enter.
Starten
Op de werkbalk helemaal rechtsboven kun je dan een van de platforms kiezen om de app te starten en links daarvan de bijbehorende simulator. Standaard is androidApp geselecteerd en een Medium Phone-simulator voor de Android-app. Als er nog geen beschikbaar is, voeg je die toe via de wizard in Tools/Device Manager.
De optie webApp [wasmJs] start een lokale webserver en opent de standaardbrowser, iosApp bouwt de app op de achtergrond met de Xcode-buildtools en start die in de geselecteerde simulator of op het apparaat. Daarvoor moet Xcode minstens één keer eerder zijn geopend, zodat de bijbehorende componenten geladen worden.
Debuggen is ook mogelijk: het plaatsen van breakpoints en het inspecteren van variabelen werkt zowel in de gedeelde als in de platformspecifieke delen van de broncode.
De bijbehorende Gradle-targets kun je ook vanaf de commandline starten. Zo maak je het WASM-project aan met ./gradlew wasmJsBrowser Distribution en kopieer je vervolgens de map webApp/build/dist/wasmJs/productionExecutable met alle bestanden die erin zitten naar de webserver.
Een APK of app-bundel voor de Google Play Store maak je het makkelijkst via het Build-menu in Android Studio. De iOS-app kun je direct in Xcode bouwen en met de ingebouwde Organizer naar de App Store van Apple uploaden.
Bijzonderheden
Als de app na verloop van tijd meer functies krijgt, komen er op een gegeven moment platformspecifieke onderdelen bij voor functies die niet overal beschikbaar zijn of anders geïmplementeerd worden. In KMP gebruik je daarvoor interfaces die met de Kotlin-taalfunctie expect worden gedeclareerd en met actual worden geïmplementeerd.
Android-apps worden gecompileerd met Kotlin/JVM. Dat is bij apps voor de andere platforms niet het geval, daar worden de Kotlin/Native- of Kotlin/WASM-compilers gebruikt. Daarom zijn Java-klassen zoals java.util.Date of ook specifieke functies zoals assert en clone niet beschikbaar in de gemeenschappelijke code in commonMain.
Ook Android-specifieke API’s en klassen zoals Context en Activity kun je daar niet gebruiken. Functies die daar gebruik van maken, moeten in het platformspecifieke deel in androidMain worden geïmplementeerd.
In de app hebben we het afspelen van een klein geluidsbestand platformspecifiek geïmplementeerd: bij Android met MediaPlayer, bij iOS met de AVAudioPlayer en op het web met de Audio-API.
In de gemeenschappelijke code definieer je een interface en declareer je met expect een functie die elders gedefinieerd moet worden en die een implementatie van die interface genereert.
interface Platform {
fun playFanfare() }
expect fun getPlatform(): Platform
De interface-functie wordt vervolgens per platform anders geïmplementeerd, bijvoorbeeld voor Android in Platform.android.kt met
lateinit var appContext: Context
class AndroidPlatform: Platform {
override fun playFanfare() { ... }
}
actual fun getPlatform() =
AndroidPlatform()
Veel Android-API’s hebben toegang nodig tot een Context-object, bijvoorbeeld om bij bronnen te komen. Dat regel je het makkelijkst in MainActivity.onCreate (map androidApp/src/main/kotlin) en sla je op in een globale variabele in de platformspecifieke code in shared/src/androidMain/kotlin. Dat werkt omdat het androidApp-project een link legt naar het androidMain-project.
Voor de iOS-frameworks genereert KMP automatisch bindings, zodat je ze vanuit de Kotlin-code in Platform.ios.kt kunt aanroepen:
import platform.AVFAudio.AVAudioPlayer
import platform.Foundation.NSURL
class IOSPlatform: Platform {
private var avAudioPlayer:
AVAudioPlayer? = null
override fun playFanfare() {
val fanfarePath = Res.getUri("files/fanfare.mp3")
val fanfareURL = NSURL.URLWithString(fanfarePath)!!
avAudioPlayer = AVAudioPlayer(contentsOfURL = fanfareURL, error = null).apply {
prepareToPlay()
play()
}
}
}
Tijdens het builden van het betreffende platform wordt gecontroleerd of er voor elke expect-declaratie een actual-implementatie aanwezig is. Als je bepaalde functies alleen op één platform wilt ondersteunen, kun je direct in de interface een lege standaardimplementatie opgeven:
interface Platform {
fun playFanfare() = Unit
}
In een tweede uitbreidingsfase hebben we naast de genoemde platformspecifieke code ook app-iconen voor de verschillende platforms toegevoegd en animaties voor het schudden van de woordenlijst ingebouwd. Het voltooide project vind je op GitLab, de webapp start ook via GitLab (zie de links onderaan dit artikel).
Samen of apart
Een multiplatformproject heeft alleen zin als zo veel mogelijk delen van de app in de gedeelde codebasis ontwikkeld worden. Dat kan goed werken voor eenvoudige apps die bijvoorbeeld een grafische interface voor een servertoepassing bieden.
Dergelijke apps hebben dan echter vaak last van het ‘kleinste gemene deler’-principe en gebruikers moeten afzien van specifieke functies van hun toestel of ongewone bedieningswijzen leren. Pop-ups en animaties voelen vreemd aan omdat ze niet precies aan de standaard van het platform voldoen.
Ook bij nieuwe Android- of iOS-versies blijven dergelijke projecten vaak achter. Zo kan KMP het nieuwe Glass-ontwerp van Apple tot nu toe niet implementeren.
Helemaal zonder platformspecifieke onderdelen lukt het daarom zelden. De manier waarop machtigingen worden beheerd – bijvoorbeeld voor toegang tot de camera, de huidige locatie of de mediabibliotheek – verschilt tussen Android, iOS en browsers. Het laden en delen van bestanden gebeurt bij Android via Intents, iOS gebruikt app-extensies en speciale API’s, en browsers hebben maar heel beperkte upload- en downloadmogelijkheden.
Voor de integratie van Apples iCloud en de Google-diensten moet er ook platformspecifieke code worden geschreven. Zorgvuldig testen op alle ondersteunde apparaten en platforms is absoluut onvermijdelijk. Bugs die in de gedeelde code zitten, maar alleen op één platform optreden, kunnen lastig te vinden en op te lossen zijn.
Naast programma-specifieke fouten en bugs in het betreffende besturingssysteem komen daar ook nog potentiële problemen in de KMP-frameworks bij. Zo toont de voorbeeld-app in de webversie in de pop-up dat je gewonnen hebt alleen een placeholder in plaats van een emoji.
Ook voor de deployment, oftewel het uploaden van de apps naar de appstores van Google en Apple, heb je specifieke kennis nodig.
Bovendien is het lastiger om de complexe, snel evoluerende toolchain en de frameworks van KMP op te zetten en up-to-date te houden dan puur te ontwikkelen in Android Studio of Xcode, die door hun integratie veel werk uit handen nemen.
De extra abstractielagen en de vele geïntegreerde bibliotheken leiden ook tot relatief grote binary’s. Zo is de via KMP gemaakte APK voor Android 24 megabyte groot, als je dat met alleen Compose doet heb je maar iets minder dan 2 megabyte nodig.
De WASM-bestanden voor het web zijn meer dan 12 megabyte groot. Voor eenvoudige webapps die ook bij een slechte internetverbinding moeten werken, is dat gewoon te veel – zeker omdat de pure broncode van de app maar zo’n 30 kilobyte beslaat.
Als de platformspecifieke functies in de loop van de ontwikkeling steeds verder toenemen, kan het op een gegeven moment zinvol zijn om de apps toch apart te ontwikkelen en alleen bepaalde onderdelen, zoals de logica en afzonderlijke, op zichzelf staande delen, in een gemeenschappelijke codebasis te houden. Die kun je dan via bibliotheken in de native projecten integreren.
Conclusie
Kotlin Multiplatform wil veel verwezenlijken. Het is een gigantische klus om complexe Compose-interfaces en de uitgebreide functies van moderne mobiele en desktopapparaten platformonafhankelijk beschikbaar te maken.
In dit artikel hebben we bewust een heel eenvoudige app, geschreven in Kotlin en Compose, naar KMP overgezet. Daarvoor waren wat aanpassingen in de code nodig. Het bewerken en opslaan van de woordenlijst hebben we voor de duidelijkheid weggelaten. Die functies zou je kunnen implementeren met het Compose Navigation 3-framework en Jetpack DataStore.
Het KMP-project is volop in ontwikkeling; er komen in hoog tempo wijzigingen en nieuwe functies bij. Dat is tegelijk een voor- en nadeel – het loont de moeite om af en toe een kijkje te nemen op het Kotlin Multiplatform-blog.
Andreas Linke en Noud van Kruysbergen
Links bij dit artikel
Buzzword-Bingo Compose-projekt
Geporte eerste versie van de KMP-app
Project-repository
Werkende B-Bingo-web-app
Download Android Studio
Officiële installatiehandleidingen
Kotlin multiplatform-plugin
Praat mee