Compose Multiplatform
You write one Compose UI tree in commonMain and let each platform be a thin host. The whole discipline is one sentence: common by default, platform by exception. Every line you put in commonMain ships to Android, iOS, and desktop unchanged; every line you put in a platform source set is a deliberate exception you should be able to justify.
Versions floor (2026)
Pin these or the K2 compiler bites you. Verify against current docs before locking a project — these are the floors, not opinions.
- Compose Multiplatform 1.11.0, bundling Jetpack Compose 1.11.1.
- Kotlin 2.1.0+ required (2.2.20 recommended for evolving iOS/Web targets). Since CMP 1.8.0 the K2 compiler is mandatory, so every dependency must compile against Kotlin 2.1.0+.
- iOS is Stable (production-ready since CMP 1.8.0, May 2025): feature parity for popular cases, type-safe navigation with deep linking, accessibility (VoiceOver, Full Keyboard Access).
- Web is Beta (CMP 1.9.0, Sept 2025), runs on WasmGC browsers. Do not promise Web parity — ship Android/iOS/desktop, pilot Web.
- Platform minimums: Android API 21, iOS 14+, macOS 13 arm64, Windows 10+, Ubuntu 20.04+, desktop JDK 11+ (17+ for
jpackagepackaging).
Where does this code go?
This is the question you answer dozens of times a day. Default to the leftmost column that compiles.
| Source set | Put here | Concrete example | Never here |
|---|---|---|---|
commonMain |
Shared @Composables, ViewModels, business logic, common interfaces, expect declarations |
@Composable fun GreetingScreen(), expect fun platformName(): String |
android.*, platform.UIKit, java.awt, androidx.activity |
androidMain |
Activity, actual using Android Context/Build |
class MainActivity : ComponentActivity |
iOS/desktop-only APIs |
iosMain |
ComposeUIViewController factory, actual via cinterop/platform.* |
fun MainViewController() = ComposeUIViewController { App() } |
android.* |
desktopMain |
application {} window, Swing interop |
application { Window(::exitApplication) { App() } } |
mobile-only APIs |
wasmJsMain (Beta) |
Web entry point | ComposeViewport(document.body!!) { App() } |
anything you can't ship as Beta |
Why this matters: a platform import in commonMain breaks the build for every other target, and the error surfaces in the iOS link step, far from the offending line. Keep commonMain import-clean.
Project structure (the 2026 default)
The current default KMP layout (announced May 2026, aligned with AGP 9.0) is a dedicated shared KMP library module + per-platform app modules, not the old single composeApp:
my-app/
shared/ # KMP library: commonMain holds the Compose UI tree
src/
commonMain/ # @Composables, ViewModels, expect declarations, DI
androidMain/ # actual impls using android.*
iosMain/ # actual impls + ComposeUIViewController
desktopMain/ # actual impls + application {} window
wasmJsMain/ # web entry (Beta)
androidApp/ # thin Android host -> setContent { App() }
iosApp/ # Xcode project -> embeds the shared framework
desktopApp/ # ./gradlew :desktopApp:run
webApp/ # WasmGC entry (Beta)
Split rule: if some screens are native and only some are shared Compose, split into sharedLogic (all platforms) + sharedUI (CMP platforms only). A server-inclusive project adds a root core module. Don't pre-split — start with one shared module and split when a platform genuinely needs native UI.
Source-set hierarchy — commonMain fans out, with intermediate sets where targets share code:
commonMain
├── androidMain
├── desktopMain (jvm)
├── wasmJsMain (Beta)
└── iosMain (intermediate)
├── iosArm64
└── iosSimulatorArm64
Scaffold a new project with kmp.new or the Kotlin Multiplatform wizard (IntelliJ IDEA 2025.2.2+ / Android Studio Otter 2025.2.1+ with the KMP plugin). Add a shared module to an existing Android app via Android Studio's Shared Module Template.
Minimal version-catalog plugin wiring (full Gradle in references/project-setup.md):
// gradle/libs.versions.toml
[versions]
kotlin = "2.2.20"
compose = "1.11.0"
agp = "9.0.0"
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }
expect / actual — the core mechanism
expect/actual is how you reach a platform API while keeping the call site common. Declare expect in commonMain; provide an actual in every target you compile.
// commonMain
expect fun platformName(): String
// androidMain
import android.os.Build
actual fun platformName(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain
import platform.UIKit.UIDevice
actual fun platformName(): String =
UIDevice.currentDevice.systemName + " " + UIDevice.currentDevice.systemVersion
Rules, each with the reason it exists:
- Every
expectneeds anactualin every compiled target. An orphanexpectis not a warning — it is a hard build failure (often only surfacing on the iOS target), so add theactualper target or remove the target. - Keep the common surface tiny. Each
expectsymbol multiplies into Nactuals you maintain; expose the smallest function, not a fat class. - Prefer a common
interface+ DI over deepexpecttrees for anything you want to test or fake.expect classcan't be mocked in common tests.
// Bad: deep expect class — N actuals, untestable in commonTest
expect class Database {
fun query(sql: String): List<Row>
fun close()
}
// Good: common interface, platform impls injected via Koin (fakeable in tests)
interface Database {
fun query(sql: String): List<Row>
fun close()
}
// androidMain/iosMain provide SqliteDatabase implementing Database, bound in a Koin module.
Native interop
You bridge in both directions. Shared Compose embeds native views; native hosts embed shared Compose.
- iOS — native view inside shared Compose:
UIKitView/UIKitViewControllerwith a factory lambda. - iOS — shared Compose inside SwiftUI: wrap
ComposeUIViewControllerin aUIViewControllerRepresentable. - Android:
AndroidViewfor native views; host the tree viasetContent { App() }in anActivity. - Desktop:
application { Window { App() } }; Swing interop viaSwingPanel.
Embed a native view through an injected interface, not a raw expect — so the common screen stays platform-agnostic and testable:
// commonMain
interface MapFactory { /* returns a platform map handle */ }
@Composable
fun MapScreen(mapFactory: MapFactory = koinInject()) {
// iosMain provides the actual UIKitView wiring around mapFactory; see references/ios-interop.md
}
Full bridge patterns (ComposeUIViewController SwiftUI wrapper, native-view-factory-via-Koin, MapKit/camera, ViewModel lifecycle) live in references/ios-interop.md — read it before writing iOS interop.
State, ViewModel, navigation, DI
androidx.lifecycle.ViewModelworks incommonMain. Obtain instances withkoin-compose-viewmodel'skoinViewModel { }so they survive recomposition. iOS has no built-inViewModelStoreOwner— tie the VM lifecycle to SwiftUI manually (KMP-ObservableViewModel lets SwiftUI observe Kotlin VMs).- Koin is the common DI runtime. Define a shared
initKoin()and call it from the AndroidApplicationand from iOS app init:
// commonMain
fun initKoin(config: KoinAppDeclaration? = null) = startKoin {
config?.invoke(this)
modules(appModule, platformModule)
}
- Navigation:
androidx.navigationprovides type-safe nav + deep links incommonMain. - Resources:
compose.components.resourcesgeneratesResaccessors —Res.string.app_name,Res.drawable.logo, fonts — shared across all platforms.
Running & packaging
- Android: run the
androidApprun config (hosts viasetContent). - iOS: open
iosAppin Xcode, or use the KMP iOS run config in the IDE. - Desktop:
./gradlew :desktopApp:run; package with./gradlew :desktopApp:packageDistributionForCurrentOS(needs JDK 17+ forjpackage). - Web (Beta):
./gradlew :webApp:wasmJsBrowserDevelopmentRun.
Anti-patterns
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
android.* / platform.UIKit / java.awt import in commonMain |
Breaks the build for every other target, error surfaces far away | expect/actual or inject via a common interface |
expect with no actual for a target |
Hard build failure on that target | Add an actual per compiled target or drop the target |
Recreating a ViewModel each recomposition (remember { VM() } wrong) |
State loss on every recompose | koinViewModel { } / hoist state |
| Treating Compose Web as production | Web is Beta (1.9), not Stable | Ship Android/iOS/desktop; pilot Web only |
| Kotlin < 2.1.0 with CMP 1.8+ | K2 incompatibility — deps fail to link | Bump to Kotlin 2.2.x |
Deep expect class for testable logic |
Can't fake in commonTest |
Common interface + Koin-injected platform impl |
Pre-splitting into sharedLogic/sharedUI on day one |
Premature complexity, extra Gradle wiring | Start with one shared module; split when a platform needs native UI |
Verify
After scaffolding or editing, run scripts/verify.sh <project-dir> (read-only, no Gradle/Xcode needed). It statically checks the structural invariants:
- a
commonMainsource set exists; - every
expectincommonMainhas a matchingactualin some platform source set (catches orphans); - the Compose Multiplatform plugin (
org.jetbrains.compose) and a Kotlin version are present, and Kotlin is >= 2.1.0 (K2 floor); - no forbidden platform imports leak into
commonMain.
It exits 0 on a clean or empty target and non-zero only on hard failures.