When mobile needs your Rust logic
You spent three weeks building a blazing-fast encryption library in Rust. The benchmarks look great. The tests pass. Now your product manager drops a message: "We need this on iOS and Android by Friday." You stare at the screen. Rewriting the logic in Swift and Kotlin is out of the question. You could write a C header and wrestle with extern "C", but memory management across the FFI boundary feels like juggling chainsaws. You need a bridge that handles the heavy lifting so you can focus on the logic.
How UniFFI bridges the gap
UniFFI is a code generator that builds safe bridges between Rust and mobile languages. Think of it like a universal power adapter for your code. You plug your Rust logic into one side, and UniFFI generates the correct plug shape for Swift, Kotlin, or Python on the other side. It handles the voltage conversion and ensures you do not blow a fuse.
You define your API once in a simple description file, and UniFFI writes the glue code for every platform. The generator creates idiomatic classes and functions in the target language. Swift developers get native classes with proper error handling. Kotlin developers get interfaces that follow Android conventions. You write Rust code once, and the bindings feel native on every platform.
Minimal setup
Start by adding UniFFI to your project. You need the crate for runtime support and the build feature for code generation.
[dependencies]
uniffi = "0.28" // Runtime support for the generated bindings
[build-dependencies]
uniffi = { version = "0.28", features = ["build"] } // Build-time code generation
Create a build script to generate the Rust scaffolding. This script runs during compilation and reads your API definition.
// build.rs
fn main() {
// Generate Rust scaffolding from the UDL file during compilation
uniffi::generate_scaffolding("src/my_api.udl").unwrap();
}
Define your API in a Universal Definition Language file. This file describes the interface without implementation details.
// src/my_api.udl
namespace my_api {
// Declare the function to be exported
i32 add(i32 a, i32 b);
};
Implement the logic in Rust and include the generated scaffolding. The macro injects the FFI glue code into your library.
// src/lib.rs
// Include the generated scaffolding to expose the FFI interface
uniffi::include_scaffolding!("my_api");
/// Calculate the sum of two numbers.
/// This function is exposed to mobile platforms via the UDL definition.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Generate the mobile bindings using the CLI tool. This produces the Swift or Kotlin files you drop into your mobile project.
# Generate Swift bindings
cargo run --bin uniffi-bindgen generate --config src/my_api.udl --language swift
# Generate Kotlin bindings
cargo run --bin uniffi-bindgen generate --config src/my_api.udl --language kotlin
What happens under the hood
When you run cargo build, the build script reads my_api.udl. It generates a Rust file containing the FFI glue code. This glue code converts Rust types to C-compatible types and back. The include_scaffolding! macro injects this code into your library. The result is a dynamic library with a standard entry point that mobile code can call.
UniFFI handles the marshalling automatically. You pass an i32 from Swift, UniFFI converts it to Rust's i32, calls your function, converts the result back, and returns it to Swift. The mobile side never sees raw pointers or manual memory management. UniFFI wraps everything in safe abstractions.
Convention aside: The community often debates UDL versus proc-macros. UniFFI supports both workflows. The UDL workflow uses a separate description file. The proc-macro workflow lets you annotate Rust code directly with attributes. UDL remains the standard for large teams because it forces a clear separation between the interface contract and the implementation. Proc-macros reduce duplication but can scatter the API definition across multiple files. Pick the style that matches your team's preference for explicit contracts versus concise code.
Define the interface clearly. Implement the logic cleanly. Let the generator handle the rest.
Real-world API with objects and errors
Real applications need more than simple functions. You need objects with state and error handling. UniFFI supports structs, enums, and results out of the box.
Define a wallet interface with a constructor, methods, and an error type. The UDL file declares the structure.
// src/wallet.udl
namespace wallet {
// Define the error type
enum WalletError {
"InsufficientFunds",
"InvalidAddress",
};
// Define the object interface
interface Wallet {
constructor();
void deposit(f64 amount);
f64 withdraw(f64 amount) raises(WalletError);
};
};
Implement the wallet in Rust. Mark the error type and the object so UniFFI knows how to wrap them.
// src/lib.rs
// Include the generated scaffolding
uniffi::include_scaffolding!("wallet");
/// Represents a transaction error.
/// UniFFI maps this to a Swift Error or Kotlin Exception.
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum WalletError {
#[error("Insufficient funds")]
InsufficientFunds,
#[error("Invalid address")]
InvalidAddress,
}
/// A simple wallet that tracks balance.
/// UniFFI creates a reference-counted wrapper for this object.
#[derive(uniffi::Object)]
pub struct Wallet {
balance: f64,
}
#[uniffi::Object]
impl Wallet {
/// Create a new wallet with zero balance.
#[uniffi::Constructor]
pub fn new() -> Self {
Wallet { balance: 0.0 }
}
/// Add funds to the wallet.
pub fn deposit(&mut self, amount: f64) {
self.balance += amount;
}
/// Withdraw funds, failing if balance is too low.
pub fn withdraw(&mut self, amount: f64) -> Result<f64, WalletError> {
if self.balance < amount {
return Err(WalletError::InsufficientFunds);
}
self.balance -= amount;
Ok(amount)
}
}
The generated Swift code gives you a Wallet class. You instantiate it with Wallet(). You call wallet.deposit(amount: 100.0). The Swift compiler sees this as a native method call. Under the hood, UniFFI marshals the arguments, calls into the Rust library via a C function pointer, and returns the result. If Rust returns an error, UniFFI throws a Swift error. You handle it with do { try ... } catch.
UniFFI manages object lifetimes automatically. The mobile side holds a handle to the Rust object. UniFFI uses reference counting to track usage. When the Swift object is deallocated, UniFFI drops the Rust object. This prevents use-after-free bugs without manual cleanup code.
Convention aside: Use #[uniffi::Object] to mark structs that need reference counting. UniFFI wraps these objects in Rc internally. If you need thread safety, add the thread_safe attribute. This switches the wrapper to Arc. Mark objects as thread-safe when they cross thread boundaries.
UniFFI turns your Rust structs into mobile objects without you touching a single pointer. Define the interface, implement the logic, and let the generator handle the rest.
Pitfalls and gotchas
UniFFI hides most complexity, but a few traps remain. Thread safety is the biggest one. UniFFI wraps objects in Rc by default. Rc is not thread-safe. If you pass a UniFFI object to a background thread in Swift and try to use it, the runtime panics with a thread-safety violation. Add #[uniffi::Object(thread_safe)] to enable cross-thread access. This changes the internal wrapper to Arc.
Data transfer size matters. The FFI boundary involves serialization and deserialization. Passing megabytes of data through UniFFI slows down your app. Pass handles or streams instead of large buffers. Keep payloads small.
Error handling requires care. UniFFI maps Rust Result types to platform-specific errors. Swift gets throws functions. Kotlin gets exceptions. Make sure your mobile code handles these errors. Unhandled errors crash the app.
Testing bindings is essential. Use the uniffi test harness to generate a test binary. It calls your bindings from a mock environment. This catches marshalling bugs before you deploy to a device. Run the tests on every platform you support.
Trust the borrow checker. It usually has a point. If UniFFI rejects your code, check your lifetimes and mutability. The generator enforces strict rules to keep the bridge safe.
When to use UniFFI
Use UniFFI when you need safe, idiomatic bindings for Swift, Kotlin, or Python and want to avoid manual glue code. Use UniFFI when your API involves complex types like structs, enums, or errors that need automatic mapping. Use UniFFI when you want a single source of truth for your interface definition. Reach for raw FFI when you are building a library that must interoperate with C code directly and cannot depend on a code generator. Reach for opaque pointers when you need maximum control over the FFI boundary and are willing to write manual marshalling logic. Pick a different framework like autocxx when you are wrapping an existing C++ codebase rather than writing Rust from scratch.