Compose Multiplatform: a practical Android & Kotlin guide
After six decades of building Android apps, you've likely felt the pain of maintaining separate codebases for iOS and Android. Compose Multiplatform offers a path forward: write your UI once in Kotlin and Jetpack Compose, then deploy to Android, iOS, and desktop with minimal platform-specific overhead. This isn't a promise of "write once, run anywhere"—it's a pragmatic trade-off that lets shared UI logic eliminate redundant work while keeping platform integration straightforward.
What it is — Jetpack Compose running on multiple platforms
Compose Multiplatform is JetBrains' framework for building UIs across Android, iOS, macOS, Windows, and Linux using the Compose declarative paradigm. Under the hood, your Compose code compiles to Kotlin/Native on iOS and Kotlin/JVM on Android, with a shared runtime that handles layout, rendering, and state management.
The core insight: you're not emulating a cross-platform runtime like React Native or Flutter. Instead, you're leveraging Kotlin's multiplatform compiler to generate native bytecode for each platform. On Android, Compose runs directly on the Compose runtime you already know. On iOS, the Compose runtime compiles to native ARM code and interacts with UIKit or SwiftUI under the hood.
For a practical example, consider a simple product listing screen:
@Composable
fun ProductList(products: List) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(products) { product ->
ProductCard(product)
}
}
}
@Composable
fun ProductCard(product: Product) {
Card(modifier = Modifier.padding(8.dp).fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(product.name, style = MaterialTheme.typography.titleMedium)
Text(product.price, style = MaterialTheme.typography.bodySmall)
Button(onClick = { /* handle purchase */ }) {
Text("Add to Cart")
}
}
}
}
This code compiles and runs identically on Android and iOS. No conditional imports, no platform checks baked into the UI layer.
Shared UI — Write screens once, run on Android and iOS
The efficiency gain materializes when you stop duplicating Jetpack Compose code. Your Material Design system, navigation structure, and form logic live in a single commonMain source set that both platforms consume.
In a typical Compose Multiplatform project structure, you organize code like this:
// commonMain/kotlin
expect fun getPlatformName(): String
@Composable
fun App() {
val viewModel = remember { ProductViewModel() }
val products by viewModel.products.collectAsState(emptyList())
MaterialTheme {
ProductList(products)
}
}
// androidMain/kotlin
actual fun getPlatformName() = "Android"
// iosMain/kotlin
actual fun getPlatformName() = "iOS"
The expect/actual pattern lets you define platform-specific implementations while keeping UI logic shared. For most screens—login, product details, cart, checkout—you won't need platform branches at all.
A real-world scenario: imagine your team built a ride-hailing app with Android and iOS clients. Shared code includes the map screen, ride request form, driver list, and payment confirmation. That's potentially 40–50% of your UI logic unified. The remaining 20–30% of platform-specific tweaks (gesture handling, permissions, native integrations) stays isolated in androidMain and iosMain.
State management becomes simpler too. Whether you use Kotlin Flow, Coroutines, or a shared ViewModel library, the async patterns work identically across platforms:
class RideViewModel : ViewModel() {
private val _state = MutableStateFlow(RideState.Idle)
val state: StateFlow = _state.asStateFlow()
fun requestRide(pickup: Location, dropoff: Location) {
viewModelScope.launch {
_state.value = RideState.Loading
try {
val ride = rideRepository.createRide(pickup, dropoff)
_state.value = RideState.Success(ride)
} catch (e: Exception) {
_state.value = RideState.Error(e.message)
}
}
}
}
@Composable
fun RideRequestScreen(viewModel: RideViewModel) {
val state by viewModel.state.collectAsState()
when (state) {
is RideState.Loading -> CircularProgressIndicator()
is RideState.Success -> RideConfirmation((state as RideState.Success).ride)
is RideState.Error -> ErrorMessage((state as RideState.Error).message)
else -> RideForm(onSubmit = { pickup, dropoff ->
viewModel.requestRide(pickup, dropoff)
})
}
}
This ViewModel compiles identically for both platforms. No duplication, no reconciliation headaches.
Platform integration — Drop into native code where needed
Shared UI handles the bulk of your app, but reality demands native integration. Camera access, push notifications, deep linking, biometric authentication—these require platform-specific APIs.
Compose Multiplatform makes this straightforward with expect/actual declarations. Define the interface in commonMain, then implement it separately:
// commonMain/kotlin
expect class BioAuth {
suspend fun authenticate(): Boolean
}
// androidMain/kotlin
actual class BioAuth {
private val biometricPrompt = BiometricPrompt(
activity,
BiometricPrompt.AuthenticationCallback()
)
actual suspend fun authenticate(): Boolean = suspendCancellableCoroutine { continuation ->
biometricPrompt.authenticate(PromptInfo.Builder()
.setTitle("Verify identity")
.setNegativeButtonText("Cancel")
.build())
}
}
// iosMain/kotlin
actual class BioAuth {
private val context = LAContext()
actual suspend fun authenticate(): Boolean = suspendCancellableCoroutine { continuation ->
var error: NSError? = null
if (context.canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error = ::error)) {
context.evaluatePolicy(
LAPolicy.deviceOwnerAuthenticationWithBiometrics,
localizedReason = "Verify your identity"
) { success, _ ->
continuation.resume(success)
}
}
}
}
Your Compose screen uses this abstraction without caring about the platform beneath:
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
var bioAuthLoading by remember { mutableStateOf(false) }
Button(onClick = {
bioAuthLoading = true
viewModel.authenticateWithBiometrics()
}) {
Text("Use Biometric")
}
}
This pattern scales to notifications, file access, analytics, and payment processors. The Kotlin Multiplatform ecosystem provides libraries like Koin for dependency injection and Kotlinx Serialization for cross-platform networking. When a library doesn't exist, you implement it once per platform and wire it into your shared code through interfaces.
For navigation, libraries like Compose Multiplatform's own androidx.navigation backport or community solutions like Precompose let you handle deep linking uniformly. You define routes in shared code and let each platform handle the OS-level URI scheme registration.
Maturity — Check library support for your needs
Compose Multiplatform is production-ready for many use cases, but the ecosystem is younger than Android alone. Before committing to a multiplatform approach, audit your dependencies.
Mature support:
- Coroutines and Flow for async/state management
- Kotlinx Serialization for JSON and protocol buffers
- Ktor Client for HTTP networking
- Room (preview) for local databases
- Compose UI fundamentals (layouts, animations, Material Design)
Partial or emerging support:
- Camera and media APIs—requires platform-specific wrapper
- WebSockets—Ktor supports it; third-party libraries vary
- Video playback—use platform-specific players wrapped in Compose
- Maps—Google Maps and Apple Maps need native bindings
- Payment processors (Stripe, PayPal)—usually platform-specific SDKs with Compose wrappers
Check the Kotlin Multiplatform directory and GitHub to see which libraries your project depends on. A SaaS app with REST APIs, JSON serialization, and standard UI components? Excellent fit. A mobile game with custom rendering or an AR app? Not yet.
iOS support in particular continues to improve. Early projects faced Compose rendering lag on older devices; newer versions resolve this. Xcode toolchain integration has matured, and iOS binary distribution is now straightforward.
Common mistakes
Mistake 1: Forcing everything into shared code. Don't avoid expect/actual out of fear. If iOS needs special handling for edge cases, implement it. Duplicating 5% of code is acceptable; forcing 40% into awkward abstractions wastes time.
Mistake 2: Neglecting platform testing. Shared code tests pass on JVM, but iOS behavior differs. Set up a CI pipeline that compiles and runs tests on iOS (physical devices or simulators) before merging.
Mistake 3: Ignoring native performance profiles. A Compose Multiplatform screen may render smoothly on Android but stutter on an older iPhone. Profile on real devices; use platform-specific optimizations (e.g., Metal acceleration on iOS) where necessary.
Mistake 4: Picking Compose Multiplatform for a one-off experiment. The setup cost (Kotlin Multiplatform toolchain, IDE configuration, build scripts) pays off only if you commit to shipping on multiple platforms. For a quick MVP on Android alone, standard Compose is faster.
Key takeaways
- Compose Multiplatform lets you write Jetpack Compose UIs once and compile them to Android, iOS, and desktop via Kotlin Multiplatform, eliminating duplicate UI code.
- Use
expect/actualdeclarations to isolate platform-specific logic (biometrics, notifications, native integrations) while keeping your UI and state management shared. - The ecosystem is mature for typical business apps (networking, serialization, databases); check library support for domain-specific features before adopting.
- Test on real iOS devices in CI; Compose rendering performance and platform-specific quirks differ enough that JVM-only testing will miss bugs.
- Accept that 20–30% of your codebase will remain platform-specific; Compose Multiplatform excels at the shared 70%, not at replacing platform teams.






Comments
Loading comments…