Compose Multiplatform in practice: one Kotlin UI for Android and iOS
Two of my Android apps, a Czech grammar trainer and an ear trainer, are now also iPhone apps. Not ports and not rewrites: the same Kotlin code runs on both, screens included. The iOS versions are in TestFlight and on their way to the App Store. Here is what that move looked like in practice.
Why Compose Multiplatform
Both apps were already Kotlin with a Jetpack Compose UI, a Room database and a handful of Google services. For iOS I had three real options:
- A native Swift app. The best fit for the platform, and a second app to keep in step with the first forever. For a one-person studio that is the expensive part: every feature twice, every bug twice.
- Flutter or React Native. A rewrite of the Android app too, in another language.
- Kotlin Multiplatform with Compose Multiplatform. Logic, data and screens move to shared Kotlin; a small native host starts them on each platform.
The third one keeps what already works. Compose Multiplatform draws the same composables on iOS, so the screens did not need to be written again; they needed to stop depending on Android.
Three layers
Each app ended up with the same shape:
- Shared code (
commonMain): rules, data, view models, every screen, the strings in all seven languages. The rule for this layer is short: noandroid.*, nojava.*, no Hilt. Time comes fromkotlin.timeandkotlinx-datetime, JSON fromkotlinx.serialization, concurrency from coroutines. - The Android host: the application class, the activity, dependency injection, and the Android side of each platform service.
- The iOS host: a thin SwiftUI app that shows the shared UI full screen, plus Swift only where an Apple or Google SDK has no Kotlin route.
The shared UI is started on iOS with one function that wraps the root composable in a view controller; SwiftUI shows that controller and does nothing else.
What moved as is
More than I expected:
- Rules and view models. Scoring, streaks, daily goals, the order of questions, the exercise engine. These were plain Kotlin already, apart from a few
java.timecalls. - Screens. Most composables moved with changed imports only. Material 3 is there, navigation is there, animations are there.
- The database. Room now supports Kotlin Multiplatform. I kept the schema and its identity hash exactly as they were, so the Android app opens the existing database file and nobody loses their progress. On iOS the same database runs on a bundled SQLite. Migrations were rewritten from the old
SupportSQLiteDatabaseAPI to the new connection API and tested on both. - Strings. Android string resources became Compose resources with the same XML. One trap: Compose resources show text verbatim, so Android escapes such as
\'appear on screen as a backslash; in the ear trainer a unit test now fails on any of them. Another: shared texts must not name the other platform's store. App Review does not accept "Google Play" in an iPhone app, so the purchase texts became neutral, and iOS also needs a visible "Restore purchases" button.
What went behind interfaces
Everything that talks to the platform got an interface in shared code and two implementations. In the grammar trainer the list was:
- key-value settings (SharedPreferences / NSUserDefaults);
- the one-time "remove ads" purchase (Play Billing / StoreKit 2);
- ads and the consent form (AdMob with Google's consent SDK on both, plus Apple's tracking prompt on iOS);
- analytics and crash reports (Firebase on both);
- daily reminders (WorkManager / local notifications scheduled ahead);
- the "rate the app" prompt, the store page, haptics, the app language.
A simplified example of the pattern:
// commonMain: what the shared code needs
interface Purchases {
val adFree: StateFlow<Boolean>
suspend fun buyAdFree(screen: ScreenHandle): PurchaseResult
suspend fun restore()
}
// One object hands the shared UI everything platform-specific.
interface AppServices {
val settings: Settings
val purchases: Purchases
val analytics: Analytics
val reminders: ReminderScheduler
}
Android builds AppServices from its existing classes; iOS builds it by hand in a small Kotlin object in iosMain. Shared tests use in-memory fakes. When iOS cannot have a feature yet, it gets an explicit no-op implementation with a comment, never a crash and never a silent difference. The ear trainer's iOS version, for example, has no ads or analytics yet: those implementations simply do nothing.
expect/actual is used only for small facts: the current screen handle, weekday names, the system language. Anything with behaviour is an interface, because an interface can be faked in a test and an expect function cannot.
What stayed in Swift
Kotlin/Native calls Apple's frameworks directly, so most iOS services are Kotlin too: settings, reminders, the ear trainer's whole iOS side. In the grammar trainer Swift is kept for SDKs that have no Kotlin route: StoreKit 2, Google Mobile Ads and Firebase. Each lives in one Swift file that implements a Kotlin interface, and Kotlin never sees the SDK itself. The bridge between the languages works well, with a few details worth knowing:
- a Kotlin
Intarrives in Swift asInt32; - a Kotlin function type parameter becomes a normal Swift closure;
- a Swift API marked
@MainActor, called from a Kotlin interface method, has to hop to the main actor explicitly; NSLog("%@", text)called from Kotlin with a Kotlin string crashes the app: the argument is not anNSString.
Native views can live inside the Compose UI. The ad banner is a UIKit view placed with UIKitView, with its height reserved in advance so the screen does not jump when the ad arrives.
Sound
The ear trainer plays a piano, and sound is the one place where the platforms differ completely. On Android it is a MIDI synthesizer; on iOS it is Apple's audio engine and sampler with a small piano SoundFont bundled with the app, driven from Kotlin, not Swift. Shared code sees one interface, roughly "play this note now, stop that one", and the timing of exercises stays in Kotlin. An iOS test renders every note offline and checks that it is not silence, because a headless machine cannot listen.
Testing both from one code base
- Shared tests run twice: on the JVM (fast, on any machine) and on an iOS simulator, where Kotlin/Native and the real iOS SQLite are involved. In the grammar trainer, CI also enforces the test coverage of the shared rules.
- Test tags are the contract between the UI test suites.
Modifier.testTagbecomes the accessibility identifier on iOS, so Android Compose tests and iOS XCUITests find the same elements by the same names. Tags are kept stable and every new interactive element gets one. - A small-screen run. The grammar trainer's iOS tests also run on the smallest iPhone the simulator still offers, where long Czech and Ukrainian words are most likely to break a layout.
What it cost and what it gave
Most of the code was written by my AI coding agents, with CI as their only view of iOS. The move went in stages: shared logic first, then the database, then platform services, then the screens. The Android app stayed releasable after each stage and kept shipping updates in between.
The gain is the one I wanted: a feature is written once. The rule in the instructions for my coding agents is "implement both sides in the same change"; a new platform service is not done until Android, iOS and the test fakes all have it. The price is a stricter discipline in shared code and a build that needs a Mac for the iOS half. I have no suitable one. How that works is a separate story.