Kotlin Multiplatform (KMP)
Irbisa · cheatsheetSeptember 13, 2026

Kotlin Multiplatform (KMP)

expect/actual, source sets, sharing logic, ktor, SQLDelight, iOS interop

Senior Developer20 itemscompressed for a skim
  1. 01

    What is Kotlin Multiplatform (KMP) and what does it share between iOS and Android?

    Medium

    KMP 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()
    }
    …
  2. 02

    What are expect/actual declarations in KMP?

    Medium

    expect/actual is KMP's mechanism for platform-specific implementations of a shared API.

    // commonMain/Platform.kt
    expect class Platform() {
      val name: String
    }
    
    expect fun currentTimeMillis(): Long
    …
  3. 03

    How does Swift consume KMP code, and where does the Objective-C bridge hurt?

    Medium

    Kotlin/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)) }
    }
    …
  4. 04

    Which libraries would you reach for in a KMP shared module for networking, persistence, DI and logging?

    Medium

    The 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()
    …
  5. 05

    How high up the stack can KMP sharing reach — can ViewModels and navigation be shared, or do they stay native?

    Hard

    The 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()
    …
  6. 06

    Your team ships the same product on iOS and Android. How do you decide whether KMP is worth adopting?

    Hard

    KMP 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,
    …
  7. 07

    Where does code that iOS and Android share, but the JVM target must not see, actually live in a KMP module?

    Medium

    In an intermediate source set — the source-set tree is a hierarchy, not a flat list of platforms.

    kotlin {
      androidTarget()
      iosArm64()
      iosSimulatorArm64()
      jvm("desktop")
    …
  8. 08

    What does the iOS app actually depend on — how does the shared Kotlin module get into an Xcode project?

    Medium

    Kotlin/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
        }
    …
  9. 09

    How do you test a shared KMP module, and why can't you reach for MockK there?

    Medium

    Tests in commonTest compile 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. 10

    Which coroutine dispatchers exist on iOS in a shared module, and what threading rules still differ from the JVM?

    Hard

    Kotlin/Native has a normal multithreaded runtime, so Dispatchers.Main, Default and IO all 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. 11

    The shared module needs Keychain access. How does Kotlin call an Apple API, and when do you need cinterop?

    Medium

    Kotlin/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. 12

    Kotlin/Native is garbage collected and Swift uses ARC. What happens to an object that both sides hold?

    Hard

    An 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. 13

    Your SQLDelight cache stutters the UI on both platforms. What do the generated query functions actually do when you call them?

    Medium

    SQLDelight 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. 14

    The same Ktor request succeeds on Android and fails on iOS with a TLS or timeout error. Where does that difference actually live?

    Medium

    HttpClient is 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. 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?

    Medium

    A 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 commonMain instead of into iosMain.

    // 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. 16

    iOS engineers say every build got two minutes slower after the shared module landed. What is actually slow, and what do you change?

    Medium

    Kotlin/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. 17

    An iOS release crashes with "Uncaught Kotlin exception" from inside a Swift do/catch. Why did the catch not help?

    Hard

    Kotlin has no checked exceptions and the Objective-C bridge cannot guess which ones you meant as errors: a function is throws in Swift only if it carries @Throws, and an exception reaching the boundary any other way terminates the process before any catch runs.

    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. 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?

    Hard

    The 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. 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?

    Hard

    The 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. 20

    Why does KMP hand iOS an Objective-C framework instead of shipping a runtime the way Flutter and React Native do?

    Hard

    Because 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"
      }
    …