Kotlin Multiplatform (KMP)
expect/actual, source sets, sharing logic, ktor, SQLDelight, iOS interop
- 01
What is Kotlin Multiplatform (KMP) and what does it share between iOS and Android?
MediumKMP lets you write Kotlin once and compile it for Android, iOS, JVM, JS, Wasm and native desktop; it went Stable in 2023 and is a production choice, not a preview.
// shared/src/commonMain/kotlin/com/app/UserRepo.kt package com.app class UserRepo(private val api: UserApi) { suspend fun me(): User = api.fetchMe() } … - 02
What are expect/actual declarations in KMP?
Mediumexpect/actualis KMP's mechanism for platform-specific implementations of a shared API.// commonMain/Platform.kt expect class Platform() { val name: String } expect fun currentTimeMillis(): Long … - 03
How does Swift consume KMP code, and where does the Objective-C bridge hurt?
MediumKotlin/Native compiles the shared module into an Apple framework that Swift imports like any other module, and almost every pain point comes from the Objective-C bridge in between.
// commonMain — the shared surface Swift will see class UserRepo { suspend fun fetchUser(id: Long): User { /* ... */ return User(id, "Alice") } fun observeUser(id: Long): Flow<User> = flow { emit(fetchUser(id)) } } … - 04
Which libraries would you reach for in a KMP shared module for networking, persistence, DI and logging?
MediumThe KMP ecosystem has converged on one fairly standard stack, and stepping outside it usually costs more than it saves.
// commonMain — the canonical shared core: serializable model, Ktor client, DI graph @Serializable data class User(val id: Long, val name: String) class UserApi(private val client: HttpClient) { suspend fun fetch(id: Long): User = client.get("https://api.example.com/users/\$id").body() … - 05
How high up the stack can KMP sharing reach — can ViewModels and navigation be shared, or do they stay native?
HardThe ViewModel class is genuinely multiplatform now, and navigation can be shared too — but only if the UI itself is Compose Multiplatform.
// commonMain — one ViewModel, bound by both platforms data class CounterUi(val count: Int = 0, val isLoading: Boolean = false) class CounterViewModel(private val repo: CounterRepo) : ViewModel() { private val _state = MutableStateFlow(CounterUi()) val state: StateFlow<CounterUi> = _state.asStateFlow() … - 06
Your team ships the same product on iOS and Android. How do you decide whether KMP is worth adopting?
HardKMP pays off when a large, long-lived business-logic layer would otherwise be written twice, and it costs more than it saves almost everywhere else.
// Decision checklist (in code form) fun shouldUseKMP( isLongLivedProduct: Boolean, bothPlatformsActive: Boolean, businessLogicShareIsHigh: Boolean, teamHasKotlinAndIosSkills: Boolean, … - 07
Where does code that iOS and Android share, but the JVM target must not see, actually live in a KMP module?
MediumIn an intermediate source set — the source-set tree is a hierarchy, not a flat list of platforms.
kotlin { androidTarget() iosArm64() iosSimulatorArm64() jvm("desktop") … - 08
What does the iOS app actually depend on — how does the shared Kotlin module get into an Xcode project?
MediumKotlin/Native builds the shared module into an Apple framework, and there are three ways to hand that framework to Xcode.
kotlin { listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach { target -> target.binaries.framework { baseName = "Shared" // Swift side: import Shared isStatic = true // dynamic if app extensions must share it } … - 09
How do you test a shared KMP module, and why can't you reach for MockK there?
MediumTests in
commonTestcompile and run on every target you build, so the same assertions execute on the JVM, on the iOS simulator and on desktop — and that is exactly why JVM-only tooling is off the table.// commonTest class CartViewModelTest { @Test fun appliesDiscountOnce() = runTest { val repo = FakeCartRepo(items = listOf(Item("sku-1", price = 100))) … - 10
Which coroutine dispatchers exist on iOS in a shared module, and what threading rules still differ from the JVM?
HardKotlin/Native has a normal multithreaded runtime, so
Dispatchers.Main,DefaultandIOall exist on iOS and shared code writes ordinary coroutines.class SyncEngine( private val io: CoroutineDispatcher = Dispatchers.IO, // injected, so tests can swap it private val scope: CoroutineScope ) { private val mutex = Mutex() // not synchronized {} private var lastCursor: String? = null … - 11
The shared module needs Keychain access. How does Kotlin call an Apple API, and when do you need cinterop?
MediumKotlin/Native ships generated bindings for the Apple system frameworks, so an iOS source set can import Foundation, UIKit or Security types and call them straight away.
// commonMain expect class SecureStore() { fun put(key: String, value: String) fun get(key: String): String? } … - 12
Kotlin/Native is garbage collected and Swift uses ARC. What happens to an object that both sides hold?
HardAn object crossing the bridge is owned by both runtimes at once, and each of them can only see its own half of the graph.
// commonMain — hand Swift an explicit cancellation handle class Cancellable(private val job: Job) { fun cancel() = job.cancel() } class FeedViewModel(private val repo: FeedRepo) : ViewModel() { … - 13
Your SQLDelight cache stutters the UI on both platforms. What do the generated query functions actually do when you call them?
MediumSQLDelight generates plain blocking functions —
executeAsList()runs the SQL on the thread that called it, and the library adds no threading of its own.// shared/src/commonMain/sqldelight/com/app/db/Cache.sq // CREATE TABLE post (id INTEGER PRIMARY KEY, title TEXT NOT NULL, savedAt INTEGER NOT NULL); // selectAll: // SELECT * FROM post ORDER BY savedAt DESC; // upsert: // INSERT OR REPLACE INTO post VALUES (?, ?, ?); … - 14
The same Ktor request succeeds on Android and fails on iOS with a TLS or timeout error. Where does that difference actually live?
MediumHttpClientis one API over a different engine per target — OkHttp on Android, NSURLSession through the Darwin engine on iOS — and everything the operating system enforces around the socket belongs to the engine, not to your shared code.// commonMain — everything behaviour-shaped, written once expect fun httpClient(): HttpClient fun HttpClientConfig<*>.shared() { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } install(HttpTimeout) { … - 15
The iOS team owns a Swift Keychain wrapper. What does a Kotlin interface look like from Swift, and what breaks when they implement it?
MediumA Kotlin interface is exported as an Objective-C protocol, so a Swift class can conform to it and be handed straight to shared code — which is how the dependency ends up pointing into
commonMaininstead of intoiosMain.// commonMain — the shared module owns the contract interface TokenStore { fun read(): String? fun write(token: String) suspend fun refresh(): String // a completion handler on the Swift side } … - 16
iOS engineers say every build got two minutes slower after the shared module landed. What is actually slow, and what do you change?
MediumKotlin/Native compiles ahead of time, so the shared module adds a real compile-and-link step to the iOS build — and a two-minute regression is almost always configurations and architectures nobody asked for.
// shared/build.gradle.kts kotlin { listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> target.binaries.framework { baseName = "Shared" isStatic = true … - 17
An iOS release crashes with "Uncaught Kotlin exception" from inside a Swift do/catch. Why did the catch not help?
HardKotlin has no checked exceptions and the Objective-C bridge cannot guess which ones you meant as errors: a function is
throwsin Swift only if it carries@Throws, and an exception reaching the boundary any other way terminates the process before anycatchruns.sealed class AppError(message: String) : Exception(message) { data object Offline : AppError("offline") } // Swift can wrap this in do/catch and it still kills the app class BadRepo(private val api: Api) { … - 18
Swift sees a type that comes from another shared module as an opaque object with no properties. What was missed in the framework setup?
HardThe framework header contains the declarations of the module being compiled plus the dependencies explicitly exported into it — everything else is reduced to a bare forward declaration, so Swift can hold the object and do nothing with it.
// shared/build.gradle.kts kotlin { listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> target.binaries.framework { baseName = "Shared" … - 19
A Kotlin failure on iOS lands in Crashlytics as hex addresses with no Kotlin frames. What has to be in place before that report is readable?
HardThe shared module ships as a native binary, so a Kotlin failure is a native crash: there is no JVM stack trace, the symbols live in a dSYM nothing uploads by default, and the Kotlin exception's own trace is gone by the time the reporter sees the process die.
// shared/build.gradle.kts kotlin { listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> target.binaries.framework { baseName = "Shared" isStatic = true // Kotlin symbols end up in the app's own dSYM … - 20
Why does KMP hand iOS an Objective-C framework instead of shipping a runtime the way Flutter and React Native do?
HardBecause Kotlin/Native compiles ahead of time into an ordinary native binary, and the only stable, compiler-agnostic way to publish an API that Swift can import is Objective-C headers.
// One source set, two compilation paths kotlin { androidTarget() // -> JVM bytecode, runs on ART with the JVM stdlib iosArm64().binaries.framework { // -> klib -> LLVM -> Mach-O framework, no VM baseName = "Shared" } …