How to Share Rust Code Between Mobile Platforms

Share Rust code between mobile platforms by creating a library crate, adding Android and iOS targets, and compiling for each specific architecture.

Share Rust code between mobile platforms

You wrote a blazing-fast image filter in Rust. It processes frames in milliseconds. Now your product manager wants it in the Android app and the iOS app. You stare at two separate codebases. Kotlin on one side, Swift on the other. Rewriting the filter twice feels like a waste. You want to write the logic once in Rust and call it from both platforms.

Rust makes this possible. You compile your Rust code into a native library and link it into your mobile projects. The mobile apps call into your Rust code through a Foreign Function Interface (FFI) layer. You write the heavy logic in Rust. Swift and Kotlin handle the UI. The result is a single source of truth for your core features.

The engine and the chassis

Rust compiles to native machine code. Android runs on Linux kernels. iOS runs on Darwin. They speak different assembly languages and have different calling conventions. You cannot drop a Rust binary into an Xcode project and expect it to work. You need to compile Rust specifically for each platform and then build a bridge so Swift and Kotlin can talk to your Rust code.

Think of Rust as a high-performance engine. Android and iOS are different car chassis. You cannot bolt the engine directly onto the chassis without a custom adapter plate. That adapter is the FFI layer. The engine provides the power. The adapter makes sure the spark plugs and fuel lines connect correctly.

Rust also needs to know which chassis it is building for. The compiler generates different instructions for an ARM processor on Android versus an ARM processor on iOS. These differences are captured in "target triples." A target triple tells the compiler the architecture, the vendor, the operating system, and the environment.

Start with a library crate

Mobile apps link against libraries, not executables. You need a Rust library crate to hold your shared logic. A library crate produces a .so file for Android and a .a or .framework for iOS.

Create the crate with the --lib flag. This sets up the project structure for a library.

cargo new --lib shared_logic

Convention aside: The community always uses library crates for mobile interop. Binary crates produce executables that mobile build systems cannot consume. Stick to --lib.

Add your Rust code to src/lib.rs. Keep the public API clean. The functions you expose are the contract between Rust and the mobile side.

// src/lib.rs

/// Adds two integers.
/// This function will be called from Kotlin or Swift.
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

/// Processes a slice of data.
/// Returns the length of the processed output.
pub fn process_data(input: &[u8]) -> usize {
    input.len()
}

Install the mobile targets

Your default Rust toolchain compiles for your development machine. You need to add the Android and iOS targets to cross-compile for phones.

Use rustup to install the targets. Modern phones use 64-bit ARM processors. The target triple aarch64 represents ARM 64-bit.

rustup target add aarch64-linux-android aarch64-apple-ios

The Android target is aarch64-linux-android. The iOS target is aarch64-apple-ios. These triples encode the architecture, the OS, and the environment. If you try to build for iOS without the target installed, the compiler rejects you with a "target not installed" error.

Convention aside: Install aarch64-apple-ios-sim if you test on the iOS simulator. The simulator runs on your Mac's architecture, not ARM. Building for the device requires the real aarch64-apple-ios target. Mixing them up causes linker errors when you switch between simulator and device.

Build for the target

Once the targets are installed, you can build the library for a specific platform. Pass the --target flag to cargo build.

cargo build --target aarch64-linux-android --release

The --release flag is mandatory for mobile. Debug builds include profiling information and omit optimizations. They are huge and slow. Mobile app stores reject apps with debug binaries, and users will notice the performance drop. Always build with --release.

The output lands in target/aarch64-linux-android/release/. You will find a .rlib file or a .so file depending on your crate type. Mobile build systems expect specific formats. You may need to adjust Cargo.toml to produce a dynamic library.

[lib]
crate-type = ["cdylib"]

Setting crate-type to cdylib tells Rust to produce a C-compatible dynamic library. This is the format Android and iOS linkers expect. Without this, you get a Rust static library that mobile tools cannot consume.

The binding layer

Compiling the library is only half the battle. Swift and Kotlin cannot call Rust functions directly. They expect C-style interfaces. You need a binding generator to create the glue code.

The binding generator reads your Rust code and produces Kotlin and Swift files. These files define the classes and methods that mobile developers use. Under the hood, the generated code calls into your Rust library via FFI.

Tools like uniffi automate this process. You write a Rust IDL (Interface Definition Language) file or use procedural macros. The tool generates the bindings for Kotlin and Swift. You drop the generated files into your mobile project and link the Rust library.

If you prefer manual control, you can write extern "C" functions yourself. This requires more boilerplate but gives you absolute control over the ABI.

Realistic example with manual bindings

Here is how a manual FFI bridge looks. This example shows the mechanics of exposing Rust to C-compatible languages. Mobile binding generators often produce code similar to this.

// src/lib.rs

use std::mem::ManuallyDrop;

/// Internal Rust function with safe types.
/// This logic is hidden from the mobile side.
fn internal_process(data: &[u8]) -> Vec<u8> {
    // Simulate processing.
    data.iter().map(|&b| b.wrapping_add(1)).collect()
}

/// C-compatible interface for mobile bindings.
/// Mobile languages expect C-style functions with raw pointers.
#[no_mangle]
pub extern "C" fn rust_process(data: *const u8, len: usize) -> *mut u8 {
    // SAFETY: Caller must provide a valid pointer and length.
    // We trust the binding generator to enforce this.
    // The mobile side must call rust_free on the result.
    unsafe {
        if data.is_null() {
            return std::ptr::null_mut();
        }

        let slice = std::slice::from_raw_parts(data, len);
        let result = internal_process(slice);

        // Leak the vector to return a raw pointer.
        // The vector's memory stays alive until rust_free is called.
        let mut v = ManuallyDrop::new(result);
        v.as_mut_ptr()
    }
}

/// Frees memory allocated by rust_process.
/// Mobile side must call this to prevent leaks.
#[no_mangle]
pub extern "C" fn rust_free(ptr: *mut u8, len: usize) {
    // SAFETY: Caller must provide a pointer returned by rust_process.
    // We reconstruct the Vec to drop it properly.
    unsafe {
        if ptr.is_null() {
            return;
        }
        // Reconstruct the Vec to trigger the drop.
        let _vec = Vec::from_raw_parts(ptr, len, len);
    }
}

The #[no_mangle] attribute prevents Rust from changing the function name. C and mobile languages expect exact names. Without it, the linker cannot find your function.

The extern "C" keyword sets the calling convention to C. Rust uses its own calling convention by default. Mixing conventions causes stack corruption and crashes.

Memory management is manual here. Rust owns memory until the value is dropped. When you return a pointer to the mobile side, Rust cannot drop the memory automatically. You must provide a free function. The mobile side calls rust_free when it is done. Forgetting to call free leaks memory. Calling it twice causes a double-free crash.

Convention aside: The community calls this the "leak and free" pattern. You leak the allocation in Rust and free it later. Binding generators often wrap this in safe classes so Kotlin and Swift developers don't see raw pointers.

Pitfalls and compiler errors

FFI introduces risks that the borrow checker cannot catch. The compiler protects you inside Rust. It cannot protect you across the FFI boundary.

If you pass a Rust String to an extern "C" function, the ABI breaks. C does not understand Rust's string layout. You get a crash, not a compile error. Always use C-compatible types: integers, floats, pointers, and slices represented as pointer plus length.

The compiler rejects raw pointer dereferences outside unsafe blocks with E0133 (dereference of raw pointer requires unsafe). This is a good sign. It forces you to acknowledge the risk.

If you mismatch types, you get E0308 (mismatched types). This happens when you try to pass a u32 where a *const u8 is expected. Fix the types before you cross the boundary.

Android builds can fail due to NDK version mismatches. The Rust target expects a specific NDK version. If your Android project uses a newer NDK, the linker may complain about missing symbols. Align the NDK versions or use a tool like cargo-ndk to manage the complexity.

iOS builds can fail if you mix simulator and device architectures. Building for aarch64-apple-ios produces code for real devices. Building for the simulator requires aarch64-apple-ios-sim. Xcode rejects universal binaries that contain incompatible architectures. Build separate libraries for simulator and device, or use a tool that handles the fat binary creation.

Memory leaks are silent killers. If you allocate memory in Rust and forget to expose a cleanup function, the memory stays allocated until the app closes. Mobile garbage collectors do not know about Rust allocations. Write the cleanup function first. Test the lifecycle before you write the allocation.

Decision matrix

Use uniffi when you want to generate Kotlin and Swift bindings automatically from a Rust IDL. It handles the boilerplate and keeps your API clean.

Use cxx when your mobile app is already written in C++ or you need tight integration with existing C++ libraries. It provides safe Rust bindings to C++ headers.

Use raw extern "C" functions only when you are building a custom binding generator or need absolute control over the ABI. Reach for a higher-level tool otherwise.

Use cargo-ndk when building for Android to handle the complex NDK setup and multi-architecture builds. It simplifies the build process and produces the correct .so files.

Use aarch64-apple-ios-sim when testing on the iOS simulator. Use aarch64-apple-ios when building for real devices. Never mix them in the same binary.

Where to go next