As a solo developer, maintaining two separate codebases for iOS and Android is something I can't manage for more than one app. When I decided to build my decision-making app, yurua, for the RevenueCat Shipaton, I knew I wanted to launch on both platforms simultaneously.
Having been a "Kotlin native" and early adopter since 2017, the choice was obvious: Kotlin Multiplatform (KMP) paired with Compose Multiplatform (CMP).
Here is a breakdown of my architecture, how I achieved a 100% shared UI, and the platform-specific struggles I had to work around to get it shipped.
The "Thin Wrapper" Architecture
yurua uses a modern multiplatform architecture designed to keep the platform-specific code to an absolute minimum. The Android, iOS, and Desktop entry modules act as extremely thin wrappers. Their only job is to bootstrap the environment, initialize third-party tools like the RevenueCat SDK, and immediately hand over control to the shared core module.
Almost everything happens in that shared core: the entire UI, the navigation graph, the view models, and the domain logic.
To me, that's "don't repeat yourself" on the highest level of abstraction.
A quick workflow hack: I built a minimal Desktop JVM module for this project, but I never shipped it. I solely developed it to utilize the Compose Desktop Hot Reload functionality. If you are building for mobile, running a desktop target locally is an incredible way to iterate on your UI without waiting for emulators or dealing with device deployment.
100% Shared UI with Compose
The entire visual layer of yurua is shared. My app follows a strict "visual silence" design philosophy—meaning specific low-stimulus themes, smooth animations, and clean layout structures. All of this runs entirely through CMP without platform-specific UI code.
For routing, I utilized Jetpack Navigation Compose, taking advantage of its recent multiplatform support. Paired with Kotlin Serialization, this provides type-safe routing across the board. I can easily pass complex data structures between screens, and it works seamlessly on both Android and iOS. Localized texts and vector graphics are all bundled and loaded via the Compose multiplatform resource system.
Business Logic & Dependency Injection
Beneath the UI layer, KMP does the heavy lifting. I heavily relied on the expect/actual pattern to define platform-agnostic contracts for system appearance and navigation interception, which are then implemented in the native source sets.
A few architectural choices stood out during development:
- Storage Abstraction: User preferences are handled via a multiplatform data store. Because file system paths differ wildly between OS environments (e.g., the application documents directory on iOS versus the app context files dir on Android), I wrote a lightweight abstraction to resolve the correct local path at runtime.
- Dependency Injection: To keep the setup lightweight and fast, I skipped heavy, reflection-based DI frameworks. Instead, I passed dependencies down manually through a simple, shared container structure.
- Monetization: I defined a unified billing interface in the shared layer, while the thin platform-specific wrappers initialize the RevenueCat SDK with their respective API keys.
The Reality of Shipping KMP: Quirks and Workarounds
"Shipping everywhere" sounds great on paper and it is! But the reality also involves fighting platform-specific quirks. Here is what broke and how I fixed it:
1. The iOS AlertDialog Crash
A surprising crash happened on iOS regarding text inputs. It turned out that using a simple standard TextField inside an AlertDialog (from Compose Material 3) caused the app to crash completely. This hit my "Save as List" dialog hard. To be fully transparent, I used an older version of KMP at this stage without noticing it.
My immediate workaround? Build a custom dialog utilizing a BasicTextField instead. The real solution was obviously to update the project and drop the older KMP versions. Full disclosure: I'm still using the custom dialog. It’s a future TODO to check if the latest KMP versions fixed the underlying bug so I can switch back to the standard component. But other things have a higher priority right now.
2. Navigation Side Effects
Initially, I tried triggering navigation events directly during the UI composition phase. This is a bad idea. It led to immediate crashes on Android when navigating away from certain screens. I had to refactor my state handling to use proper effect handlers (like LaunchedEffect) to ensure navigation fires safely outside of the composition lifecycle.
3. The Back Navigation Dilemma
Android has a native back system; iOS relies on swipe gestures or explicit on-screen buttons. Because CMP doesn't provide a universal back handler out-of-the-box, I built a custom multiplatform abstraction. It delegates to the native Compose back handler on Android, while acting as a no-op on iOS. For iOS, I rely on native swipe-back wrappers and on-screen buttons (which, of course, also render and function perfectly on Android).
4. Apple’s HIG vs. Programmatic Exits
yurua has a "Back to the day" button at the end of a decision flow to close the app. On Android, terminating the process is standard and easy. On iOS, using platform.posix.exit(0) technically works, but it looks like a system crash and violates Apple's Human Interface Guidelines.
I actually only realized this policy risk while writing up the summary for my hackathon submission. I’ll likely have to replace the exit action on iOS with a simple text state confirming the app has done its job. Luckily yurua still passed app review in the App Store (for now). But that's definitely something I need to target.
Dealing with these underlying OS differences and finding the right fallbacks remains the biggest challenge in KMP development. But with every release, the gap gets smaller, and the ability to ship a polished native experience from a single codebase makes it entirely worth it.
Bottom Line
For me the pros of KMP clearly outweigh the cons. Even with AI I wouldn't want to maintain two completely separate code bases for iOS and Android. I'm happy JetBrains released KMP as it enables me to leverage my (legacy) Kotlin skills and release not only for Android but also for iOS - with minimal exposure to the iOS part (which I have very little experience with as of now).
Disclosure: All experiences and learnings shared here are my own. I used AI solely as a writing assistant to ensure fluency for my non-native English.
Top comments (0)