DEV Community

Cover image for 97% of My App's Code Is in commonMain — A Field Report on Shipping 100% Compose Multiplatform
wang jack
wang jack

Posted on

97% of My App's Code Is in commonMain — A Field Report on Shipping 100% Compose Multiplatform

I shipped a small dev-news reader to Google Play with the entire client written
in one Compose Multiplatform codebase
— every screen in commonMain, no
per-platform UI. This is an honest field report on what that actually costs in
production: the numbers, the native seams, and the parts that still hurt (hi,
iOS). The repo is open source (MIT), so everything here is checkable.

TL;DR — For a content/list/detail app, CMP is comfortably production-ready
on Android. 96.9% of the shared module is commonMain; the native cost is
concentrated in ~10 expect/actual seams. iOS compiles and renders, but isn't
polished yet.

The numbers

Source set Files Lines Share
commonMain 59 ~7,700 96.9%
androidMain 2 123 1.6%
iosMain 3 127 1.6%

All 17 screens live in commonMain — trending list, aggregated feed, README
detail, profile with paging, settings, favorites — and there isn't a single
if (isAndroid) branch in the UI.

The 3% that isn't shared

The native cost isn't spread thinly across the codebase. It's concentrated in
~10 expect/actual seams, and this is the entire list:

  • Platform info — app version, system language, User-Agent string
  • System interaction — open URL, open app settings, share sheet
  • Analytics — a trackEvent hook (Android → Aptabase; iOS is deliberately a no-op for now)
  • WebView — the messy one (below)

Everything that touches a platform API is small and enumerable. Everything else
came for free.

Why expect/actual and not an interface + DI?

For these ~10 seams, expect/actual was the least ceremony: no DI wiring, and
the compiler refuses to build until every target implements the declaration. The
moment a seam has more than one implementation, or I'd want to fake it in tests,
an interface in commonMain with injected impls is the better tool. For a fixed
set of platform primitives, expect/actual wins on friction.

The ugliest boundary: WebView

I have two WebView paths, and I'll be precise because the repo is open:

  1. Rendering GitHub READMEs from an HTML string — inline expect/actual over android.webkit.WebView / WKWebView, JS disabled, theming done by injecting CSS colors so light/dark stays consistent. ~30–50 lines per side.
  2. Opening external links in-app — this one I pulled into a standalone library, kmp-webview, because the lifecycle/config surface (file chooser, media capture, title handling) got hairy enough to deserve its own home.

WebView is hands-down the least elegant part of the codebase. If you've wrapped
WKWebView / Android WebView behind a common composable, I'd genuinely welcome
a sanity check on the library's API.

Honest status: iOS is WIP

Android is live on Google Play. iOS compiles and every Compose screen renders in
the simulator — but it isn't polished:

  • iPad share crashes without a popoverPresentationController anchor (there's a literal TODO)
  • iOS has no analytics wired
  • the AI-chat screen is Android-only right now

Not vaporware, just not shipped. And honestly, iOS is where the KMP tax
actually shows up
— not in the shared code, but in the toolchain around it:
living with Xcode for the shell, slower clean builds when the iOS target is in
the loop, and fewer battle-tested libraries. The Kotlin/Compose side is smooth;
the iOS edge is where I spend my "fighting the tools" time.

Stack

Kotlin 2.3.21 · Compose Multiplatform 1.11.1 · Ktor 3.5.0 · Coil 3 ·
multiplatform-settings · Material 3. Navigation is a small stack-based setup in
commonMain (data objects/classes as route keys), no platform-specific nav.

Takeaways

  • For a content/list/detail app, shared-UI CMP pays off on Android. Write 17 screens once, not twice.
  • The native tax is small and predictable. It lives at ~10 expect/actual seams — anything that wraps a platform view or calls a platform API.
  • The pain is exactly where you'd guess: WebView, and the iOS toolchain.
  • expect/actual for fixed platform primitives; interfaces for anything you'd test or swap.
  • Be honest about iOS. Compiling and rendering in a simulator is not the same as shipping.

The app is a GitHub Trending / Hacker News / Product Hunt reader, if you want
context on the screens. Repo (MIT): https://github.com/HarlonWang/TrendingAI
and the WebView lib: https://github.com/HarlonWang/kmp-webview.

What's your experience taking CMP to production, especially on iOS? I'd love to
compare notes in the comments.

Top comments (1)

Collapse
 
wang_jack_75a91dea08d2995 profile image
wang jack

One thing I left out of the post to keep it tight: the biggest surprise wasn't
that the shared UI worked — it was how little ended up in the platform source
sets. I expected androidMain/iosMain to grow as the app did; instead they've
stayed almost flat at ~120 lines each while commonMain kept climbing.

A couple of things I'm genuinely unsure about and would love this community's
take on:

  1. iOS in production — for those who've actually shipped CMP to the App Store (not just the simulator), what bit you that you didn't see coming?
  2. expect/actual vs interface + DI — where do you draw the line? I went expect/actual for fixed platform primitives, but I keep second-guessing it for anything I might eventually want to fake in tests.

Happy to dig into the architecture or the numbers — ask away.