I wanted to explore a particular way of building mobile apps: native interfaces on iOS and Android, with the business logic shared in Rust. Each platform gets its own UI, but the rules for fetching data, searching, saving records, and recovering from failures live in one place.
The example is Holocron, a Star Wars explorer built with SwiftUI on iOS and Jetpack Compose on Android. Both apps browse people, planets, starships, vehicles, species, and films. They support search, favorites, an offline archive, connected records, and ship comparisons.
Star Wars gives the pattern something concrete to work on. A character has a homeworld, appears in films, and flies ships. Following those connections exercises navigation, data loading, and caching. Saving them introduces persistence. Searching while a request is still running introduces the kind of bug that a static screen never reveals.
Where the shared code stops
The project has two Rust crates. swapi-core owns application behavior: HTTP requests, response parsing, state, search, pagination, cancellation, and persistent storage. swapi-ffi exposes that behavior to Swift and Kotlin through typed records, commands, and subscriptions.
Mozilla’s UniFFI generates the language bindings. It lets the native apps call the Rust library without maintaining the binding code by hand. Compiling and packaging that library for each platform is still the project’s responsibility.
The division looks like this:
| Responsibility | Owner |
|---|---|
| Fetch and validate API data | Rust core |
| Search, pagination, retry, and cache policy | Rust core |
| Favorites, saved records, and comparison values | Rust core |
| Deliver state on the UI thread and manage subscriptions | Swift and Kotlin adapters |
| Layout, navigation, focus, accessibility, and animation | SwiftUI and Compose |
That adapter row deserves attention. Saying the native layer “just handles UI” includes some real engineering. A Rust callback can arrive on a worker thread after a screen has gone away. Swift and Kotlin have to deliver it safely, reject stale updates, and release the subscription when its owner is finished.
I like having this boundary explicit. You can look at a feature and ask which side owns a decision. Whether a failed request can be retried belongs in Rust. How the retry button looks, where focus moves, and what the screen reader announces belong in the native app.
Follow a search for Luke
The search box is a useful way to see the whole arrangement working.
As someone types, the native text field owns the editing experience: selection, keyboard composition, and focus. It sends the query to a Rust session. Rust owns the 300 ms debounce, starts the request, and replaces the previous search when the query changes.
That last part matters. If a response for an earlier query finishes late, it mustn’t replace the results for the current one. The shared paging implementation serializes commands and cancels superseded work. Both apps get that behavior from the same implementation.
Rust then publishes a snapshot containing the query, records, total count, whether another page exists, the loading or failure phase, cache information, and a revision number. The Swift adapter exposes it as observable state; the Kotlin adapter exposes it through StateFlow.
The native views render that snapshot. A refresh can keep existing records visible. A pagination failure can offer retry without throwing away the pages already loaded. Those decisions are represented in the shared state instead of being reconstructed separately by each screen.
The revision number protects the final delivery step. Each adapter rejects an older snapshot and refuses updates after it has stopped. Cancelling a subscription can’t undo a callback that’s already running, so the native guard is part of the contract.
These snapshots represent the latest state. A slow observer may skip intermediate snapshots. That works for rendering a screen; a consumer that needed every event would need a different contract.
Offline behavior belongs with the data
Holocron’s archive uses SQLite from Rust. Native code supplies the database path, and the shared core handles saved records, favorites, local search, collection downloads, and freshness rules.
Consider opening a record when the connection fails. The core can publish saved content first, attempt a refresh when needed, and retain that content if the refresh fails. It supplies cache metadata so the UI can explain what happened. Neither native app has to invent its own definition of stale data.
There are smaller rules here that are easy to overlook. Updating a cached record preserves its favorite flag. Clearing evictable cache data retains favorite records. An interrupted collection download keeps partial data, but the collection is marked complete only after all expected records arrive.
Those are product behaviors. Keeping them in Rust means a change to one of those rules has one implementation and one place to test it. The iOS and Android apps still need integration tests to prove that they expose the result correctly.
Comparing ships, drawing the bars
The ship comparison screen makes another part of the boundary visible. Rust selects the comparison data and normalizes valid numeric measurements. Values such as unknown remain unknown; they don’t quietly turn into zero-length bars.
The native layer owns card geometry, transitions, and haptics. The same principle applies to film opening crawls: shared content feeds native playback, with animation timing and reduced-motion behavior handled on each platform. Animation frames never cross the Rust boundary.
Home-screen widgets extend this arrangement beyond the app. Rust selects the cards to show. Android stores a native projection for its widget; iOS writes one to an App Group for the WidgetKit extension. The iOS extension reads that projection without opening the Rust database or making its own HTTP requests.
This is the part of the pattern I find most useful: shared behavior can support quite different presentation surfaces without requiring them to share a rendering system.
Two native apps still need two native builds
There’s a cost to this choice. The project has Rust, Swift, and Kotlin code, generated bindings, and platform-specific packaging. Android needs shared libraries for its supported processor architectures. iOS needs device and simulator libraries packaged in an XCFramework. The binding generator and Rust UniFFI dependency must agree on their version.
You also maintain two interfaces. A shared core won’t implement both navigation stacks, tune both layouts, or check accessibility on your behalf. For a small app with little shared behavior, that extra build and language boundary may cost more than it saves.
The tests follow the same separation. Rust tests cover parsing, request races, pagination, persistence, and state transitions using fixtures and local HTTP servers. Native tests run through the actual generated bindings and packaged Rust library to exercise state delivery, screen journeys, and lifecycle cleanup.
The repository’s verification notes record simulator and emulator results. Those are development-build evidence. Physical-device performance, release signing, and store delivery remain separate work; this example isn’t a claim of a shipped App Store or Play Store product.
Read the pattern through one feature
If you want to explore the source, start with People search. Follow crates/swapi-core/src/people.rs into crates/swapi-ffi/src/people.rs, then compare ios/Holocron/PeopleModel.swift with android/app/src/main/java/dev/holocron/app/PeopleController.kt. The two adapters make the shared contract easy to recognize.
The README has build instructions, screenshots, and links to the architecture documentation. Rust and Android work can run on Linux; the iOS build needs macOS and Xcode.
I’ve explored a related separation in the Rust and WebAssembly chess course: a Rust engine behind a browser interface. Holocron takes that idea into two native mobile apps, where lifetimes, offline storage, and platform integration put more demands on the boundary.
What I want to carry forward from this project is the ownership rule. Put application decisions in the shared core, give native code a clear state contract, and let each platform handle the interaction. Search and persistence stay consistent while the interfaces have room to feel at home.
If this pattern looks useful, star the Holocron repo and try a feature on both platforms. I’d like to hear where you’d draw the boundary in your own app. Follow me on X @mikezupper for more projects like this, or email [email protected]. If you find a bug in the example, open an issue on GitHub.