ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-04-05Advanced

Antigravity × Compose Multiplatform: The Complete Guide to Shared UI Across iOS, Android, and Desktop in 2026

A deep-dive production guide to building high-quality cross-platform apps with Compose Multiplatform and Antigravity IDE. Covers architecture, expect/actual patterns, Desktop support, automated testing, and full release pipelines for iOS, Android, and JVM Desktop.

compose-multiplatformkotlin6ios36android28desktop2cross-platformantigravity435kmpcmp

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 -version

CMP 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/actual provides 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 commonMain runs identically on iOS, Android, and Desktop
  • expect/actual is 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: JFileChooser

Maximizing 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:packageDeb

Packages 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:linkReleaseFrameworkIosArm64

For 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-alias

Desktop: 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 library

Example: 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 tracker

By 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) from shared (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 / packageDeb give you a native app with minimal extra code
  • Testing: commonTest runs 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, not composeApp
  • No Android or iOS imports visible in commonMain files
  • expect/actual used 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/actual per platform
  • No version conflicts between CMP, Kotlin, and AGP

Testing

  • commonTest coverage 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 pluralStringResource where 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.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-07-03
Catching Download Size Regressions Before Submission Day — A Weekly Agent Gate for AAB/IPA Size Budgets
Mediation SDKs and bundled assets quietly inflate download size. A design for size ledgers, budget gates, and agent-driven delta attribution using bundletool and App Thinning reports.
App Dev2026-06-22
Porting a Wallpaper Viewer's Slideshow and Page-Scrubber from iOS to Android — Where the Two-Way Sync with SnapHelper Tripped Me Up
A hands-on record of porting a full-screen wallpaper viewer's slideshow and bottom scrubber from iOS to Android. How I pinned down the current page with RecyclerView and SnapHelper, synced it two-way with the scrubber, and resolved the conflict between auto-advance and user input with a small state machine — in working Kotlin.
App Dev2026-06-17
Stop Dialogs From Stacking: One Gate for Paywalls, Review Prompts, and Rewarded Ads
A field record of curing the bug where a paywall, a review prompt, and a rewarded ad all surface at once, fixed with a single priority-based modal gate. I let an Antigravity agent sweep up the scattered show() calls, but kept the display policy in my own hands.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →