All Articles
Reading Time
15 min read
Published
2025-10-09
Word Count
3,778words

Grab a coffee — this one is a deep dive!

Rust on Mobile: iOS + Android Shared Core with UniFFI

Summary

How do you set up a Rust shared core for mobile development? An end-to-end guide to generating Kotlin/Swift bindings from one Rust source with UniFFI, cargo-ndk, and XCFramework.

  • UniFFI lets you generate Kotlin and Swift bindings from a single Rust library, sharing the same business logic across iOS and Android.
  • The toolchain has three parts: cargo-ndk on Android, XCFramework on iOS, and UniFFI's binding generation for both.
  • Error handling at the FFI boundary automatically maps Result<T,E> to Kotlin Exception / Swift Error — but you must explicitly destroy Object instances on the Kotlin side.
  • UniFFI hasn't reached version 1.0 yet — on small teams or UI-heavy projects, native or KMP usually stays lower-friction.
Rust on Mobile: iOS + Android Shared Core with UniFFI

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

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:

toml
1# Cargo.toml
2[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" feature
12uniffi = { 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:

rust
1// src/lib.rs
2uniffi::setup_scaffolding!();
3 
4#[uniffi::export]
5fn add(a: i32, b: i32) -> i32 {
6 a + b
7}
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):

rust
1// src/bin/uniffi-bindgen.rs
2fn main() {
3 uniffi::uniffi_bindgen_main()
4}

We set up a second binary the same way to generate the Swift header/modulemap:

rust
1// src/bin/uniffi-bindgen-swift.rs
2fn 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:

bash
1# 1) Build the Rust library
2cargo build --release
3 
4# 2) Generate Kotlin bindings from library mode
5cargo run --features=uniffi/cli --bin uniffi-bindgen generate \
6 --library target/release/libmathcore.so \
7 --language kotlin \
8 --out-dir out/kotlin
9 
10# 3) Cross-compile for Android targets with cargo-ndk
11cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \
12 -o app/src/main/jniLibs build --release

On 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:

kotlin
1// Android — using the generated binding
2import uniffi.mathcore.add
3import uniffi.mathcore.CalcResult
4 
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:

bash
1# Add and cross-compile the iOS targets
2rustup target add aarch64-apple-ios aarch64-apple-ios-sim
3cargo build --release --target aarch64-apple-ios && cargo build --release --target aarch64-apple-ios-sim
4 
5# Generate Swift sources + header + XCFramework-compatible modulemap in one call
6cargo run --features=uniffi/cli --bin uniffi-bindgen-swift -- \
7 target/release/libmathcore.a out/swift \
8 --swift-sources --headers --modulemap --xcframework
9 
10# Combine the iOS + simulator architectures into an XCFramework
11xcodebuild -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.xcframework
swift
1// iOS — using the generated binding
2// 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.

rust
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-strip option was removed; you need to turn on stripping yourself via [profile.release] strip = true in Cargo.toml.
  • LTO (Link-Time Optimization) is an explicit option: you can make the compiler do cross-crate optimization with [profile.release] lto = true in Cargo.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_64 triples 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:

  1. Install the Rust toolchain — add the Android (aarch64-linux-android, armv7-linux-androideabi) and iOS (aarch64-apple-ios, aarch64-apple-ios-sim) targets with rustup target add.
  2. 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.
  3. Generate bindings — both jobs run the same uniffi-bindgen command, with a different --language flag.
  4. Packaging — the Android job copies the .so files into jniLibs; the iOS job packages them with xcodebuild -create-xcframework.
  5. 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-multiplatform may 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 --config flag moved to the global configuration format; the [ByRef] bytes type now maps directly to &[u8] on the Rust side and directly to ByteBuffer on the Kotlin side.
  • v0.32.1: fixed a checksum validation bug on the Kotlin aarch64 target.
  • 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:

Sources

Tags

#Rust#UniFFI#cargo-ndk#XCFramework#iOS#Android#FFI#Cross-Platform
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

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.

Share