Most cross-platform decisions get framed as a rewrite: pick a framework, rebuild the app, hope it was worth it. After four years of native Android and then several years of Flutter, I stopped accepting that framing. The thing that moved me to Kotlin Multiplatform (KMP) was not performance or syntax. It was that I can adopt it one class at a time, inside an app that already works.

This post is the reasoning behind that switch, plus the smallest real example of what “gradual” looks like in code.

What problem was I actually trying to solve?

Two native apps drift. The Android and iOS teams implement the same rule twice: when to refresh a token, how to validate a form, when a sync is due. Sooner or later the two versions disagree, and you find out from a bug report.

Flutter fixes the drift by making everything shared, including the UI. That works well when you start from zero. It is a much harder sell when you already have two healthy native apps, native engineers, and native UI that users are happy with.

What I wanted was narrower: share the rules, keep the platforms.

Why not Flutter add-to-app?

Flutter does support gradual adoption through add-to-app. You embed a Flutter module in an existing native app and move screens over one by one. I have nothing against it, but the unit of adoption is different:

  • The unit is a screen (or a Flutter view), not a class. Business logic written in Dart lives inside the Flutter module.
  • Native code talks to it across a bridge. Platform channels (or a generator like Pigeon) sit between your Kotlin and your Dart. Every shared call is a message.
  • The app now ships a second runtime. The Flutter engine comes along with the first screen you migrate.

None of that is wrong. It just means the first step is already a big one.

What does gradual adoption look like in KMP?

With KMP the first step can be a single file. On Android, a shared module is an ordinary Gradle dependency. commonMain compiles to plain JVM bytecode for the Android target, so the Android app calls shared code directly. No bridge, no second runtime, no change to how the rest of the app is written.

iOS consumes the same code as a framework, and can start using it whenever the iOS side is ready. Android does not have to wait for that.

The versions used below: Kotlin 2.4.10, AGP 9.2.1 with the com.android.kotlin.multiplatform.library plugin, Gradle 9.6.1.

Step 1: add a shared module next to the app

The existing :app module stays as it is. Add a :shared module:

// settings.gradle.kts
include(":app", ":shared")
// build.gradle.kts (root)
plugins {
    id("com.android.application") version "9.2.1" apply false
    id("com.android.kotlin.multiplatform.library") version "9.2.1" apply false
    kotlin("multiplatform") version "2.4.10" apply false
}
// shared/build.gradle.kts
plugins {
    kotlin("multiplatform")
    id("com.android.kotlin.multiplatform.library")
}

kotlin {
    android {
        namespace = "codes.didarul.shared"
        compileSdk = 36
        minSdk = 24
        withHostTestBuilder {}
    }

    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "Shared"
            isStatic = true
        }
    }

    sourceSets {
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}
// app/build.gradle.kts (existing plugins and android {} block unchanged)
dependencies {
    implementation(project(":shared"))
}

Step 2: move one rule into commonMain

Pick something small that both platforms implement today and that has already drifted at least once. A “is a background sync due?” rule is a good first candidate: pure logic, one platform dependency (where the last sync time is stored).

The platform dependency becomes an interface. The rule itself is plain Kotlin:

// shared/src/commonMain/kotlin/codes/didarul/shared/sync/SyncPolicy.kt
package codes.didarul.shared.sync

/** Whatever the platform uses to remember the last successful sync. */
interface SyncStateStore {
    fun lastSyncMillis(): Long?
    fun markSynced(atMillis: Long)
}

/** Decides whether a background sync is due. Same rule on Android and iOS. */
class SyncPolicy(
    private val store: SyncStateStore,
    private val minIntervalMillis: Long,
    private val nowMillis: () -> Long,
) {
    fun isSyncDue(): Boolean {
        val last = store.lastSyncMillis() ?: return true
        return nowMillis() - last >= minIntervalMillis
    }

    fun onSyncSucceeded() {
        store.markSynced(nowMillis())
    }
}

I prefer an interface over expect/actual for the first migration. The platform side keeps using whatever storage it already has, and the shared code does not need to know about it. expect/actual is great for small platform primitives; for “plug in the thing my app already has”, an interface is less friction.

The rule gets one set of tests, in commonTest, that runs for both targets:

// shared/src/commonTest/kotlin/codes/didarul/shared/sync/SyncPolicyTest.kt
package codes.didarul.shared.sync

import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class SyncPolicyTest {

    private class InMemoryStore(var last: Long? = null) : SyncStateStore {
        override fun lastSyncMillis(): Long? = last
        override fun markSynced(atMillis: Long) {
            last = atMillis
        }
    }

    private var now = 0L
    private val store = InMemoryStore()
    private val policy = SyncPolicy(store, minIntervalMillis = 60_000L, nowMillis = { now })

    @Test
    fun dueWhenNeverSynced() {
        assertTrue(policy.isSyncDue())
    }

    @Test
    fun notDueInsideInterval() {
        now = 1_000L
        policy.onSyncSucceeded()
        now = 30_000L
        assertFalse(policy.isSyncDue())
    }

    @Test
    fun dueOnceIntervalPasses() {
        now = 1_000L
        policy.onSyncSucceeded()
        now = 61_000L
        assertTrue(policy.isSyncDue())
    }
}

Run them with ./gradlew :shared:allTests. That one task runs the suite on the JVM for Android and on the iOS simulator.

Step 3: Android uses it like any other Kotlin class

This is the part that sold me. The Android side is just Kotlin calling Kotlin. The storage implementation is the SharedPreferences code the app already had, now behind the shared interface:

// app/src/main/java/codes/didarul/app/sync/SharedPrefsSyncStateStore.kt
package codes.didarul.app.sync

import android.content.Context
import codes.didarul.shared.sync.SyncStateStore

class SharedPrefsSyncStateStore(context: Context) : SyncStateStore {

    private val prefs = context.getSharedPreferences("sync_state", Context.MODE_PRIVATE)

    override fun lastSyncMillis(): Long? =
        if (prefs.contains(KEY_LAST_SYNC)) prefs.getLong(KEY_LAST_SYNC, 0L) else null

    override fun markSynced(atMillis: Long) {
        prefs.edit().putLong(KEY_LAST_SYNC, atMillis).apply()
    }

    private companion object {
        const val KEY_LAST_SYNC = "last_sync_millis"
    }
}
// app/src/main/java/codes/didarul/app/sync/SyncModule.kt
package codes.didarul.app.sync

import android.content.Context
import codes.didarul.shared.sync.SyncPolicy
import java.util.concurrent.TimeUnit

fun provideSyncPolicy(context: Context): SyncPolicy =
    SyncPolicy(
        store = SharedPrefsSyncStateStore(context.applicationContext),
        minIntervalMillis = TimeUnit.MINUTES.toMillis(15),
        nowMillis = System::currentTimeMillis,
    )

Your DI setup, WorkManager jobs and Compose screens do not change. They depend on a Kotlin class that happens to live in another module. If you decide KMP is not for you, moving that file back into :app is a cut and paste.

Step 4: iOS picks it up when it is ready

On iOS the shared module arrives as the Shared framework. The iOS app implements the same interface with UserDefaults and calls the same rule:

import Foundation
import Shared

final class UserDefaultsSyncStateStore: NSObject, SyncStateStore {
    private let defaults = UserDefaults.standard
    private let key = "last_sync_millis"

    func lastSyncMillis() -> KotlinLong? {
        guard let stored = defaults.object(forKey: key) as? NSNumber else { return nil }
        return KotlinLong(value: stored.int64Value)
    }

    func markSynced(atMillis: Int64) {
        defaults.set(NSNumber(value: atMillis), forKey: key)
    }
}

let syncPolicy = SyncPolicy(
    store: UserDefaultsSyncStateStore(),
    minIntervalMillis: 15 * 60 * 1000,
    nowMillis: { KotlinLong(value: Int64(Date().timeIntervalSince1970 * 1000)) }
)

Notice the KotlinLong boxing. That is the Objective-C interop layer showing through: nullable and generic numbers are boxed, and Kotlin default arguments are not visible from Swift. It is a real cost for iOS developers, and it is why I start with small, boring APIs at the boundary.

What did I give up compared to Flutter?

Being honest about the trade makes the choice easier to defend:

  • UI is not shared by default. With plain KMP you still build two UIs. Compose Multiplatform can share UI too, and I have shipped an app with it to both stores, but that is a separate decision you can make later.
  • iOS developers inherit a Gradle build. The shared framework has to be built, and someone on the iOS side needs to be comfortable with that.
  • The Swift boundary needs care. Boxed numbers, sealed classes as class hierarchies, and suspend functions all need a bit of shaping before they feel natural in Swift.
  • One language for everything is gone. In Flutter, a single Dart developer can own a whole feature. In KMP, the shared layer is Kotlin and the edges are native.

When would I still pick Flutter?

For a new product with a small team, one design shared across platforms, and no existing native code, Flutter is still a strong, fast choice. I would not talk anyone out of it.

KMP wins when there is already something worth keeping: native apps, native engineers, platform-specific UI. That describes most apps that have been in production for a few years, which is why it is where I now start.

Key takeaways

  • The deciding factor for me was gradual adoption: KMP lets you share one class, not one screen.
  • On Android, a KMP module is a normal Gradle dependency. No bridge, no second runtime.
  • Start with pure business rules that both platforms already implement and that have drifted before.
  • Put platform dependencies behind interfaces first; reach for expect/actual for small platform primitives.
  • Test once in commonTest, run on every target.
  • Budget time for the Swift boundary; keep early shared APIs small and simple.
  • Flutter is still the better fit for greenfield apps with shared UI and no native code to keep.
Share: