Our Android App Is Written in Swift

Last month we shipped the Polymarket Android app for the US market. It's live on Google Play. It's built with Compose and Navigation3, and it looks and feels like a modern Android app.

It's also mostly written in Swift.

Nearly 170,000 lines of production Swift run inside it: our models, our networking, our business logic, and more than 120 ViewModels. The same code powers our iOS app. One codebase, two apps.

Here's how we did it, what broke along the way, and why we'd do it again.

The question that started it

When we got the green light to launch in the US, our iOS app was well underway. The Android app didn't exist.

The obvious question was "how fast can we ship Android?" We asked a different one: once Android ships, how do we avoid maintaining two products that slowly drift apart?

Every team that runs two native apps knows the drift. A bug fixed on one platform and forgotten on the other. A pricing rule that quietly disagrees between the two. Two teams building the same feature twice, slightly differently.

So we made a bet on Skip, a toolchain that compiles Swift for Android and bridges it to Kotlin. Within a week, our existing Swift networking and models were powering a list of NBA games inside an Android app. That was enough proof to keep going.

Drawing the line

We had a head start: the iOS app was 100% Swift, split into packages with Swift Package Manager. The first move was structural. Everything we wanted to share went into polymarket-shared. Everything iOS-only stayed in polymarket-ios.

The rule was simple: share everything we can.

Anything touching UIKit, SwiftUI, MapKit, PassKit, or platform-specific SDKs stayed on the iOS side. Everything else was a candidate for the shared layer.

To make that boundary real, we hid platform code behind protocols and injected the right implementation at startup, using Point-Free's swift-dependencies.

Take payments. Apple Pay only exists on iOS:

// In polymarket-ios
class ApplePayClient {
  func requestPayment(amount: Double) async throws -> ApplePayResult { ... }
}

To reuse the surrounding logic with Google Pay, the shared package defines a protocol:

// In polymarket-shared
protocol PlatformPayClient {
  func requestPayment(amount: Double) async throws -> PlatformPayResult
}

enum PlatformPayResult {
  case applePay(...)
  case googlePay(...)
}

And here's the part that still feels a little like magic: the Android implementation is plain Kotlin, using the native Google Pay SDK, conforming to a Swift protocol.

class GooglePayClient(
    val context: Context,
) : PlatformPayClient {
    override suspend fun requestPayment(amount: Double): PlatformPayResult { ... }
}

On iOS, the AppDelegate injects the Apple Pay client. On Android, the Application class injects the Google Pay one. From then on, shared code asks for platformPay and gets whatever platform it's running on.

We repeated this pattern for analytics, storage, notifications, and authentication. Once those boundaries were in place, our domain logic depended on nothing but Foundation, Observation, and a small set of Swift packages that already run everywhere. Which means it ran on Android as-is.

Then we got greedy: sharing ViewModels

Shared models and networking are table stakes. Once tests were passing on Android, we asked the question that actually changes the economics:

Could we share the ViewModels too?

Our app uses our own flavor of MVVM. Every ViewModel is a plain Swift class marked @Observable, built on a small shared base class. The UI sends user actions in through an input enum, reads state back out through observable properties, and navigation flows out through callbacks. No UIKit. No SwiftUI. Just state and behavior.

That's what made the idea possible, because an @Observable object doesn't care who renders it:

  • UIKit, which is most of our iOS app. Here we follow the convention of reading the state in viewWillLayoutSubviews or updateProperties.
  • SwiftUI, on the screens where we use it. Observation is built in.
  • Compose, on Android. Screens read the same properties, and Skip's observation bridge triggers recomposition when they change.

One ViewModel. Three UI frameworks. Each platform renders it natively, and none of them can drift apart on behavior, because there's nothing to drift: it's the same object.

Getting there took real work. Our ViewModels were built on ObservableObject, lots of code expected @Published properties and Combine publishers, and the ViewModels lived in the iOS package. Each one had to move into the shared module with its dependencies and migrate to @Observable. That migration paid off on iOS too: @Observable gave us a cleaner, faster foundation for both UIKit and SwiftUI.

So, to prove this approach, we picked a single screen to prove the idea end to end: Settings.

A week later, Settings was running on iOS and Android from the same ViewModel. That was the moment this stopped being a demo.

From there we moved screen by screen. Every migration merged into main and shipped to iOS users in the normal release cycle. The risk stayed small, iOS got healthier, and the pool of shared product logic kept growing.

Shipping the teaser first

One question we refused to answer on launch week: what does an Android release actually look like when Swift is in the stack?

So we shipped a teaser first, the Polymarket waitlist app on Google Play. It sounds trivial, but it exercised the whole machine: CI, Android packaging, shared Swift code, Skip bridging, analytics, and release mechanics. It was the smallest thing we could put in users' hands that still proved the system.

Once the numbers looked healthy, we iterated screen by screen and integration by integration until we had the app we wanted to launch.

The payoff during those months was compounding. While the Android team ported and integrated, the iOS team kept shipping features and fixes, and Android inherited most of that work for free, because the shared core kept moving forward underneath both apps.

What bit us

Not everything worked on the first try. Three problems cost us real time.

Object lifetimes

The hardest problem: Swift @Observable objects don't naturally fit Android's lifecycle model.

On iOS, object lifetime is predictable. Present a sheet, and its ViewModel is created just before it appears and destroyed when it's dismissed. Push a screen, same thing. Our shared ViewModels were designed around that behavior.

Compose is happy to keep objects around and reuse them later. In most Android apps that's a feature. In ours it caused stale data, zombie websocket subscriptions, strange recomposition behavior and more bugs.

After a few failed patches, we did the honest thing and built our own Compose components that recreate iOS lifetime behavior: a navigation stack, a tab view, a sheet, and a full screen cover, plus a swiftViewModel helper that creates a fresh ViewModel for each new screen or presentation and tears its scope down on dismissal.

We still don't control when Android's garbage collector frees a Swift-backed object. But we do control when a fresh ViewModel is created, and that's what the mental model depends on. Porting features got much more predictable after this.

Combine, on its way out

When this project started, much of our structural code was built on Combine, and OpenCombine runs it fine on Android. The catch is the bridge: Skip generates Kotlin APIs from Swift Concurrency, not from Combine publishers. So anything Android needed to call directly got a thin layer translating Combine into async/await. Something like this:

// In polymarket-shared
class PriceFeed {
  // Combine works fine on both platforms,
  // but Kotlin can't see this
  let prices: AnyPublisher<Price, Never>

  // So we expose a Swift Concurrency surface,
  // which Skip bridges to Kotlin
  var priceUpdates: AsyncStream<Price> {
    prices.stream()
  }
}

The mirror image showed up on iOS. As more code moved to Swift Concurrency and @Observable, older views still wanted publishers, so we wrote glue in the other direction too, exposing @Observable properties as publishers where needed.

Today, Combine is the exception rather than the rule. New code reaches for Swift Concurrency, Observation, and swift-async-algorithms first, and Combine survives only in a few corners we haven't migrated yet. Every migration shrinks the glue, and the goal is to remove it entirely.

The human problem

The last lesson was about people. Once a shared layer exists, it's only time before someone in your team imports UIKit into the shared code on a Friday afternoon. The fix isn't vigilance, it's CI: every pull request that touches shared code runs the shared test suite on an Android emulator and builds the Android app before it can merge. With that guardrail in place, the team started writing platform-neutral Swift by default.

What day-to-day development looks like

A fair question at this point: what is it actually like to work in this codebase?

If you're an iOS engineer, nothing changes. You write Swift in Xcode, and the shared package is a normal Swift Package Manager dependency.

If you're an Android engineer, the app looks like any other Android project: Kotlin, Compose, Gradle, Android Studio. The shared Swift arrives as prebuilt Android libraries. A tool called skip export compiles each shared Swift module into an AAR, and Gradle consumes those like any other dependency.

So a Kotlin change follows the normal loop: edit, build, run. A Swift change adds one step:

  1. Edit the Swift code in the shared package.
  2. Run skip export. It recompiles only the modules that changed and drops fresh AARs into the Android project.
  3. Sync Gradle, then build and run as usual.

In practice, Android engineers spend most of their time in Compose, building screens against ViewModels that already exist. When they do need to change Swift, the extra step is annoying but mechanical.

Swift on Android: a field report

Swift has run on Linux for a decade, but open-source Foundation is still young, and running Swift on Android at this scale is new territory. We've hit issues, reported them to the Android Workgroup, and watched things improve in real time.

Honestly, more worked than we expected. Localized strings, logging, bundled resources, @Observable tracking in Compose, Keychain, FileManager, testing: the breadth of what Skip handles surprised us more than once. The ecosystem is pulling in the right direction too. Point-Free's swift-dependencies, swift-clocks, swift-concurrency-extras, and swift-identified-collections, Apple's swift-async-algorithms and swift-crypto, and Google's gRPC all made this path far more practical than it would have been even a year ago.

The rough edges, especially from an Android engineer's chair:

  • The edit-compile cycle. That extra skip export step adds up. You feel it most when iterating on Swift changes from Android Studio, where the IDE can't do it for you.
  • Debugging. You can't set a breakpoint in Swift from Android Studio. Yet.
  • Binary size. Swift adds real weight to the APK, about 50 MB at download time for us.
  • Closure bridging. Bridging Swift closures to Kotlin was far harder than we expected. This is where we truly pushed the limits of the toolchain, because Swift closures are not simple.

Would we do it again? Without hesitation. Every alternative meant giving up Swift for the domain logic we're good at writing, and walking away from the well over 100,000 lines of useful Swift we had already written when we started.

Where this goes

The Android app is close to feature parity with iOS, and we keep shipping updates to make it feel more at home on Android. Both apps draw from the same core.

We're proud sponsors of Skip, and we'd recommend this path to any team with strong iOS roots that wants to keep writing Swift without giving up on Android.

Finally: thank you to Pierluigi, Bishwa and Pedro from our Android team, Marc from Skip, and the Swift Android Workgroup for making Swift on Android not just possible, but practical.

42 Likes

Not to discourage you or anything, but – languages and frameworks aside – in my experience, cross-platform development can be a false economy.

I've seen too many projects where the overhead of maintaining a shared codebase across two or three platforms outweighs the total amount of platform-specific code those projects would otherwise require.

More often than not, there's also a drift toward the lowest common denominator: interesting platform-specific features get rejected simply because they can't be supported everywhere.
The upfront cost is not always obvious, either. It may involve using tools that are non-standard for a given platform and then having to hire people with an equally non-standard skill set. One example I encountered was a team abandoning C++ in an Android project simply because it was cheaper to hire an Android developer who couldn't read or debug C++ than one who could.

Your mileage may vary, of course.

Nevertheless, +1 for the Swift-on-Android effort. And that's not just because of Swift... I did have a similar reaction a few years ago when someone implemented a backend in Kotlin.

1 Like

This is really cool! Thank you for sharing such a thorough writeup. We're excited to try something like this soon.

3 Likes

In my experience, it is not. Cross-platform development is wildly popular, in the form of dominant frameworks like Flutter and React Native. These are the frameworks that are "eating the world" right now, at least in the mobile space. The economic benefits are indisputable: one codebase instead of N, one place for testing, maintenance, feature synchronization, and release cadence.

I'd love to get more insights about this if you can share them. My experience is entirely the opposite: a single shared codebase facilitates so much more code reuse, robustness, and team velocity than doing everything twice+[1].

I'll disclaim as one of the authors and maintainers of Skip (the technology that the OP uses to share their codebase between iOS and Android), I certainly have a horse in the race. But I have honestly never encountered a case in my 30 years of software development experience of any app development team — small or large — that has regretted unifying their codebase.


  1. …and yes, I must hedge that these economics may very well be disrupted by LLM-driven development in the coming years. I speak only of my experiences to date, and not what the future may hold. ↩︎

4 Likes

I would make a distinction here between "cross-platform development" in the Flutter or Electron sense, and sharing code between platforms.

I am not a fan of Flutter or Electron (for several reasons, bloat being one).

And I can see the problem with trying to share C++ code between platforms, but sharing Swift code as outlined here makes a lot of sense in my opinion, and it works really well from my own experience building a multi-platform (macOS, Windows) app.

My app is a native AppKit (Swift) app on macOS, a native Windows APP SDK (Swift) app on Windows, and both apps get all the resources, models, and logic (except platform-specific logic or resources, of course) from a shared (Swift) Core library.

The native UI layer ends up being very thin on both platforms, as I try to put any shared logic (including UI logic, but not in an MVVM style) into the shared Core. For me, that's the most economic way to support two platforms.

4 Likes

Very impressive accomplishment! Good luck!

Finally: thank you to Pierluigi, Bishwa and Pedro from our Android team, Marc from Skip, and the Swift Android Workgroup for making Swift on Android not just possible, but practical.

Thank you for the trust in the direction! Happy to answer any questions from the Forum!

2 Likes

I'll +1 @marcprux's message here. We have millions of users on our Android app, and much of it is built with Swift. We have gotten incredible improvements in feature consistency, shared bugfixes, and developer focus by having shared logic. We use native UI code (accessing shared logic via bindings built by FishyJoes) and it has served us very well, even with the cost of integration.

Technologies like Skip, FishyJoes, and those described by @poly-rob can allow organizations to tailor their shared code needs to the amount of sharing they're comfortable with. Glad to see another success story described in detail!

3 Likes

Sure, this is how it played out for me.

When you require candidates to be strong in both iOS and Android, you may reduce the available talent pool by an order of magnitude, assuming salary, seniority, and other factors remain equal.

If you then require using Kotlin on iOS – or, equally, Swift on Android – you narrow that pool even further, perhaps by another factor of 30 if not 50.

The likely outcome is that you hire someone less senior than the role really requires.

From there, the hidden costs begin to accumulate. Engineers have to work with non-standard tooling, debug complex issues in unfamiliar languages, and deal with UI frameworks that are not native to the platform.

Over time, both the shared codebase and the platform-specific glue needed to make it work continue to grow. Eventually, the overall system becomes larger and more complicated than maintaining two or three independent, platform-native codebases would have been.

We experienced this ourselves a few years ago when C++ was the shared language. Making it work cleanly with managed C#, Android JNI, and iOS required a significant amount of technical gymnastics. Even seemingly the most close to C++, the iOS integration was far from ideal, with NSObject appearing everywhere.

Then, after a few years, the junior engineer you hired becomes senior and leaves for a more conventional role with a more standard technology stack.

At that point, the hiring cycle starts all over again.

YMMV, of course.

2 Likes

Fair point. But the way we see it, we don't need a "Swift on Android" engineer, but a just a Swift engineer. The same language we know and love behaves identically on Android.

Although there are rough edges in the Android Studio experience, it's far better than what I expected and I think most people would be surprised by the breadth and quality of the ecosystem around Skip and Swift on Android.

We recommend this approach to any team with strong roots in iOS that wants to keep feature parity with Android.

This one is particularly cloying. One of the main reasons we went with FishyJoes instead of Kotlin Native for our shared code was the reliance on all things @objc. Using C++ as the shared language technology and needing to integrate using NSObject adds complexity, while the memory model of C++ also adds significant drawbacks.

Using Swift and its FFI features in FishyJoes has allowed us to generate all the tricky integration code, and mostly ignore that types written in Swift are not native Kotlin types at all. We even use a Swift version of the JNI! That accelerates development significantly and removes guesswork and training for developers that work at the boundary.

Perhaps the nature of the toolchains you've used in the past have colored your perception of how much extra work shared codebases take over duplicated implementations. Or not, and I've just drunk too much of the Swift Everywhere Kool-Aid, but I'd never go back! Take FishyJoes, Skip, or swift-java for another spin and see if your opinion changes.

1 Like