Mobile development with Rust means putting the same business logic for iOS and Android into a single Rust library instead of writing it twice, then generating bindings for both platforms from that one source. This article walks through bringing a real Rust module to Kotlin and Swift with UniFFI, where cargo-ndk and XCFramework fit in the toolchain, how errors are handled at the FFI boundary, and when this approach makes less sense than KMP or native Swift/Kotlin.
💡 Pro Tip: Set up UniFFI from the very start of the project as "one source, two bindings" — trying to bolt it on later means retrofitting already-written platform-specific interfaces onto UniFFI types after the fact, and that's usually more expensive than setting it up correctly the first time.
Table of Contents
- Why a Rust Core: What Problems It Solves
- The Toolchain: cargo-ndk, XCFramework, UniFFI
- Your First Shared Module: An End-to-End Example
- Error and Memory Handling at the FFI Boundary
- The Truth About Build Time and Binary Size
- Setting Up Both Platforms Together in CI
- When to Choose KMP or Swift Instead of Rust
- FAQ
- How do you share common code between iOS and Android with Rust?
- What is UniFFI and what does it do?
- Does a Rust mobile core make sense in production?
- Can you compile Rust for Android without cargo-ndk?
- How does error handling work in UniFFI?
- Update (September 2026)
- Conclusion
- Sources
Why a Rust Core: What Problems It Solves
When you write the same business rule (a pricing engine, a sync algorithm, an encryption layer) separately in Swift and Kotlin, two teams end up dealing with two different bug surfaces. A Rust core keeps that logic in one place and guarantees identical behavior on both platforms.
Android's official developer documentation (AOSP) describes Rust as "a modern systems programming language that offers memory safety guarantees along with performance equivalent to C/C++." The same page also references Rust's "Fearless Concurrency" slogan — the compiler guarantees that make writing safe concurrent code easier.
This comes from a concrete source: AOSP's official "Android Rust introduction" page explains how Google positions Rust in native OS components, showing a deliberate, safety-driven bet on the language. It links Rust's entry into Android to three Google Security Blog posts ("Rust in the Android Platform," "Integrating Rust into the Android Open Source Project," "Rust/C++ interop in the Android Platform") — still the reference point for the platform's Rust approach.
On mobile, this general platform support isn't by itself a "shared core" — the real benefit is shipping this Rust code to both mobile platforms from a single source. This is where UniFFI comes in.
- What it isn't: a Rust core doesn't replace the UI layer; it's not a substitute for SwiftUI/Jetpack Compose.
- What it's for: platform-independent business logic, cryptography, data sync, parser/algorithm layers.
- Who it isn't for: a small team building a simple CRUD app that will stay on a single platform — in that case Rust's learning-curve cost doesn't pay for itself (we cover this in detail in the last section).
The Toolchain: cargo-ndk, XCFramework, UniFFI
Three pieces carry a shared Rust core to mobile: the tools that compile it, the platform-specific package formats, and the generator that produces the language bindings.
Android side — cargo-ndk: cargo-ndk is a cargo plugin that makes it easier to compile Rust code for Android NDK targets; UniFFI's own README references it in its "External resources" section ("Cargo NDK Gradle Plugin allows you to build Rust code using cargo-ndk, which generally makes Android library builds less painful"). Important behavior change: as of v4.0.0 (July 30, 2025), cargo-ndk no longer strips output by default and the --no-strip option was removed — you now handle symbol stripping yourself via Cargo's [profile.release] strip = true setting.
iOS side — XCFramework: Apple's official path combines binaries for different platforms and architectures (arm64 device, arm64/x86_64 simulator) into a single package, distributed via Swift Package Manager or directly in an Xcode project. This format is called XCFramework; Apple's official "Creating a multiplatform binary framework bundle" guide walks through the process.
Binding generator — UniFFI: Developed by Mozilla, UniFFI is the official toolset for generating multi-language bindings (Kotlin, Swift, Python, Ruby) from Rust libraries. Although it hasn't reached version 1.0 yet, the project considers itself production-ready — it's used in production in Firefox's mobile and desktop browsers, where code written once in Rust is callable from both Kotlin and Swift via automatically generated bindings.
Layer | Tool | Job | Platform |
|---|---|---|---|
Compilation (Android) | cargo-ndk | Rust → NDK targets (.so) | Android |
Packaging (iOS) | XCFramework | Combining multi-architecture binaries | iOS |
Binding generation | UniFFI | Rust → Kotlin/Swift code generation | Both |
Interface definition | .udl file or proc-macro | Defining the Rust API surface | Shared |
UniFFI has two ways to define an interface: the classic .udl file (a WebIDL-based Interface Definition Language) or proc-macros written directly inside the Rust code (like #[uniffi::export]). In new projects, the proc-macro approach produces fewer sync errors since it keeps the interface definition alongside the Rust source.
At the time of writing (October 2025), UniFFI's current version was v0.30.0 — tagged just a day earlier. We mention this because later command examples are compatible with this version family; in your own project always pull the current version with cargo add uniffi.
Your First Shared Module: An End-to-End Example
Let's walk through a simple calculation core. First we set up the library on the Rust side:
1# Cargo.toml2[package]3name = "mathcore"4version = "0.1.0"5edition = "2021"6 7[lib]8crate-type = ["cdylib", "staticlib", "lib"]9 10[dependencies]11# uniffi::uniffi_bindgen_main() won't compile without the "cli" feature12uniffi = { version = "0.30.0", features = ["cli"] }13 14[build-dependencies]15uniffi = { version = "0.30.0", features = ["build"] }We define the interface with a proc-macro — right inside the Rust source, no .udl file needed:
1// src/lib.rs2uniffi::setup_scaffolding!();3 4#[uniffi::export]5fn add(a: i32, b: i32) -> i32 {6 a + b7}8 9#[derive(uniffi::Record)]10pub struct CalcResult {11 pub value: i32,12 pub overflow: bool,13}In a single-crate scenario, set up uniffi-bindgen as an executable binary: add a [[bin]] entry to Cargo.toml and write a small main.rs calling uniffi::uniffi_bindgen_main() (multi-crate workspaces instead create a separate uniffi-bindgen crate). The bindgen CLI lives behind the uniffi crate's cli feature: either add features = ["cli"] to the dependency, or run with --features=uniffi/cli (both shown below):
1// src/bin/uniffi-bindgen.rs2fn main() {3 uniffi::uniffi_bindgen_main()4}We set up a second binary the same way to generate the Swift header/modulemap:
1// src/bin/uniffi-bindgen-swift.rs2fn main() {3 uniffi::uniffi_bindgen_swift()4}UniFFI's library mode (generate --library) generates bindings directly from the compiled cdylib/.so file — more convenient than pointing at a separate .udl file, since the interface is already defined via proc-macro. The flow looks like this:
1# 1) Build the Rust library2cargo build --release3 4# 2) Generate Kotlin bindings from library mode5cargo run --features=uniffi/cli --bin uniffi-bindgen generate \6 --library target/release/libmathcore.so \7 --language kotlin \8 --out-dir out/kotlin9 10# 3) Cross-compile for Android targets with cargo-ndk11cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \12 -o app/src/main/jniLibs build --releaseOn Kotlin, the generated code exposes the CalcResult data class and add() function directly with Kotlin types — no need to write the JNI bridge by hand:
1// Android — using the generated binding2import uniffi.mathcore.add3import uniffi.mathcore.CalcResult4 5val result: Int = add(3, 4)On iOS, the same step generates Swift bindings; the static libraries plus header/modulemap files then get combined into a single package with Apple's XCFramework tool:
1# Add and cross-compile the iOS targets2rustup target add aarch64-apple-ios aarch64-apple-ios-sim3cargo build --release --target aarch64-apple-ios && cargo build --release --target aarch64-apple-ios-sim4 5# Generate Swift sources + header + XCFramework-compatible modulemap in one call6cargo run --features=uniffi/cli --bin uniffi-bindgen-swift -- \7 target/release/libmathcore.a out/swift \8 --swift-sources --headers --modulemap --xcframework9 10# Combine the iOS + simulator architectures into an XCFramework11xcodebuild -create-xcframework \12 -library target/aarch64-apple-ios/release/libmathcore.a \13 -headers out/swift \14 -library target/aarch64-apple-ios-sim/release/libmathcore.a \15 -headers out/swift \16 -output MathCore.xcframework1// iOS — using the generated binding2// Added to the generated out/swift/*.swift target; carries the XCFramework's C layer.3let result: Int32 = add(a: 3, b: 4)Worth noting: the Rust-side type names (i32, CalcResult) are automatically translated into each language's counterparts (Int/Int32, data class/struct) on both platforms — no manual syncing, which is exactly where UniFFI earns its keep.
Error and Memory Handling at the FFI Boundary
The boundary between Rust and Kotlin/Swift (the FFI — foreign function interface) is where two memory models meet: Rust's ownership system and Kotlin/Swift's garbage-collector/ARC-based models. UniFFI abstracts this boundary, but knowing how it works matters when debugging.
For errors, UniFFI maps Rust's Result<T, E> type to each language's own error mechanism: an Exception subclass in Kotlin, an Error-conforming enum in Swift. A function returning Err(...) on the Rust side becomes catchable with try/catch in Kotlin and do/catch in Swift — no manual bridging needed.
1#[derive(uniffi::Error, Debug)]2pub enum MathError {3 Overflow,4 DivisionByZero,5}6 7#[uniffi::export]8fn safe_divide(a: i32, b: i32) -> Result<i32, MathError> {9 if b == 0 {10 return Err(MathError::DivisionByZero);11 }12 a.checked_div(b).ok_or(MathError::Overflow)13}For memory management, get the distinction right: Records and Enums cross the boundary by value (copied) — like CalcResult above; only Interface/Object types are carried by reference behind an Arc<>. In the official documentation's words: "Interfaces are passed by reference so can not have data items - unlike a Record or Enum, which are passed by value so only have data fields and no methods." On Swift, ARC releases this reference for you; in Kotlin you must explicitly destroy() every Object instance (or use a .use { } block) — this applies to Objects inside record fields too. Since large data (e.g. image byte arrays) gets copied on every crossing, factor this cost into heavy data-flow scenarios. The version this article was written against (v0.30.0) had no zero-copy byte buffer support; see the "Update" section at the end for the current state.
The Truth About Build Time and Binary Size
Binary size and build time vary from project to project — code size, dependency count, and target-architecture count together determine them. There are four variables you can control:
- cargo-ndk no longer strips by default: with the v4.0.0 (July 30, 2025) breaking change, the tool stopped stripping output by default and the
--no-stripoption was removed; you need to turn on stripping yourself via[profile.release] strip = trueinCargo.toml. - LTO (Link-Time Optimization) is an explicit option: you can make the compiler do cross-crate optimization with
[profile.release] lto = trueinCargo.toml; this generally shrinks the binary but lengthens build time. opt-level = "z": a standard Rust compiler flag that optimizes for size over speed; it can be preferred for modules that aren't performance-critical.- The number of architectures is a direct multiplier: compiling separately for three Android ABIs like
arm64-v8a+armeabi-v7a+x86_64triples the contribution to the total APK/AAB size — the Play Store's App Bundle mechanism reduces this cost at distribution time, but on the build side you still need three separate compilations.
In short: instead of a blanket claim like "Rust binaries are small/large," treat these four control points as variables to measure in your own project.
Setting Up Both Platforms Together in CI
The most practical benefit of a shared core is building both Android and iOS targets from the same Rust source in a single CI pipeline. cargo-ndk was designed for cross-platform CI scenarios — it runs on different operating systems, including Windows, so you can build Android targets regardless of whether your machine is Linux, macOS, or Windows.
A typical CI flow looks like this:
- Install the Rust toolchain — add the Android (
aarch64-linux-android,armv7-linux-androideabi) and iOS (aarch64-apple-ios,aarch64-apple-ios-sim) targets withrustup target add. - Install cargo-ndk — only needed in the Android job; the iOS job can run directly on a macOS runner with
cargo build --target aarch64-apple-ios. - Generate bindings — both jobs run the same
uniffi-bindgencommand, with a different--languageflag. - Packaging — the Android job copies the
.sofiles intojniLibs; the iOS job packages them withxcodebuild -create-xcframework. - Publishing — both outputs are pushed to their own platform's package manager (Maven local/Android Archive, Swift Package).
In practice, the trap is making sure iOS and Android jobs build the Rust source from the same commit — bindings generated from different Rust versions can create subtle behavior differences (especially in error message formatting).
When to Choose KMP or Swift Instead of Rust
A shared Rust core doesn't fit every scenario. Even UniFFI's own maintainers explicitly state the tool is "production-ready but far from 1.0, with internal work still ongoing" — meaning you accept some risk of API breakage.
Compared with the alternatives, the decision points can be summarized like this:
Criterion | Rust + UniFFI | Kotlin Multiplatform (KMP) | Separate native (Swift+Kotlin) |
|---|---|---|---|
Team skillset | Requires Rust knowledge | Kotlin knowledge is enough | Each platform in its own language |
Code sharing | Full (core logic) | Full (core logic) | None |
Ecosystem maturity | Pre-1.0, API can break | Official JetBrains support | Most mature, platform-native |
Typical use case | Crypto/parser/algorithm core | Business logic + partial UI sharing | Small/mid-size single-feature apps |
There's also a bridge to Kotlin Multiplatform within UniFFI's own ecosystem: the third-party "Gobley" project connects UniFFI output directly to KMP targets (JVM + Native) — Rust and KMP aren't mutually exclusive; they can be combined depending on the need.
A Rust core makes sense in these cases:
- A cryptography/security layer — code audited in one place that guarantees identical behavior on both platforms.
- Complex sync/parser algorithms — platform-independent, heavily tested business logic.
- If you already have a Rust team — a team already using Rust on the desktop/backend side avoids duplicating knowledge by moving the mobile core to the same language.
A Rust core does NOT make sense in these cases:
- A small team, a single-platform-first MVP — UniFFI's toolchain setup cost (cargo-ndk, XCFramework, CI matching) loses you time in the short term.
- UI-heavy features — a Rust core doesn't cover the UI layer; in this scenario, KMP's UI-sharing options like
compose-multiplatformmay be more appropriate. - If the team has no Rust experience at all and there's time pressure — the learning curve plus FFI debugging complexity puts your deadline at risk.
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
By the time you finish this article, you'll have set up your own Rust shared core module from scratch. Use the checklist below to confirm you've applied every step we covered; check off each item in order.
FAQ
How do you share common code between iOS and Android with Rust?
You write your platform-independent business logic as a single library on the Rust side, then generate Kotlin and Swift bindings from that library with UniFFI. On Android, cargo-ndk compiles .so files into the jniLibs folder; on iOS, static libraries are packaged into an XCFramework and added to the Xcode project. Your application code (SwiftUI/Compose) calls these generated bindings as if they were an ordinary native library.
What is UniFFI and what does it do?
UniFFI, developed by Mozilla, is a toolset that automatically generates bindings from Rust libraries to languages like Kotlin, Swift, Python, and Ruby. You define the interface either with a .udl file or with proc-macros directly inside the Rust code; UniFFI generates the rest of the JNI/C-ABI bridge code for you. Firefox's mobile and desktop browsers use this tool in production.
Does a Rust mobile core make sense in production?
For platform-independent, heavily tested business logic like cryptography, sync algorithms, or parsers — yes; it guarantees identical behavior on both platforms from a single source. But UniFFI hasn't reached version 1.0 yet, and the maintainers openly describe it as a tool with "ongoing internal work"; on small teams, UI-heavy projects, or time-pressured MVPs, the toolchain setup cost can outweigh the benefit.
Can you compile Rust for Android without cargo-ndk?
Yes, you can compile directly with commands like cargo build --target aarch64-linux-android, but you have to manage the NDK toolchain paths, linker settings, and multi-architecture output by hand. cargo-ndk reduces these steps to a single command — that's why most projects prefer this plugin over compiling by hand. As for symbol stripping, the tool no longer does it for you automatically since v4.0.0; you turn on [profile.release] strip = true yourself.
How does error handling work in UniFFI?
An enum marked with #[derive(uniffi::Error)] on the Rust side is automatically mapped to an Exception subclass in Kotlin and an Error-conforming type in Swift. When a function's return type is Result<T, E>, the foreign side handles it with its own natural error-catching mechanism (try/catch, do/catch); you don't have to translate error codes by hand at the FFI boundary.
Update (September 2026)
The body of this article was written against UniFFI v0.30.0 from October 2025. Over the year since then, the main activity in the UniFFI ecosystem happened across this version chain: v0.32.0 (June 2026), v0.32.1 (September 8, 2026), and v0.32.2 (September 23, 2026 — one day before this article was written). According to the project's own CHANGELOG, the notable changes are:
- v0.32.0 breaking changes: binding generation now errors if Kotlin or Python have an async primary constructor — previously these languages either skipped the ctor or generated one that always threw (Swift is unaffected); the
--configflag moved to the global configuration format; the[ByRef] bytestype now maps directly to&[u8]on the Rust side and directly toByteBufferon the Kotlin side. - v0.32.1: fixed a checksum validation bug on the Kotlin
aarch64target. - Pending on main (not yet released): a change that lets synchronously exported functions borrow a byte buffer owned by the foreign side zero-copy and write into it in place, and an experimental JNI-based Kotlin binding generator (
uniffi-bindgen-kotlin-jni) are in development.
If setting up a new project, run this article's commands against the current version (cargo add uniffi) rather than v0.30.0 directly, and review the v0.32.0 type change if you use [ByRef] bytes. cargo-ndk and AOSP's official Rust documentation saw no new development in this window (October 2025–September 2026) — both remain current as they were when this article was written.
Conclusion
A shared Rust core eliminates the cost of writing the same business logic twice for two platforms — but it isn't free: the UniFFI/cargo-ndk/XCFramework trio saves you from hand-writing JNI/C-ABI bridge code, in exchange for a Rust learning curve and FFI debugging discipline. For critical, platform-independent logic like cryptography, sync, or parsing, this trade-off usually pays off; for small teams and UI-heavy work, a native approach or KMP stays lower-friction.
If you want to go deeper, check out our related articles:
- Mobile microservices architecture — the backend/service-side counterpart of the shared-core approach.
- iOS offline-first architecture — one of the reasons to move the sync layer into a Rust core.
- Flutter iOS platform channels — Flutter's equivalent of building a language bridge at the FFI boundary.
- React Native vs Flutter comparison — a broader look at cross-platform code-sharing options.
- iOS CI/CD pipeline setup — for wiring the cargo-ndk/XCFramework steps in this article into a real CI job.
Sources
- AOSP — Android Rust introduction — the primary AOSP documentation describing Rust's official standing on the Android platform, and the memory-safety and "Fearless Concurrency" rationale.
- mozilla/uniffi-rs — GitHub repository — UniFFI's official source code; its README notes Firefox's production use and its pre-1.0 status.
- UniFFI User Guide (latest) — the official manual comparing the proc-macro and
.udlinterface-definition methods. - UniFFI — Foreign Language Bindings (Tutorial) — the source for setting up
uniffi-bindgenand thegenerate --librarycommand. - bbqsrc/cargo-ndk — GitHub repository — the official repository for the Rust-compilation plugin for the Android NDK.
- cargo-ndk — CHANGELOG — the v4.0.0 (July 30, 2025) breaking change: "No longer strips build output by default, and
--no-stripoption is removed". - Apple Developer — Creating a multiplatform binary framework bundle — Apple's official guide to the XCFramework packaging process.
- UniFFI v0.30.0 release notes — the record for the version tagged and current at the time this article was written (October 2025).
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

