usharik.dev

← All articles

Compose Multiplatform in practice: one Kotlin UI for Android and iOS

Published · · 6 min read

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:

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:

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:

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:

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:

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

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.

Apps in this article