Compose Multiplatform (CMP) is JetBrains' build of Jetpack Compose for non-Android targets: iOS, desktop on the JVM, and web on Wasm. The composable APIs you already know (Column, Row, Modifier, Material 3) are the same ones.
Relationship
Jetpack Compose is Google's Android runtime and UI toolkit.
CMP is JetBrains' multiplatform build of the same source, tracking each Compose release.
On Android, CMP simply delegates to Jetpack Compose; there is no second renderer.
On iOS and desktop it draws with Skia onto a platform surface.
Maturity, which is the part people still get wrong:
Android and desktop: production for years.
iOS: Stable since 1.8 (May 2025); see the iOS stable question.
Web on Wasm: Beta. Usable, but expect churn and a large initial payload.
Gain: one UI codebase across platforms. Cost: the result looks like your design system everywhere, so anything that must feel platform-native still needs platform work.
AndroidSenior
Compose Multiplatform
Compose for iOS/desktop/web, resources, navigation, platform UI bridging
Questions closed
0 / 22
- 0Review
Questions
Because only one layer of Compose is Android-specific. The stack is a Kotlin compiler plugin, a UI-agnostic runtime, and a ui layer that is the only part JetBrains had to port.
The compiler plugin (org.jetbrains.kotlin.plugin.compose) rewrites @Composable functions to thread a Composer, emit groups and decide skipping. It is a Kotlin plugin, so JVM, Native and Wasm get it for free.
**compose.runtime** owns snapshot state, the slot table, recomposition, and the Applier interface. It has no pixels in it: the runtime manages a tree and Applier says how to build one. That is why the same runtime can drive DOM nodes in Compose HTML.
**compose.ui** is LayoutNode, measurement, drawing, input, text and the accessibility bridge. On Android it draws into an AndroidComposeView; elsewhere it draws with Skia through Skiko onto Metal, Direct3D or OpenGL. This is the ported layer.
**compose.foundation and compose.material3** are essentially common code on top of ui, which is why iOS got LazyColumn, gestures and Material 3 nearly for free once the layer below worked.
What follows from it
A difference between platforms is almost always in ui: input, text shaping, insets, interop. Layout and lazy-list logic sits in common parts of ui and foundation, so it behaves the same everywhere; debug it on desktop, where the loop is fastest.
Composables do not become native widgets, and there is no UIView per composable. The whole scene is one host view with one canvas, which is exactly why accessibility, text selection and interop each needed explicit bridging work.
Every target keeps a thin entry point whose only job is to host the shared root composable.
Android: a MainActivity calling setContent { App() }. Nothing else is different, because on Android this is Jetpack Compose.
iOS: a function in iosMain returning ComposeUIViewController { App() }. Swift calls it and either presents the controller from UIKit or wraps it in a UIViewControllerRepresentable for SwiftUI.
Desktop: application { Window(onCloseRequest = ::exitApplication) { App() } } in the JVM source set.
Web: ComposeViewport { App() }, attached to a DOM element. This replaced the older CanvasBasedWindow API.
What belongs where matters more than the boilerplate. Theme, navigation host and dependency-injection start-up go in App() so every platform gets them identically. Anything the platform owns (push registration, the launching intent or URL, permission prompts, window size on desktop) stays in the entry point and is passed in as a parameter or a callback.
CMP ships its own resource system that generates a typed Res object from files in your source sets; it uses neither Android's R class nor iOS asset catalogs.
The layout is a composeResources/ directory in any source set (typically commonMain; platform source sets can hold their own):
drawable/: images and vector XML.
font/: typefaces.
values/strings.xml: strings in the Android XML format, with values-ru/, values-de/ and other qualifier folders for locales, and plurals alongside them.
files/: arbitrary raw bytes.
Access
In composition: painterResource(Res.drawable.logo), stringResource(Res.string.hello, name), pluralStringResource(...), Font(Res.font.sora_bold).
Outside composition, as suspend functions: getString(Res.string.hello), Res.readBytes("files/config.json"), and Res.getUri(...) when you have to hand a path to an interop view.
The multiplatform resources API has been stable since Compose Multiplatform 1.7, so the common accessors no longer need an experimental opt-in.
Three things are Android-only: the R class with the androidx.compose.ui.res accessors, LocalContext, and Intent. Resources move to the multiplatform Res class, and the share action becomes a callback each platform supplies.
**R.drawable and R.string**: Android's generated resource class does not exist in common code. Put the files under commonMain/composeResources/ and use painterResource(Res.drawable.ic_share) and stringResource(Res.string.share_title) from org.jetbrains.compose.resources. Same function names, different package; the import is what changes.
**LocalContext**: there is no Context on iOS, desktop or web. A shared composable that needs one is doing platform work in the wrong layer.
**Intent**: the share sheet is UIActivityViewController on iOS and something else again on desktop. Hoist it: the composable takes onShare: (String) -> Unit and each entry point passes its implementation, or declare @Composable expect fun rememberShareAction(): (String) -> Unit, whose Android actual reads LocalContext.
The compiler catching this is the point of the source-set split: commonMain only sees APIs that exist on every target.
Two mechanisms, and the choice is about how fixed your target list is: expect/actual on a composable function, or a composable slot the platform entry point fills in.
expect/actual
Declare @Composable expect fun PlatformWebView(url: String, modifier: Modifier = Modifier) in commonMain and one actual per target source set. @Composable goes on both sides, and default values live only on the expect; repeating them on the actual is a compile error.
Every target must supply an implementation, so adding wasmJs later fails the build until you write one. That is protection when the screen is meaningless without the feature, and a chore when it is optional.
The slot, which is usually better
The screen takes webView: @Composable (String, Modifier) -> Unit and each entry point passes its own. No source-set plumbing, previews and tests pass a fake, and a target without a real implementation passes a placeholder instead of blocking the build. A CompositionLocal does the same when threading the lambda through ten layers would be worse.
The trap is branching on the platform for anything other than capability. if (isIOS) for a layout decision breaks the first time somebody narrows a desktop window; size comes from currentWindowAdaptiveInfo(). And a platform check in shared code lies in previews and tests, which run on the JVM.
Rule of thumb: expect/actual for "does this platform have the thing", a lambda for "who supplies it".
Every CMP target has an interop composable that hosts a real platform view inside the Compose tree, so 'we need MapKit here' is never a reason to hand a whole screen back to the platform.
Android: AndroidView { factory } hosts any View; AndroidExternalSurface and AndroidEmbeddedExternalSurface are the camera and video paths.
iOS: UIKitView { factory } for a UIView, UIKitViewController { factory } for a UIViewController, which is also how you embed SwiftUI by wrapping it in a UIHostingController. The current overloads take a UIKitInteropProperties argument for touch and accessibility behaviour, replacing the older standalone interactive and accessibilityEnabled flags.
Desktop: SwingPanel { factory } for Swing and AWT components.
Web: DOM interop is the least developed of the four; treat it as experimental.
The reverse direction matters just as much: ComposeUIViewController { } wraps a composable so UIKit or SwiftUI can present it.
Keep the embedded view small and leaf-shaped. Layout, theming, navigation and back handling stay Compose's job; the moment the native view starts owning navigation you have two competing back stacks.
factory runs once, when the native view is created, so it only ever sees the first center. Values that change after that belong in update, which runs again when the composable recomposes with new data.
Two callbacks, two jobs: factory builds the view once per node; update runs right after it and again whenever the inputs it captured change. AndroidView follows the same contract, so this is the same bug on both platforms.
**Keep update idempotent**: update runs after factory and again when the state it reads or the values it captures change, which can be more often than center itself. Setting the same centre is harmless; if it is not (animated moves), compare with the map's current centerCoordinate first.
The other direction: reporting the region the user panned to goes through an MKMapViewDelegate kept in remember, because UIKit holds delegates weakly.
Use the multiplatform AndroidX Navigation Compose (NavHost, type-safe routes, deep links). Off Android the work is in back handling (iOS edge swipe, browser history), per-platform deep-link wiring and the lack of process-death restore.
Options
Navigation Compose, multiplatform build (org.jetbrains.androidx.navigation:navigation-compose): the same NavHost, type-safe routes and deep links as on Android. Stable since CMP 1.8.
Navigation 3 (back stack as state): Alpha on non-Android targets in CMP 1.10, no browser-history support yet.
Decompose or Voyager when navigation must live outside Compose: Decompose is component-based and testable without any UI, Voyager is a lighter screen-object API.
What is genuinely different away from Android
There is no system back button on iOS. CMP maps the edge swipe onto Compose back handling, so NavHost pops on a swipe, and every custom BackHandler has to make sense as a gesture.
Deep links are declared once but wired three ways: an intent filter on Android, associated domains plus a URL callback on iOS, the address bar on web.
On web the browser history is the navigation surface and has to stay in sync with the back stack.
Process death and saved-state restoration are an Android concern; iOS suspends instead, so rememberSaveable guarantees differ.
rememberSaveable without a Saver only accepts values that fit into an Android Bundle, and Filter is a plain common data class. Android's registry rejects it with IllegalArgumentException on first composition; desktop never serialises the value, so it never checks.
Why only Android: there rememberSaveable feeds the Activity's saved-instance-state Bundle, so the registry accepts only primitives, String, Parcelable, Serializable and similar, and checks the value as soon as it registers it. Desktop has no process death to survive and keeps any object in memory, so the bug ships if the team develops on desktop.
**Why not @Parcelize**: Parcelable and java.io.Serializable do not exist in commonMain; making a common class Parcelable takes an expect/actual workaround. A Saver is ordinary common code.
The fix: listSaver (or mapSaver) splits Filter into Bundle-safe values and rebuilds it on restore. Pass it as stateSaver, which applies it to the value inside the MutableState. Save small things (the query and flags), never the loaded list.
Catch it next time: run every shared screen on the Android target in CI, and cover saveable state with StateRestorationTester in an Android UI test: emulateSaveAndRestore() saves and restores the way a rotation or process death does.
Compose Multiplatform maps every platform's safe-area and keyboard information onto the same WindowInsets API you already use on Android.
Insets
WindowInsets.safeDrawing, systemBars and ime are available from common code. On iOS they come from the hosting view controller's safe-area layout guide, on desktop they are mostly empty, on Android they are the system bars and cutout.
Apply with Modifier.windowInsetsPadding(...) or let Scaffold handle it through contentWindowInsets rather than hardcoding a status-bar height anywhere.
Modifier.imePadding() covers the keyboard on both mobile platforms.
Draw behind the bars and pad with insets; Android now requires edge-to-edge on recent API levels, and it is the only approach that survives a device with a different cutout.
Adaptive layout
Branch on available width, never on the platform: currentWindowAdaptiveInfo() gives the window size class, and a desktop window dragged narrow should get the phone layout.
NavigationSuiteScaffold picks a bottom bar, a rail or a drawer from that size class.
Describe drawn content and merge what reads as one thing: semantics { contentDescription = ... } on the Canvas, semantics(mergeDescendants = true) on the card. Compose exports only its semantics tree to VoiceOver and TalkBack, so what is missing there does not exist for the reader.
On Android the tree becomes a virtual view hierarchy inside the single AndroidComposeView; on iOS it becomes UIAccessibilityElements inside the Compose UIView; the desktop and web bridges are weaker.
Text and Image(contentDescription = ...) contribute on their own. Canvas, drawBehind and custom layouts contribute nothing: drawing is not semantics.
A card read as five separate stops is a missing merge. clickable already merges its children, so this bites non-clickable cards and rows; give a clickable one onClickLabel so the reader announces a verb.
testTag is a test hook, not a label; it is never spoken.
Verify with TalkBack and VoiceOver, but write the assertions once: onNodeWithContentDescription(...) in commonTest runs on every target.
Preview stateless composables from commonMain, iterate on the desktop target with Compose Hot Reload, and open Xcode to verify iOS behaviour rather than to design layouts. Layout code is identical on every target, so edit on the fastest one.
Previews
Since CMP 1.10 the androidx @Preview (androidx.compose.ui.tooling.preview.Preview) works in commonMain. The JetBrains one and the desktop-only one are deprecated. Projects on CMP before 1.9 had a JetBrains annotation without parameters, so check which import you have.
Android Studio and IntelliJ IDEA with the Kotlin Multiplatform plugin render common previews.
A preview that reaches into DI, a repository or a suspend loader renders as an error box. Preview stateless composables fed by fake data, the same discipline that makes them testable.
Hot reload
Compose Hot Reload applies code changes to a running desktop window and keeps its state. It is JVM-only: iOS and Wasm still mean a full relink.
Bundled with the Compose Gradle plugin since 1.10, on by default for the desktop target (Kotlin 2.1.20+).
A framework link plus an Xcode build costs a minute; a desktop reload costs about a second. Treating the iOS simulator as the edit loop burns more hours than anything else here.
What still needs the device
Insets, the keyboard, gestures, fonts and scroll feel differ per platform; check them on the simulator or a phone before you call a screen done.
A CMP module applies org.jetbrains.kotlin.multiplatform, org.jetbrains.kotlin.plugin.compose (the Compose compiler) and org.jetbrains.compose. A Kotlin bump breaks the build because compiler plugins and klib-producing libraries are built against one specific Kotlin version.
org.jetbrains.kotlin.multiplatform declares targets and source sets.
org.jetbrains.kotlin.plugin.compose is the Compose compiler. Since Kotlin 2.0 it ships with the Kotlin distribution and carries the Kotlin version, which retired the old compatibility table where each Compose release pinned one exact Kotlin release.
org.jetbrains.compose is JetBrains' plugin. It generates the typed Res accessors, adds the desktop packaging tasks and (older projects) the compose.* aliases, now deprecated in favour of catalog entries.
Android modules additionally apply the Android Gradle plugin and take Compose dependencies from the same catalog entries.
Why upgrades break: anything that is a compiler plugin or ships klibs is compiled against a specific Kotlin compiler API. Compose, kotlinx-serialization, SQLDelight and KSP processors all fall into that category, and a klib produced by a newer Kotlin simply cannot be read by an older one. Ordinary JVM libraries have no such coupling, which is why a KMP upgrade feels stricter than an Android one.
Hygiene that keeps it manageable: keep everything in a version catalog, upgrade Kotlin and Compose as a deliberate change rather than a drive-by bump, check that the KSP version supports your Kotlin (before KSP 2.3 versions were <kotlin>-<ksp>), and read the Compose Multiplatform release notes for the supported Kotlin range before starting.
Compose for web compiles to WebAssembly and paints the entire page onto a single canvas, which explains both what it is good at and what it is bad at.
Status first: the Wasm target is Beta, unlike iOS and desktop which are stable. It works, teams ship internal tools with it, and the API still moves between releases.
What the canvas model costs you
There is essentially no DOM, so search engines see nothing and SEO is off the table.
Accessibility depends on Compose's semantics being bridged to the browser rather than on real elements, so it is not equivalent to a well-built HTML page.
Text selection, browser find-in-page, autofill and password managers behave differently from a normal site, because none of those things are looking at your content.
The initial payload is the Wasm binary plus the rendering engine, measured in megabytes. First load on a cold cache is slow; subsequent loads are cached.
What you have to wire yourself: the browser back button and history, the address bar as a navigation surface, and deep links. On Android the system gives you those; on the web they are yours.
Also keep two things apart. Compose Multiplatform on Wasm is the canvas-rendered UI toolkit. Compose HTML is a different, older library that composes real DOM elements and shares only the Compose runtime, not the widgets.
So: good for an internal admin tool, a demo of an existing app, or putting existing screens in front of a small known audience. Wrong for a public marketing site or anything whose value depends on being indexed.
The Gradle plugin drives jlink and jpackage: it builds a trimmed JDK runtime (default modules plus those you list in modules(...)), bundles it with your jars, and produces a native installer for the OS you are running on.
jpackage does not cross-compile: a .msi is built on Windows, a .dmg on macOS, a .deb on Linux, so three formats mean a CI matrix, not a flag.
jlink drops modules, so a library that reflectively touches java.sql or jdk.unsupported throws ClassNotFoundException in the installed app while run works. Test with runDistributable, not run; suggestRuntimeModules prints what to add.
The ...Release... variants additionally run ProGuard over your jars and dependencies (smaller, obfuscated), not over the runtime. Serializers and Ktor engines need keep rules. Installer size is dominated by the bundled JRE and Skiko, so expect tens of MB regardless.
macOS needs signing and notarization, otherwise Gatekeeper refuses to open the app on the tester's machine.
Stable means a frozen public API, production support and the hard basics in place: VoiceOver, native-feeling text input, UIKit and SwiftUI interop both ways. It does not mean native. CMP draws its own widgets with Skia onto a Metal surface, so the app looks like your design system, not like iOS.
What stable covers (since CMP 1.8, May 2025)
Accessibility: VoiceOver, AssistiveTouch and Full Keyboard Access, fed from the Compose semantics tree.
Text: selection, the magnifier and the system context menu.
Interop: UIKitView and UIKitViewController inside Compose, ComposeUIViewController inside UIKit or SwiftUI.
Scrolling and back: iOS-like scroll physics and the edge-swipe mapped onto back handling.
What still differs
Look: Material 3 defaults look like Material. San Francisco, Dynamic Type and iOS-style controls are work you do yourself.
Chrome and controls: large titles, sheet detents, pickers and swipe-to-delete are Compose reimplementations, and users can tell them apart from UIKit's.
OS integration: widgets, Live Activities, App Intents and share extensions stay native code either way.
Cost: an extra framework of roughly 10 MB or more, and Kotlin/Native link times in the iOS build.
When not to share the whole UI
"Indistinguishable from native" is a hard product requirement, as in banking or premium consumer brands.
The app is mostly platform integrations rather than screens.
The iOS team will not own Kotlin code; then share logic with KMP and keep SwiftUI.
The usual production answer is a mix: CMP for design-system screens that should look the same on both platforms, native for the screens that define how the app feels.
Shared Compose UI tests really do run on every target, the iOS binary grows by roughly a Skia-sized framework, and the remaining tooling gap is the iOS build loop.
Testing
runComposeUiTest runs the same onNodeWithText and performClick assertions from commonTest on Android, desktop and iOS. It still needs an experimental test-API opt-in.
Android keeps the familiar JUnit4 createComposeRule path as well.
ViewModel and logic tests are plain JVM tests: fast, no emulator, no simulator. Push as much as possible down to that layer.
Binary size
An iOS framework carrying Compose UI adds roughly 10 to 15 MB compressed: Skia, the Kotlin runtime, bundled fonts. Heavier than hand-written Swift.
Android pays almost nothing extra, because it is already Compose.
Desktop distributions are tens of MB because they bundle a JRE and the rendering engine.
Tooling
IntelliJ IDEA and Android Studio with the Kotlin Multiplatform plugin are the supported IDEs.
Previews and Hot Reload: see the fast edit loop question; verifying the real iOS build still means Xcode.
Debugging spans Kotlin and Objective-C frames; lldb works but symbol names are noisy.
CI needs a macOS runner for anything iOS and a Linux runner for Android and desktop. They are priced very differently, and that difference shapes what you run per PR versus nightly.
The trade: fewer screens written twice, in exchange for a build you have to understand.
The unit of adoption is a single screen wrapped in a ComposeUIViewController and presented like any other view controller.
The plumbing
In iosMain, expose a function that returns ComposeUIViewController { SettingsScreen(...) }. Everything Compose needs stays behind that one function.
In Swift, wrap it in a UIViewControllerRepresentable for SwiftUI, or push it onto a UINavigationController from UIKit.
Pass inputs in as parameters and results out as callbacks. The Compose screen must not reach into the host app's singletons; keeping the boundary a function signature is what lets you delete or replace it later.
The mistake to avoid is duplicated navigation. The host keeps the back stack; the Compose screen requests dismissal through a callback and the SwiftUI NavigationStack decides what happens. Two back stacks arguing produces a back button that goes to the wrong place, and it is a frequent adoption failure.
Seams to check on the first screen: safe-area insets (the hosting controller decides what the composable sees), the keyboard, the interactive swipe-back gesture, and theming. The Compose screen brings its own colours and typography and will not match the rest of the app for free.
The economics are worth stating plainly. The app grows by roughly the size of the Compose framework the moment the first screen uses it, so one screen carries the whole cost and the tenth is nearly free. That, rather than the code, is what decides whether a pilot is worth doing.
A ViewModel lives exactly as long as the ViewModelStoreOwner that created it, and on iOS that owner is the ComposeUIViewController your Swift code builds: rebuild the controller and you get a new store, onCleared() on the old one, and fresh state.
androidx lifecycle is multiplatform now, so this is the same code everywhere: androidx.lifecycle.ViewModel, viewModelScope, viewModel { } from lifecycle-viewmodel-compose, and collectAsStateWithLifecycle from lifecycle-runtime-compose all work from commonMain. Only the owner differs.
Where the owner comes from
Android: the Activity, which survives a configuration change; that is why the bug is invisible there.
iOS: the hosting controller. A UIViewControllerRepresentable recreates it whenever SwiftUI decides the view has a new identity, and makeUIViewController running twice is the whole bug.
Inside a common NavHost: the NavBackStackEntry. viewModel() called there is scoped to the entry, so the Compose back stack, not the platform, decides lifetime.
Fixes, in order
Keep the SwiftUI identity stable (no changing .id(...), no if/else swapping the bridge in and out) and push new inputs through updateUIViewController, never through make.
Hoist the owner: the Swift parent keeps one Kotlin state holder alive (with its own CoroutineScope, cancelled when the flow ends) and passes it into the controller function.
Move the state below the UI: a store or repository in your DI graph that outlives any screen, with the ViewModel as a thin projection over it.
Put the screen inside the shared NavHost so the back stack owns the scope on every platform.
What does not transfer is process-death restoration. rememberSaveable and SavedStateHandle restore across back-stack entries within a session; iOS suspends instead of killing, and nothing hands you a bundle after a cold start unless you persist it yourself. Ask who owns the store, not what the screen looks like.
Start by ruling out the ordinary Compose causes, because most of these turn out not to be iOS-specific at all.
Before anything else, check you are measuring a release build. A debug Kotlin/Native binary is dramatically slower than a release one, and a large share of "Compose is slow on iOS" reports are debug builds compared against a release Android app.
Causes that exist on every platform and are the usual answer:
Unstable or freshly allocated parameters defeating skipping (less of a problem with strong skipping, the default since Kotlin 2.0.20), so a whole subtree recomposes.
State read too high in the tree: read it in a lambda modifier instead, so animation does not recompose anything.
Allocation or real work inside a composable body.
A lazy list without stable keys, or items whose height is measured expensively.
Images decoded at full resolution for a small slot.
Causes that really are iOS-flavoured
The first frames pay for Kotlin/Native initialization and the renderer's pipeline warm-up, so the first screen and the first scroll are the worst it ever looks. Measure the steady state separately from the first run.
Interop views inside a scrolling list are expensive: each one is a real UIView being composited with the Compose surface. Keep them few, small and out of long lists.
Blur, large shadows and big offscreen layers cost more here than the same code costs on desktop.
ProMotion: CADisableMinimumFrameDurationOnPhone must be true in Info.plist or iPhones cap rendering at 60 Hz. CMP 1.7+ refuses to start without it, so a 60 Hz cap on iOS means someone switched enforceStrictPlistSanityCheck off.
Tooling: Xcode Instruments' Time Profiler and Core Animation show where frames go, with Kotlin frames under mangled names. Recomposition counts only exist in Android Studio's Layout Inspector, which is fine, because a composition problem reproduces on Android and you fix it once for both.
A reference cycle across Kotlin and Swift is invisible to both collectors: Kotlin/Native GC cannot see ARC references and ARC cannot see Kotlin ones, so Leaks stays silent while the screen is never freed.
Where CMP screens produce it
A Kotlin singleton (object AppRouter) storing the ComposeUIViewController, or a callback that captured it.
Platform registrations nobody removes: an NSNotificationCenter block observer, KVO, a CADisplayLink or an NSTimer. The platform retains the Kotlin block or target strongly, and that block captures composition state.
Delegates are the exception: UIKit holds them weakly, so a delegate created inline in a factory is freed at once (the opposite bug) and belongs in remember.
CoroutineScope(Dispatchers.Main) created in a composable body instead of rememberCoroutineScope(); the job never cancels and keeps the whole composition alive.
Fix and verify
Every platform registration gets a DisposableEffect with a real onDispose, in the same composable that made it.
Callbacks handed to Swift are cleared on dismissal, and the Swift side captures [weak self].
The Compose controller is owned by the navigation stack; Kotlin never stores a UIViewController.
Push and pop the screen twenty times, then open Xcode's Memory Graph Debugger: controllers still alive are your answer. Footprint that plateaus is fine; footprint that climbs linearly with visits is a boundary cycle.
The rule that prevents all of it: whatever the composition registers with the platform, the composition unregisters.