Most "should we go cross-platform?" debates are fought with opinions. I wanted numbers.

So I built the same app five times. Each version is an iOS app and an Android app with the same product, data and screens. The only thing that changes is how code is shared between the two platforms:

Shares UI on iOS / Android
Native baseline nothing SwiftUI / Compose, written twice
Rust + UniFFI logic SwiftUI / Compose
Swift, cross-compiled for Android logic SwiftUI / Compose
React Native logic + screens React Native views
Skip Lite logic + screens SwiftUI / real Jetpack Compose

The app is a UFC betting app (I know). It's built on two real, finished events, so every odd, result and settlement in the test fixtures is a fact rather than an invention. All the code, build scripts and measurement tools are open source: github.com/daniboybye/fightdeck.

The rule I started from: the app stays native

I left out anything that draws its own UI on a canvas. That isn't about taste. A canvas loses the system, not the pixels: accessibility, text selection, the input method editor, gestures, Dynamic Type and Liquid Glass all have to be re-implemented instead of inherited.

The rule was this: the host app stays SwiftUI and Compose, each feature ships as its own SDK, and the cross-platform technology hides behind that SDK. You keep new platform APIs from day one. You can mix technologies in one app, or swap one out. And you pay for what you mount, not for what exists. A hundred desktop features shouldn't ship their runtime into a phone.

The rule has a price, and it's better to know it up front: lost consistency, coordination between SDKs, diamond dependencies, some duplicated logic, and the testing illusion that every SDK passes on its own while the integration still breaks.

What it costs to ship

Release builds, arm64:

iOS .app Android download Fixed runtime (iOS / Android)
Native 1.96 MB 1.47 MB —
Rust 2.83 MB 2.17 MB ~0.55 / ~0.4 MB
Swift 1.97 MB 7.76 MB 0 / ~6.2 MB
React Native 23.32 MB 6.70 MB ~21.2 / ~5.2 MB
Skip Lite 2.07 MB 4.83 MB 0 / ~3.3 MB

The interesting column is the last one. Each further feature costs every approach roughly 0–100 KB. The runtime is what separates them, and you pay for it once. Swift and Skip cost nothing on iOS because the Swift runtime ships with the OS. React Native pays on both platforms.

What it costs to write

Host code vs. baseline Adapters Total hand-written Generated
Native — — 4,492 —
Rust −27% 64 4,878 8,473
Swift −28% 569 4,743 1,152
React Native −25% 733 5,531 282
Skip Lite −46% 216 4,524 2,785

The surprise: no approach writes less code in total than writing everything twice. Sharing moves lines out of the apps and into shared code, adapters and generated code. What you gain is one copy of the logic to change and test, not fewer lines.

What each technology actually felt like

Rust + UniFFI: where I'd start a new headless core in 2026. UniFFI, started by Mozilla, generates both sides of the bridge. Not one line of wrapper is written by hand: 8,473 generated lines leave 64 lines of adapter in the apps. You still design the API boundary, but with far fewer constraints than C++. Option and Vec arrive as Optional and Array. Records are copied on every crossing, so prefer a few coarse calls over many fine ones. The rough edge: the generated Swift isn't yet ready for Swift 6 strict concurrency, so it lives in its own Swift 5 target.

Swift on Android: free on iOS, Android pays. With the official Swift SDK for Android and swift-java's jextract, the Compose app really does run the same Swift, and the bridge is generated. But Android carries its own Swift runtime (+6.2 MB download, the largest install of all). You write a facade by hand. Iteration is slow, and you can't debug Swift from Android Studio. Even so, this is the approach where I needed the fewest iterations to finish, because Swift is what I read and review best. Familiarity is a real cost factor.

React Native: mature, cheap per screen, not invisible. One Hermes runtime serves every feature SDK. The next screen is just more JS in the same bundle, and the host never imports React Native. With the New Architecture, JSI and Codegen give typed calls in place of the old message bridge. The seam still shows, though: these are React Native widgets, not Liquid Glass or Material 3. Yoga's flexbox replaces platform layout, and types and component libraries you already have natively get re-implemented in JS. It also has the most adapter code (733 lines) and the most configuration.

Skip Lite: real Compose, but read the fine print. Skip transpiles Swift and SwiftUI to Kotlin and Jetpack Compose. On Android there's no Swift at all, and you debug ordinary Kotlin in Android Studio. It's the only approach that empties the host apps (−46%). The catch: the generator translates the declaration, not the platform's behaviour. Some failures are loud: an unsupported API breaks the build. Most are silent and only show up on a device: a view that doesn't render, a missing icon, or the keyboard not lifting content, because SwiftUI does that implicitly and there's no call to translate. The Android job changes from "write Compose" to "read Compose".

The takeaways

  1. Keep the app native and make the SDK the variable. It's the only setup where you can change your mind later.
  2. Logic shares well. UI shares less than it promises. Even in a demo with almost no business logic, sharing logic empties the apps by about a quarter, whatever the language, and that share grows with every rule you add.
  3. Choose by fixed cost and by where the seams show, not by how the first feature looks.
  4. Every approach moves the cost; none removes it. C++ pays in wrappers, Rust in generated code, Swift in Android tooling, React Native in runtime and visible seams, Skip in the limits of transpilation.

Two caveats, and they pull in opposite directions.

First, I set up the demo to penalise UI sharing: it targets iOS 26 and Android 17, and new design languages land in SwiftUI and Compose first.

Second, and more important: it's a demo with almost no business logic. The screens are most of the code, so sharing only logic looks modest here: about a quarter of the host code. A real app is different. Pricing, validation, settlement, sync, offline rules and caching pile up in the layer below the UI, and that layer is exactly what Rust and Swift for Android move into one shared copy. The fixed runtime is already paid and each extra feature costs ~0–100 KB. So the more logic the app has, the larger the share of code that Rust or Swift-for-Android share, while the UI stays fully native. In a production app I'd expect these two to share a much bigger part of the codebase than this demo shows.

The full write-up, with methodology and the rough edges per approach, is in FINDINGS.md. Every number can be reproduced with tools/ci-local.sh. If you've shipped one of these in production and your experience differs, I'd love to hear where. And if the repo saved you a week of evaluation, a ⭐ helps others find it.

This is the written version of my talk "Share Code, Keep Your Native iOS & Android UI".