What I learned shipping a running app with Kotlin Multiplatform, Compose, and a native Apple Watch app
I build Flux Run, an AI running coach for iPhone and Android, as a solo developer. The app itself is written once in Kotlin Multiplatform: run recording, pace and split maths, reminders, and the Compose Multiplatform UI. The AI coaching is not in the app: it runs on a separate backend service, and the app talks to it and shows what it returns. Around that sits a small amount of native code where the platform really matters. Here is where the line fell, and the two things that bit me.
What is shared, what is native
- Shared (Kotlin Multiplatform + Compose Multiplatform): run recording state, pace and split maths, reminder logic, settings, the screens that show your plan, and the UI for iPhone and Android. Most of the app's logic and UI is shared; only the watch, widgets, Live Activity and Android services are native.
- Native on iPhone (SwiftUI): the Apple Watch app (heart rate, distance, haptics at every kilometre, HealthKit workouts), the Lock Screen Live Activity and widgets. These need Apple APIs Compose can't reach.
- Native on Android: foreground service for recording runs with the screen off, Health Connect, and a separate Wear OS app.
The rule I settled on: if it needs a system extension (watch, widget, Live Activity, foreground service), it is native; if it is a screen or a rule, it is shared.
Gotcha 1: an interstitial ad detached the Compose view and crashed the app
Apple rejected a build under guideline 2.1(a) for a crash after an ad played. Symbolicating the crash showed an unhandled Kotlin exception from Compose's hosting view controller: "Cannot attach layer if the view is not in the window hierarchy." The cause: a Compose AlertDialog opened while the AdMob interstitial was presented full-screen over the root view controller, which temporarily removes the Compose view from the window hierarchy. The fix was twofold: present the interstitial from its own temporary UIWindow so the Compose view stays attached, and only show that dialog within 30 minutes of a purchase started in the paywall. If you mix Compose Multiplatform on iOS with full-screen native presentations, check what your Compose view's window state is when they overlap.
Gotcha 2: the iOS app can silently ship old Kotlin
The iOS app consumes the shared module as an xcframework. If you build the app in Xcode without rebuilding the framework first, you get the previous Kotlin code with no error. I now rebuild the xcframework as its own explicit step before every iOS build, and I check the built IPA rather than trusting the build log.
Smaller lessons
- Compose string resources don't want escaped quotes: write the apostrophe plainly, or it shows up literally on screen.
- Keep the Watch and Wear apps thin. They record sensor data and show the current session; the plan itself comes from the phone and the backend.
Why KMP for this app
One team of one. Sharing the run logic and UI across two phones, and keeping the watch apps native, let me change a screen or a rule once and see it on iPhone and Android the same day. The AI side lives on the server, so it can change without shipping a new app.
If you want to try it: Flux Run is free on iPhone and Android.