KMP compiles the same Kotlin code for several targets (JVM bytecode for Android, a native binary through LLVM for iOS), so business logic is written once while each platform keeps its own UI.
How it compiles: commonMain goes through the JVM backend for Android and through Kotlin/Native for iOS, which links it into an Apple framework with a small runtime and garbage collector. No VM or bridge has to start with the app.
Usually shared: networking (Ktor), serialization (kotlinx.serialization), storage (SQLDelight, Room), domain rules, validation, and often ViewModels through the multiplatform androidx.lifecycle artifacts.
Usually native: UI (unless you choose Compose Multiplatform), navigation, and anything tied to the OS: permissions, push, background work, payments.
Not worth sharing: thin wrappers over platform APIs. Writing them twice is often cheaper than an expect/actual pair plus its interop.
Why bother: one implementation of the rules means the two apps cannot quietly disagree about prices, validation or caching.
KMP has been Stable since November 2023, and Google supports it for sharing business logic between Android and iOS.
AndroidSenior
Kotlin Multiplatform (KMP)
expect/actual, source sets, sharing logic, ktor, SQLDelight, iOS interop
Questions closed
0 / 16
- 0Review
- 1Asked often
Questions
expect declares an API in commonMain without a body, and each platform source set supplies the actual implementation; the build fails if any target is missing one.
Works for functions, properties, objects and classes; an actual for a class may be a typealias to an existing platform type. Functions and properties are stable; expect/actual classes are still Beta in Kotlin 2.x and print a warning unless you pass -Xexpect-actual-classes.
Good fit: small leaf helpers with exactly one implementation per platform (a device name, a typealias to a platform type).
An actual can live in an intermediate source set such as iosMain, so one implementation covers every iOS target.
Prefer an interface plus DI when you want a fake in tests, a choice at runtime, or an implementation written in Swift. An actual is fixed at compile time and leaves no seam for a fake.
Every Gradle target gets its own leaf source set, and the default hierarchy template stacks shared sets above them (iosMain, appleMain, nativeMain) up to commonMain, which compiles for every target.
Leaf sets: one per target: androidMain, iosArm64Main, iosSimulatorArm64Main, jvmMain, wasmJsMain. Truly per-architecture code is rare.
Intermediate sets: iosMain is the parent of all iOS targets, appleMain adds macOS, watchOS and tvOS, nativeMain covers everything Kotlin/Native builds. Code there can call Apple APIs and is still written once.
The rule: a source set may use only what every target below it supports. iosMain can import UIKit; commonMain sees only the common stdlib and multiplatform libraries.
Dependencies are per source set: Ktor core in commonMain, the OkHttp engine in androidMain, the Darwin engine in iosMain.
Tests mirror it: every xxxMain has an xxxTest, and commonTest runs on all targets.
The default hierarchy template has created the intermediate sets automatically since Kotlin 1.9.20.
The actual sits in the leaf source set iosArm64Main, so only the device target has it; the simulator target also compiles commonMain, finds the expect and has no actual to match it.
Every target must end up with exactly one actual for each expect, either in its own leaf set or in any intermediate set above it.
The fix: move the file to src/iosMain/kotlin. With the default hierarchy template iosMain is the parent of iosArm64Main and iosSimulatorArm64Main, so one actual serves both.
Why it slips through: Xcode builds only the architecture of the current run destination, so a developer testing on a phone never compiles the simulator target. The person who hits it is a teammate on the simulator, or CI.
Prevention: compile every declared target in CI, and treat a leaf set like iosArm64Main as a smell: almost all iOS code belongs in iosMain or appleMain.
The usual stack is Ktor Client with kotlinx.serialization, SQLDelight or Room for storage, Koin for DI, plus coroutines and kotlinx-datetime; a library is usable from commonMain only if it publishes artifacts for every target you build.
Networking: Ktor 3.x: ktor-client-core in commonMain, and a per-platform engine (OkHttp on Android, Darwin on iOS) in the platform source sets.
Persistence: SQLDelight 2.x (you write SQL, it generates typed Kotlin) or Room 2.7+, whose KMP support is stable. Key-value: multiplatform-settings or DataStore.
DI: Koin 4.x, or plain constructor injection in a small module. Hilt is Android-only and cannot appear in commonMain.
Logging: Kermit, which writes to Logcat on Android and os_log on iOS.
Swift-facing: SKIE or KMP-NativeCoroutines, so that Flow and sealed types are usable from Swift.
Selection rule: the library must publish artifacts for every target. Libraries bound to the Kotlin compiler plugin (serialization, Compose) and generated code (SQLDelight) break first on a Kotlin upgrade.
Tests in commonTest compile and run on every target, so the same assertions execute on the JVM, on the iOS simulator and on desktop, and that is exactly why JVM-only tools such as MockK and Mockito are off the table.
Framework: kotlin("test") gives @Test and the assert* functions on all targets. ./gradlew allTests runs everything; jvmTest, iosSimulatorArm64Test or testDebugUnitTest (AGP 8) run one target.
Coroutines: runTest from kotlinx-coroutines-test is multiplatform. Inject the dispatcher instead of hard-coding Dispatchers.IO, or the test cannot control virtual time.
Flows and HTTP: Turbine asserts on Flow emissions; Ktor's MockEngine replaces the network without a server.
Instead of mocks: hand-written fakes against your own interfaces. Mokkery is a multiplatform mocking plugin if you really need one, but fakes are the norm. This is also an argument for interfaces over expect/actual: an interface has a fake, an actual does not.
Platform tests: androidUnitTest (androidHostTest with the AGP 9 Android-KMP library plugin) and iosTest hold tests for code that really is platform code: a Keychain wrapper, a file path.
The JVM run catches most logic bugs in seconds, but only a simulator run on a macOS runner catches Kotlin/Native behaviour.
Pick KMP when you want native UI and shared logic, especially if the Android app is already Kotlin; pick Flutter or React Native when one team should ship one UI to both platforms as fast as possible.
What runs on the device: Flutter ships its engine and draws every pixel itself from AOT-compiled Dart. React Native runs JavaScript on Hermes and renders native views through its new architecture (JSI, Fabric). KMP compiles Kotlin to JVM bytecode and to a native framework, with no runtime beyond a small garbage collector.
UI: KMP leaves UI native unless you add Compose Multiplatform; Flutter and React Native share UI by default.
Adoption: KMP enters an existing native app one module at a time. Flutter and React Native are usually a new app, or embedded screens (add-to-app, brownfield) that bring a whole engine with them.
Team: KMP fits an Android-heavy team that also has real iOS engineers. Flutter fits one cross-platform team. React Native fits teams with strong React skills or a web app to share code with.
Typical costs: KMP: Swift interop through Objective-C headers and slower iOS builds. Flutter: platform channels for native features and a larger app. React Native: the JS/native boundary and upgrade churn.
Apple system frameworks are callable from iosMain through bindings that Kotlin/Native ships; a third-party C or Objective-C library needs a cinterop .def file; a Swift-only API, or code the iOS team owns, goes behind a Kotlin interface implemented in Swift.
Apple frameworks directly
platform.Foundation.*, platform.UIKit.*, platform.Security.* are plain imports in any iOS source set, no configuration.
Objective-C classes map to Kotlin classes. C-style APIs such as SecItemAdd need @OptIn(ExperimentalForeignApi::class), pointer handling inside memScoped { } and CoreFoundation casts, which makes Keychain code noisy but possible.
cinterop
For a non-system C or Objective-C library: a .def file lists its headers and linker flags, and Gradle generates Kotlin bindings. The CocoaPods plugin generates them for you from a Pod.
Kotlin/Native reads only C and Objective-C headers. A pure Swift API without @objc is invisible to Kotlin.
An interface implemented in Swift
Declare the interface in commonMain; it is exported as an Objective-C protocol and a Swift class (not a struct) conforms to it. Pass the instance in at startup through Koin or a constructor.
Swift must implement every member: default bodies and default arguments do not cross the bridge. A suspend member becomes a completion handler that must be called exactly once on every path, or the Kotlin caller hangs.
Bonus: commonTest can use a fake, and the iOS team keeps the code in the language and repo they own.
loadProfile has no @Throws, so Kotlin/Native lets only CancellationException cross into Swift as an error. The ApiException reaches the Objective-C bridge as an unhandled Kotlin exception and the app terminates; Swift's catch never runs.
Why it happens: Objective-C has no unchecked exceptions, so the generated header marks a function as throwing (async throws in Swift) only for the classes listed in @Throws. A suspend function without it is treated as @Throws(CancellationException::class), and any other exception aborts the process.
Why Android is fine: a JVM caller catches any Throwable, so the bug only shows up on iOS, usually in production on the first server error.
The fix: @Throws(Exception::class). A listed class covers its subclasses, so ApiException, the Darwin engine's IOException on a timeout and CancellationException all arrive in Swift as an NSError, with the original exception under userInfo["KotlinException"].
**Listing only ApiException is not enough**: on a suspend function the compiler requires CancellationException (or a superclass) in the list, and even @Throws(ApiException::class, CancellationException::class) would still crash on a network IOException. Use a common superclass such as Exception.
Catch it next time: test the error path from Swift (an XCTest against a Ktor MockEngine returning 404); a JVM test never reproduces it. Many teams also make every public API exposed to iOS either carry @Throws or return a sealed result type instead of throwing.
Kotlin/Native compiles the shared code into an Apple framework with an Objective-C header, and Swift imports it like any other framework; the pain comes from everything Kotlin can express and Objective-C cannot.
Suspend functions: arrive as async throws methods, but cancelling the Swift Task does not cancel the coroutine.
Flow: arrives as an opaque class, not an AsyncSequence, so Swift cannot simply for await over it.
Sealed classes: become a plain class hierarchy; a Swift switch is not exhaustive and needs a default.
Generics: survive only on classes; on interfaces and functions they degrade to Any.
Default arguments: do not exist in Objective-C, so Swift must pass every parameter.
Exceptions: only those listed in @Throws (plus CancellationException for suspend functions) become Swift errors; any other exception terminates the app, even inside do/catch.
Names: one flat namespace without packages; collisions get mangled names. @ObjCName and @HiddenFromObjC let you curate the surface.
What teams use today: SKIE (Touchlab) generates Swift enums for sealed types, AsyncSequence for Flow, default arguments and two-way cancellation; KMP-NativeCoroutines covers just the coroutine part. Swift export, which emits a Swift API directly instead of an Objective-C header, is still in Alpha, breaking changes are expected, so check its status before betting a release on it.
One thing to stop repeating: the old freezing memory model and InvalidMutabilityException are gone. The current memory model has been the default since Kotlin 1.7.20, and the legacy one was removed in 1.9.20.
ViewModels can be shared today with the multiplatform androidx.lifecycle artifacts, but navigation is worth sharing only when the UI itself is Compose Multiplatform; with SwiftUI on iOS, navigation stays native.
ViewModels
ViewModel and viewModelScope resolve in commonMain. Android keeps the usual configuration-change retention; on iOS with SwiftUI nothing owns the instance for you, so the screen must own it and clear it on dismiss, typically by holding a ViewModelStore and calling clear(), which runs onCleared() and cancels the scope.
Expose one StateFlow<UiState> plus one onAction(action) entry point. That pair is the whole contract native UI binds to, and it keeps platform code dumb.
Older codebases use Decompose components, MOKO MVVM or a hand-rolled class with its own CoroutineScope.
Navigation
With Compose Multiplatform, JetBrains publishes Navigation Compose for every target: the same NavHost and type-safe routes as on Android. It needs Compose UI, so it does not help a SwiftUI app.
Decompose fits when navigation must live in shared non-UI code and you want a back stack you can unit-test.
Most KMP apps with native UI keep navigation native and let shared ViewModels emit events. Navigation is where platform conventions (back gesture, sheets, tab bars) matter most.
Where sharing goes wrong
A shared ViewModel that encodes Android habits (snackbars, SavedStateHandle assumptions) produces an iOS screen that feels ported.
Sharing everything couples release trains: a change for one platform now needs testing on both.
watch starts a collection in a scope nobody can cancel. When the screen closes the coroutine keeps running and keeps the Swift closure alive, and every onAppear adds another collector, so each update reaches the callback twice, three times and so on.
Why it happens: the CoroutineScope is a local variable and watch returns Unit, so no one outside holds the Job. A StateFlow never completes, so collect never ends on its own. On Android viewModelScope or repeatOnLifecycle would stop it; on iOS there is no lifecycle owner unless the shared API hands one out.
A leak, not just duplicates: the running coroutine holds onEach, which holds the Swift closure and the self it captured. Xcode's Memory Graph shows several CartViewModel instances after the screen is reopened a few times.
The fix: return a handle: a small Closeable whose close() cancels the scope. Swift stores it and calls close() in onDisappear or deinit; cancellation stops collect and releases the closure.
Libraries do this for you: SKIE exposes Flow as an AsyncSequence that is cancelled together with the Swift Task, and KMP-NativeCoroutines returns a cancel closure. A hand-rolled wrapper must at least return a handle.
Catch it next time: a commonTest that watches, closes the handle, emits again and asserts the callback did not run; the buggy version gives the test nothing to close.
Each Kotlin/Native framework embeds its own runtime and its own copy of every library it links, so two frameworks mean duplicated, incompatible types; one umbrella module that depends on all feature modules gives iOS a single runtime and one consistent API.
What export() controls
The framework header contains the umbrella module's own public API plus the modules passed to export(). Types from any other module are still in the binary, but Swift sees them as opaque objects with no properties or methods.
export() requires an api dependency in commonMain; exporting an implementation dependency fails the build.
transitiveExport = true exports everything reachable. It bloats the header and slows the build, so listing modules explicitly is the better default.
Why separate frameworks fail
A :core:model module compiled into two frameworks becomes two unrelated User classes: passing one to the other fails, and as? returns nil. Each framework also carries its own garbage collector and copy of coroutines and stdlib.
Keeping the surface small
Export only what Swift touches: usually domain models and a few entry-point facades. Networking and storage modules stay implementation.
internal and @HiddenFromObjC keep helpers out of the header.
Read the generated Shared.h once in a while; it is the real contract the iOS team codes against.
Kotlin/Native compiles ahead of time and links the shared module, its runtime and every library into one framework, so the iOS build gains a link step that is not incremental and the app gains several megabytes.
Build time
Changed source sets compile to klibs, then the whole framework is linked in one pass. That link dominates, and a Release link is several times slower than Debug because it optimises and skips the compiler caches.
Call embedAndSignAppleFrameworkForXcode from the Xcode build phase: it builds only the configuration and architecture the current scheme needs. A script that runs assembleXCFramework or ./gradlew build builds everything, every time.
Turn on the Gradle build and configuration caches, give the Gradle and Kotlin daemons enough memory, and on CI cache ~/.konan and ~/.gradle.
Do not disable the Kotlin/Native compiler caches (kotlin.native.cacheKind=none) unless a specific bug forces it; it makes every build slower.
Binary size
The framework carries the runtime, the garbage collector and every klib you link: Ktor, serialization and coroutines each add weight.
Release builds drop unused code, but everything exported to Swift is kept alive, so a wide export() surface keeps more code.
A static framework (isStatic = true) avoids a dynamic library load at launch, but every target that links it (app, widget, share extension) carries its own copy of the runtime.
Measure before tuning: --profile or a build scan names the slow Gradle task, and the App Store Connect size report shows what users actually download.
KMP pays off when a large, long-lived business-logic layer would otherwise be written twice, and it costs more than it saves when the shared part would be thin or the iOS team is not on board.
Good fits
Logic-heavy products on both platforms (banking, e-commerce, ride-hailing), where the two apps drifting apart is a real cost.
Long-lived products: the setup cost is paid once and the shared layer pays back for years.
An Android team already in Kotlin, plus iOS engineers willing to read Kotlin and Gradle.
An existing app that can adopt it one module at a time.
Bad fits
Short-lived apps, where the multi-target build setup dominates the project.
UI-heavy apps where shared logic would be a thin slice of the code.
No iOS expertise on the team: KMP removes none of the work of shipping a polished native iOS app.
A business that actually wants "write the app once": that is Compose Multiplatform or Flutter, not plain KMP.
Costs to budget
Slower iOS builds and a larger app.
Swift interop work (SKIE, API shaping) and Kotlin stack traces in iOS crash reports.
macOS runners in CI, and libraries you rely on that may have no iOS artifacts.
The honest pitch is "shared business logic, native UI".
Start with one self-contained, logic-heavy slice (the API client and models, or a pricing engine), move it into a shared module, ship it inside both apps, and only then grow the shared layer step by step.
The first slice
Pick code that is duplicated, has had bugs from the two versions drifting apart, and has no UI: models plus networking, validation rules, a sync engine.
Android consumes the module as an ordinary Gradle dependency; usually the Android implementation moves into it almost unchanged.
On iOS the shared code replaces the Swift implementation behind the protocol the app already had, so call sites barely change.
Making iOS a partner, not a consumer
Decide distribution early: an Xcode build phase in a monorepo, or a versioned XCFramework through Swift Package Manager if the iOS app has its own repo.
Shape the Swift API with SKIE and @ObjCName, and have an iOS engineer review every public API change.
Before the first release, not after the first crash: readable Kotlin frames in iOS crash reports, and CI that builds and tests on macOS.
Growing it
Next comes persistence, then shared ViewModels; shared UI last, and only if a pilot screen proves it is worth it.
Keep an exit: if each slice sits behind an interface the apps already own, rolling one back means restoring the old implementation, not a rewrite.
Track a few numbers (bugs fixed once instead of twice, time to ship a feature on both platforms, iOS build time) so the next step is a decision, not a habit.