Setup and context: Why Compose Multiplatform Changes Everything
While Kotlin Multiplatform (KMP) brought shared business logic to mobile development, Compose Multiplatform (CMP) takes it a step further — sharing the UI layer itself across iOS, Android, Desktop (JVM), and Web (Wasm). Developed by JetBrains, CMP lets you write a single @Composable function that renders natively on all supported platforms.
By 2026, CMP 1.7 has reached Production Stable status for iOS, with companies like Airbnb and Autodesk shipping it in production. It has emerged as a serious alternative to Flutter, particularly for teams already invested in the Kotlin ecosystem.
Google Antigravity supercharges CMP development by deeply understanding Gradle multimodule setups, expect/actual patterns, iOS framework generation, and Compose state management. The result: dramatically faster iteration with fewer configuration headaches.
This guide is written for intermediate-to-advanced developers who already understand KMP basics. If you're new to KMP, start with Kotlin Multiplatform × Antigravity Complete Guide before proceeding.
Prerequisites and Environment Setup
Required Knowledge
- Solid Kotlin fundamentals (Coroutines and Flow are a plus)
- Familiarity with Jetpack Compose basics (
@Composable,State,ViewModel) - Understanding of the KMP module structure (
commonMain,androidMain,iosMain)
Development Environment
- Antigravity (latest version)
- Android Studio Meerkat or later (for Android build verification)
- Xcode 26 (for iOS builds and simulator testing)
- JDK 17 or higher
- Gradle 8.9 or higher
Verify your environment from Antigravity's integrated terminal:
# Check JDK version
java -version
# Check Gradle version (wrapper recommended)
./gradlew --version
# Check Kotlin version
kotlinc -versionCMP vs. KMP: Understanding the Distinction
Getting this right up front prevents costly architectural mistakes down the road.
KMP (Kotlin Multiplatform)
- Focuses on sharing business logic, data, and domain layers
- Each platform uses its own native UI: SwiftUI on iOS, Jetpack Compose on Android
expect/actualprovides platform-specific implementations behind a common interface
CMP (Compose Multiplatform)
- Built on top of KMP — extends sharing to the UI layer itself
- A single Composable in
commonMainruns identically on iOS, Android, and Desktop expect/actualis used only where platform-specific UI behavior is genuinely needed
Recommended Module Structure
Ask Antigravity to suggest a production-ready CMP module layout:
project/
├── composeApp/ ← CMP main module (UI-sharing)
│ ├── src/
│ │ ├── commonMain/ ← All-platform shared code
│ │ │ ├── kotlin/
│ │ │ │ ├── ui/ ← Shared Composables
│ │ │ │ ├── viewmodel/ ← Shared ViewModels
│ │ │ │ └── di/ ← Koin DI modules
│ │ │ └── resources/ ← Shared resources
│ │ ├── androidMain/ ← Android-specific
│ │ ├── iosMain/ ← iOS-specific
│ │ └── desktopMain/ ← Desktop-specific
│ └── build.gradle.kts
├── shared/ ← Pure KMP business logic
│ ├── src/
│ │ ├── commonMain/
│ │ ├── androidMain/
│ │ └── iosMain/
│ └── build.gradle.kts
└── settings.gradle.kts
This separation gives composeApp full ownership of UI concerns while shared handles business logic — a clean, testable architecture.
Project Setup: Working with the Antigravity Agent
Starting from the KMP Wizard
Generate a starter project at kmp.jetbrains.com and open it in Antigravity. The IDE immediately begins analyzing your .gradle.kts files, providing dependency resolution and error highlighting before you write a single line of code.
Automated Dependency Configuration
Use this prompt to have Antigravity wire up all core dependencies at once:
@agent
Open composeApp/build.gradle.kts and configure the following:
- Compose Multiplatform 1.7.x (latest stable)
- Koin 4.x (dependency injection)
- Ktor 3.x (networking)
- SQLDelight 2.x (local database)
- Coil 3.x (image loading)
Manage all versions in libs.versions.toml.
Antigravity generates a complete libs.versions.toml (Version Catalog) and updates build.gradle.kts accordingly:
# libs.versions.toml
[versions]
compose-multiplatform = "1.7.0"
kotlin = "2.1.0"
koin = "4.0.0"
ktor = "3.0.3"
sqldelight = "2.0.2"
coil = "3.0.4"
[libraries]
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }Shared UI Components: Patterns That Work Across Platforms
Design Token System
Defining your design tokens in commonMain ensures visual consistency across all platforms without duplication:
// commonMain/kotlin/ui/theme/AppTheme.kt
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColors = lightColorScheme(
primary = Color(0xFF0066FF),
secondary = Color(0xFF6B9FFF),
surface = Color(0xFFF8FAFF),
background = Color(0xFFFFFFFF),
onPrimary = Color.White,
onSurface = Color(0xFF1A1A2E)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF4D94FF),
secondary = Color(0xFF9BB8FF),
surface = Color(0xFF1A1A2E),
background = Color(0xFF0D0D1A),
onPrimary = Color.White,
onSurface = Color(0xFFE8EEFF)
)
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content
)
}Antigravity automatically suggests implementing system dark theme detection via expect/actual.
Screen-Level Composable Design
Keep screen-level Composables in commonMain. They'll work identically on all three platforms:
// commonMain/kotlin/ui/screens/HomeScreen.kt
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun HomeScreen(
viewModel: HomeViewModel = koinViewModel(),
onNavigateToDetail: (String) -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsState()
Scaffold(
topBar = { TopAppBar(title = { Text("Compose Multiplatform") }) }
) { paddingValues ->
when (val state = uiState) {
is HomeUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator()
}
}
is HomeUiState.Success -> {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.items) { item ->
ItemCard(item = item, onClick = { onNavigateToDetail(item.id) })
}
}
}
is HomeUiState.Error -> {
ErrorScreen(message = state.message, onRetry = viewModel::reload)
}
}
}
}This single Composable runs on iOS, Android, and Desktop with no modifications.
Handling Platform-Specific Code with expect/actual
The most critical design decision in CMP is knowing when to use expect/actual and when to keep things in commonMain.
The expect/actual Contract
// commonMain: declare the interface
expect class PlatformContext
expect fun getPlatformName(): String
expect fun openUrl(url: String)
// --- Android ---
// androidMain:
actual class PlatformContext(val context: android.content.Context)
actual fun getPlatformName(): String = "Android ${android.os.Build.VERSION.RELEASE}"
actual fun openUrl(url: String) {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url)
)
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
// inject context via Koin
}
// --- iOS ---
// iosMain:
actual class PlatformContext
actual fun getPlatformName(): String =
"iOS ${platform.UIKit.UIDevice.currentDevice.systemVersion}"
actual fun openUrl(url: String) {
val nsUrl = platform.Foundation.NSURL.URLWithString(url) ?: return
platform.UIKit.UIApplication.sharedApplication.openURL(
nsUrl,
options = emptyMap<Any?, Any?>(),
completionHandler = null
)
}
// --- Desktop ---
// desktopMain:
actual class PlatformContext
actual fun getPlatformName(): String =
"Desktop (${System.getProperty("os.name")} ${System.getProperty("os.version")})"
actual fun openUrl(url: String) {
java.awt.Desktop.getDesktop().browse(java.net.URI(url))
}Camera and Photo Library Access
For mobile apps that need camera access, tell Antigravity: "Implement camera/photo library access for iOS, Android, and Desktop using expect/actual." It will suggest libraries like moko-media or peekaboo and scaffold the implementation:
// commonMain
expect class ImagePicker {
@Composable
fun launch(onImageSelected: (ByteArray?) -> Unit)
}
// Android: ActivityResultContracts.PickVisualMedia
// iOS: PHPickerViewController
// Desktop: JFileChooserMaximizing Antigravity: Your CMP Development Accelerator
Automated ViewModel Generation
Antigravity's standout capability for CMP is generating complete, production-ready ViewModel classes in commonMain:
@agent
Create a HomeViewModel with the following requirements:
- Fetch a list of items from https://api.example.com/items using Ktor
- Cache results locally with SQLDelight
- UiState should be a sealed class: Loading / Success / Error
- Use Koin for dependency injection
From this single prompt, Antigravity generates:
HomeViewModel.kt(commonMain)HomeUiState.kt(sealed class)ItemRepository.kt(interface, commonMain)ItemRepositoryImpl.kt(Ktor + SQLDelight implementation)AppModule.kt(Koin module with platform-specific Engine config)HomeScreenTest.kt(unit tests with Fakes)
The generated code correctly configures Ktor's platform-specific engines: OkHttp for Android, Darwin for iOS, and CIO for Desktop.
Compose Navigation
// commonMain/kotlin/navigation/AppNavigation.kt
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.compose.runtime.Composable
sealed class Screen(val route: String) {
object Home : Screen("home")
object Detail : Screen("detail/{itemId}") {
fun createRoute(itemId: String) = "detail/$itemId"
}
object Settings : Screen("settings")
}
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Screen.Home.route) {
composable(Screen.Home.route) {
HomeScreen(onNavigateToDetail = { id ->
navController.navigate(Screen.Detail.createRoute(id))
})
}
composable(Screen.Detail.route) { backStack ->
val itemId = backStack.arguments?.getString("itemId") ?: return@composable
DetailScreen(itemId = itemId, onBack = { navController.popBackStack() })
}
composable(Screen.Settings.route) { SettingsScreen() }
}
}Desktop Support: Getting a JVM App for Free
One of CMP's most underappreciated advantages is that a Desktop app comes essentially for free once you have the shared UI.
Desktop Entry Point
// desktopMain/kotlin/main.kt
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import androidx.compose.ui.unit.dp
fun main() = application {
val windowState = rememberWindowState(width = 1200.dp, height = 800.dp)
Window(
onCloseRequest = ::exitApplication,
title = "My CMP App",
state = windowState
) {
AppTheme { AppNavigation() }
}
}That's it — the same AppNavigation() call used on mobile renders the full app on Desktop.
Native Menu Bar
Window(onCloseRequest = ::exitApplication, title = "My CMP App") {
MenuBar {
Menu("File", mnemonic = 'F') {
Item("New", shortcut = KeyShortcut(Key.N, meta = true)) { /* ... */ }
Item("Open", shortcut = KeyShortcut(Key.O, meta = true)) { /* ... */ }
Separator()
Item("Quit", shortcut = KeyShortcut(Key.Q, meta = true)) { exitApplication() }
}
Menu("Edit") { Item("Preferences") { /* ... */ } }
}
AppTheme { AppNavigation() }
}Building Distribution Packages
# macOS .dmg
./gradlew :composeApp:packageDmg
# Windows .msi
./gradlew :composeApp:packageMsi
# Linux .deb
./gradlew :composeApp:packageDebPackages are output to composeApp/build/compose/binaries/.
Automated Testing Strategy
Unit Tests in commonTest
Tests placed in commonTest run on every supported platform — no separate test suites needed:
// commonTest/kotlin/viewmodel/HomeViewModelTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
class HomeViewModelTest {
@Test
fun `initial state should be Loading`() = runTest {
val repo = FakeItemRepository()
val viewModel = HomeViewModel(repo)
assertIs<HomeUiState.Loading>(viewModel.uiState.value)
}
@Test
fun `successful fetch should produce Success state`() = runTest {
val fakeItems = listOf(
Item(id = "1", title = "Test Item 1"),
Item(id = "2", title = "Test Item 2")
)
val repo = FakeItemRepository(items = fakeItems)
val viewModel = HomeViewModel(repo)
viewModel.reload()
val state = viewModel.uiState.value
assertIs<HomeUiState.Success>(state)
assertEquals(2, state.items.size)
}
@Test
fun `API error should produce Error state`() = runTest {
val repo = FakeItemRepository(shouldThrow = true)
val viewModel = HomeViewModel(repo)
viewModel.reload()
assertIs<HomeUiState.Error>(viewModel.uiState.value)
}
}Android Compose UI Tests
@RunWith(AndroidJUnit4::class)
class HomeScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loading_indicator_is_shown_during_load() {
composeTestRule.setContent {
AppTheme { HomeScreen(viewModel = FakeLoadingViewModel()) }
}
composeTestRule.onNodeWithTag("loadingIndicator").assertIsDisplayed()
}
}Just ask Antigravity: "Generate a FakeItemRepository for HomeViewModel tests." It produces a complete stub in seconds.
Production Release Pipeline
iOS: App Store via Fastlane
CMP generates a .xcframework that you embed in your Xcode project:
# Generate release iOS framework
./gradlew :composeApp:linkReleaseFrameworkIosArm64For automated TestFlight uploads, use this Antigravity prompt:
@agent
Configure Fastlane with an Appfile and Matchfile, then create
a TestFlight upload lane using App Store Connect API key auth.
Android: Google Play
# Build a signed AAB
./gradlew :composeApp:bundleRelease
jarsigner -verbose -sigalg SHA256withRSA \
-digestalg SHA-256 \
-keystore release.keystore \
composeApp/build/outputs/bundle/release/composeApp-release.aab \
release-key-aliasDesktop: GitHub Releases via CI/CD
# .github/workflows/desktop-release.yml
name: Desktop Release
on:
push:
tags: ['v*']
jobs:
build:
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build package
run: |
if [ "${{ runner.os }}" == "macOS" ]; then
./gradlew :composeApp:packageDmg
elif [ "${{ runner.os }}" == "Windows" ]; then
./gradlew :composeApp:packageMsi
else
./gradlew :composeApp:packageDeb
fi
shell: bash
- uses: actions/upload-artifact@v4
with:
name: desktop-${{ runner.os }}
path: composeApp/build/compose/binaries/**/*Integrating Platform-Specific Libraries Without Polluting commonMain
One of the trickier aspects of CMP is dealing with third-party libraries that only support one or two platforms. The key rule: never let platform-specific library imports leak into commonMain. Use the expect/actual wrapper pattern to keep your shared code clean.
Example: Push Notifications
Firebase Cloud Messaging (FCM) for Android and APNs for iOS are platform-specific. Wrap them behind a common interface:
// commonMain/kotlin/notifications/PushNotificationService.kt
interface PushNotificationService {
suspend fun registerForPushNotifications(): Result<String> // returns FCM/APNs token
fun handleIncomingNotification(payload: Map<String, String>)
}
// Wire up via Koin — each platform provides its actual implementation
// androidMain: FirebaseMessaging.getInstance().token
// iosMain: UNUserNotificationCenter + APNs token
// desktopMain: no-op or desktop-specific notification libraryExample: Analytics
// commonMain
interface AnalyticsTracker {
fun trackScreen(name: String)
fun trackEvent(name: String, params: Map<String, Any> = emptyMap())
}
// androidMain: Firebase Analytics or Amplitude SDK
// iosMain: Firebase Analytics (iOS SDK) or Amplitude iOS
// desktopMain: PostHog Java SDK or custom HTTP trackerBy defining these interfaces in commonMain and injecting platform-specific implementations via Koin, your shared ViewModels and screens can track events without any platform knowledge:
class HomeViewModel(
private val repository: ItemRepository,
private val analytics: AnalyticsTracker // injected by Koin
) : ViewModel() {
init {
analytics.trackScreen("Home")
loadItems()
}
}Localization and Internationalization in CMP
CMP uses the Res object from composeResources for string localization, fully supported in commonMain.
Setting Up String Resources
commonMain/
composeResources/
values/
strings.xml ← default (English)
values-ja/
strings.xml ← Japanese
values-de/
strings.xml ← German
<!-- commonMain/composeResources/values/strings.xml -->
<resources>
<string name="app_name">My CMP App</string>
<string name="home_title">Home</string>
<string name="loading">Loading...</string>
<string name="error_retry">Retry</string>
<string name="items_count">%1$d items found</string>
</resources>Accessing strings in Composables:
import myapp.composeapp.generated.resources.Res
import myapp.composeapp.generated.resources.home_title
import org.jetbrains.compose.resources.stringResource
@Composable
fun HomeTopBar() {
TopAppBar(
title = { Text(stringResource(Res.string.home_title)) }
)
}The same stringResource() call works on iOS, Android, and Desktop, resolving the appropriate locale at runtime. Antigravity can auto-generate translation files from your existing English strings using a single agent prompt.
Adopting Dependency Injection with Koin Across All Platforms
Koin 4.x is the de facto DI solution for CMP. Unlike Hilt (Android-only), Koin works in commonMain and requires minimal setup.
Module Definition
// commonMain/kotlin/di/AppModule.kt
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val dataModule = module {
single<ItemRepository> { ItemRepositoryImpl(get(), get()) }
single { createHttpClient() } // expect/actual for platform-specific Ktor Engine
single { createDatabase() } // expect/actual for SQLDelight driver
}
val viewModelModule = module {
viewModelOf(::HomeViewModel)
viewModelOf(::DetailViewModel)
viewModelOf(::SettingsViewModel)
}
val appModules = listOf(dataModule, viewModelModule)Starting Koin per Platform
// androidMain: Application.onCreate()
fun initKoin(context: Context) {
startKoin {
androidContext(context)
modules(appModules)
}
}
// iosMain: called from Swift AppDelegate/App
fun initKoin() {
startKoin { modules(appModules) }
}
// desktopMain: called in main()
fun initKoin() {
startKoin { modules(appModules) }
}Antigravity can scaffold the entire Koin setup — including platform-specific actual functions for creating the SQLDelight driver and Ktor engine — from a single prompt describing your data sources.
Common Errors and How to Fix Them
Error 1: "Unresolved reference: actual" at compile time
This means a platform source set is missing an actual implementation for an expect declaration. Paste the error into Antigravity's chat — it will pinpoint exactly which platform is missing the implementation and generate the boilerplate.
Error 2: "Framework not found" during iOS build in Xcode
Add $(SRCROOT)/../composeApp/build/cocoapods/framework/ to FRAMEWORK_SEARCH_PATHS in your Xcode build settings. Antigravity can also detect Podfile misconfigurations and suggest fixes.
Error 3: ClassNotFoundException on Desktop
Desktop builds sometimes require shadow JAR configuration:
// composeApp/build.gradle.kts
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "MyApp"
packageVersion = "1.0.0"
jvmArgs("-Xmx512m")
}
}
}Error 4: Images not displaying on iOS
CMP resource files must be placed in commonMain/composeResources/drawable/ — not androidMain/res/drawable/. iOS cannot access Android resource directories. Antigravity's static analysis flags incorrect resource paths before you even run a build.
Advanced Patterns: State Management and Side Effects
Unidirectional Data Flow (UDF) in CMP
Adopting a strict UDF architecture ensures your shared ViewModels are predictable and testable across all platforms. The pattern: UI events flow up to the ViewModel, state flows down to the Composable.
// commonMain/kotlin/viewmodel/HomeViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
sealed class HomeEvent {
object LoadItems : HomeEvent()
data class SelectItem(val id: String) : HomeEvent()
object Refresh : HomeEvent()
}
class HomeViewModel(
private val repository: ItemRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
// Side effects (navigation, snackbars) go through a separate channel
private val _events = MutableSharedFlow<HomeEffect>()
val effects: SharedFlow<HomeEffect> = _events.asSharedFlow()
init { loadItems() }
fun onEvent(event: HomeEvent) {
when (event) {
is HomeEvent.LoadItems -> loadItems()
is HomeEvent.SelectItem -> navigateToDetail(event.id)
is HomeEvent.Refresh -> refresh()
}
}
private fun loadItems() {
viewModelScope.launch {
_uiState.value = HomeUiState.Loading
repository.getItems()
.onSuccess { items -> _uiState.value = HomeUiState.Success(items) }
.onFailure { error -> _uiState.value = HomeUiState.Error(error.message ?: "Unknown error") }
}
}
private fun navigateToDetail(id: String) {
viewModelScope.launch {
_events.emit(HomeEffect.NavigateToDetail(id))
}
}
private fun refresh() {
viewModelScope.launch {
_uiState.value = HomeUiState.Loading
repository.refresh()
loadItems()
}
}
}
sealed class HomeEffect {
data class NavigateToDetail(val id: String) : HomeEffect()
data class ShowError(val message: String) : HomeEffect()
}Consuming side effects in the Composable:
@Composable
fun HomeScreen(viewModel: HomeViewModel = koinViewModel(), onNavigate: (String) -> Unit) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is HomeEffect.NavigateToDetail -> onNavigate(effect.id)
is HomeEffect.ShowError -> { /* show snackbar */ }
}
}
}
HomeContent(
uiState = uiState,
onEvent = viewModel::onEvent
)
}SQLDelight: Cross-Platform Local Storage
SQLDelight generates type-safe Kotlin APIs from SQL statements that work identically on Android (Room-compatible), iOS (SQLite), and Desktop (JDBC).
-- commonMain/sqldelight/com/example/Item.sq
CREATE TABLE Item (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT 0
);
selectAll:
SELECT * FROM Item ORDER BY created_at DESC;
upsert:
INSERT OR REPLACE INTO Item(id, title, description, created_at)
VALUES (?, ?, ?, ?);
deleteById:
DELETE FROM Item WHERE id = ?;SQLDelight generates a ItemQueries class you can use directly in your ItemRepositoryImpl:
// commonMain/kotlin/data/ItemRepositoryImpl.kt
class ItemRepositoryImpl(
private val queries: ItemQueries,
private val api: ItemApi
) : ItemRepository {
override suspend fun getItems(): Result<List<Item>> = runCatching {
val cached = queries.selectAll().executeAsList().map { it.toDomain() }
if (cached.isNotEmpty()) return Result.success(cached)
val remote = api.fetchItems()
remote.forEach { item ->
queries.upsert(item.id, item.title, item.description, System.currentTimeMillis())
}
remote
}
override suspend fun refresh(): Result<List<Item>> = runCatching {
val remote = api.fetchItems()
remote.forEach { item ->
queries.upsert(item.id, item.title, item.description, System.currentTimeMillis())
}
remote
}
}Handling Permissions Across Platforms
Permission requests — camera, notifications, location — must be handled natively but can be abstracted behind a common interface:
// commonMain
interface PermissionController {
suspend fun requestPermission(permission: AppPermission): PermissionState
suspend fun getPermissionState(permission: AppPermission): PermissionState
}
enum class AppPermission {
CAMERA, NOTIFICATIONS, LOCATION
}
enum class PermissionState {
GRANTED, DENIED, NOT_DETERMINED
}
// Android: uses ActivityResultContracts.RequestPermission
// iOS: uses AVCaptureDevice.requestAccess / UNUserNotificationCenter
// Desktop: returns GRANTED (most permissions not required)Antigravity generates all three actual implementations when you describe this interface in the prompt.
Performance Optimization Tips for CMP
Avoiding Unnecessary Recompositions
The rules that apply to Jetpack Compose on Android apply equally in CMP. Key principles:
Use remember for expensive computations:
val sortedItems by remember(items) {
derivedStateOf { items.sortedByDescending { it.createdAt } }
}Use key() in LazyColumn to prevent full re-renders on list updates:
LazyColumn {
items(items = items, key = { it.id }) { item ->
ItemCard(item = item)
}
}Image Loading with Coil 3
Coil 3 is fully CMP-compatible and handles image loading efficiently across all platforms:
// commonMain
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
@Composable
fun RemoteImage(url: String, modifier: Modifier = Modifier) {
AsyncImage(
model = ImageRequest.Builder(LocalPlatformContext.current)
.data(url)
.crossfade(true)
.build(),
contentDescription = null,
modifier = modifier
)
}iOS-Specific Memory Management
When interfacing with iOS APIs from iosMain, be mindful of memory ownership. Kotlin/Native uses automatic reference counting (ARC), but retain cycles can still occur with closures. Antigravity's static analysis flags potential memory leaks in iosMain files and suggests weak references where needed.
Summary
Compose Multiplatform is the most compelling cross-platform framework for Kotlin developers in 2026 — delivering iOS, Android, and Desktop coverage from a single codebase with no compromises on native performance or UI quality.
Key takeaways from this guide:
- Module architecture: Separate
composeApp(shared UI) fromshared(business logic) for clean boundaries - expect/actual: Isolate only genuinely platform-specific behavior; keep everything else in
commonMain - Shared Composables: Theme, Navigation, and ViewModels all belong in
commonMain - Desktop:
packageDmg/packageMsi/packageDebgive you a native app with minimal extra code - Testing:
commonTestruns on every platform — write once, test everywhere - Release pipeline: Fastlane + GitHub Actions automates iOS/Android/Desktop distribution
Antigravity removes the most painful parts of CMP development — Gradle configuration, expect/actual boilerplate, and test scaffolding — so you can focus on building a great product across all three platforms.
To go deeper on state management patterns in shared ViewModels, check out Kotlin × Coroutines / Flow / Room / Hilt Complete Guide.
Checklist Before Shipping Your First CMP App
Use this checklist to verify your project is production-ready across all platforms:
Architecture
- All business logic lives in
shared/commonMain, notcomposeApp - No Android or iOS imports visible in
commonMainfiles expect/actualused only where platform divergence is genuinely required- ViewModels follow UDF with separate state and effect flows
Build & Dependencies
- All dependency versions managed in
libs.versions.toml - Ktor engine configured correctly per platform (OkHttp / Darwin / CIO)
- SQLDelight driver initialized via
expect/actualper platform - No version conflicts between CMP, Kotlin, and AGP
Testing
commonTestcoverage for all ViewModels and Repository logic- Android Compose UI tests for critical user flows
- Desktop smoke tests run on CI for each release
Release Pipeline
- iOS: Xcode Archive + Fastlane TestFlight upload automated
- Android: bundleRelease + Play Store upload via Fastlane or GitHub Actions
- Desktop: packageDmg / packageMsi / packageDeb artifacts uploaded to GitHub Releases
- Version code / build number incremented automatically in CI
Localization
- All user-visible strings use
stringResource(Res.string.xxx)— no hardcoded text - At minimum English and your primary target locale are complete
- Plural forms handled with
pluralStringResourcewhere applicable
Antigravity can run through this checklist for you automatically: "Review my CMP project for production readiness and list any issues found." The agent scans your codebase, flags problems, and provides fix suggestions inline — saving hours of manual review before launch.