Skip to content
UniFFI: Kotlin Bindings for Rust Without Writing JNI by Hand
rust-native

UniFFI: Kotlin Bindings for Rust Without Writing JNI by Hand

By David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)

Updated 7 August 202611 min read
AndroidRustNDKUniFFIKotlinFFI
Share:

Rust + NDK on Android, Part 3 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Previous: Calling Rust from Kotlin: JNI Basics for Android Developers. Next: Project Structure: Gradle + Cargo in One Build, ABI Splits, Debug/Release Flavors.

You don’t have to write another Java_dev_davthecoder_rustseries_... symbol by hand to call Rust from Kotlin. UniFFI, Mozilla’s binding generator, reads annotations on your Rust code and generates the entire Kotlin side of the bridge: data classes for your structs, enum classes for your enums, a sealed exception hierarchy for your error types. Functions come out as plain camelCase Kotlin that throws a typed exception wherever the Rust returns a Result. You write the Rust and run one generator, and the border paperwork from Part 2 is gone.

In Part 2 I made you write that paperwork on purpose, because you can’t debug a bridge you’ve never seen. This part solves the same problem the comfortable way: we annotate a small expense parser crate with UniFFI’s proc macros, generate the Kotlin bindings, wire generation into the Gradle build, and then compare the two approaches without romance. This assumes the toolchain from Part 1. Everything below is pinned to uniffi 0.29.

Key Takeaways

  • UniFFI generates the whole Kotlin side of a Rust API: records become data classes, enums become enum classes, error enums become sealed exception hierarchies, and Result returns become thrown exceptions.
  • No UDL file is required anymore. #[uniffi::export], three derives and uniffi::setup_scaffolding!() describe functions, records, enums and errors directly in the Rust source (UniFFI proc macro docs).
  • The generated Kotlin calls Rust through JNA, version 5.12.0 or newer, and every argument is serialized across the boundary, so hot per-frame call paths remain hand-written JNI territory (UniFFI Kotlin docs).
  • From here the series uses raw JNI where the point is to see the machinery, and recommends UniFFI for production apps with a wide API surface.

What UniFFI generates and why it exists

The three functions in Part 2 cost roughly sixty lines of glue: a mangled symbol per function, two conversions per string, and a manual repack of every return value. That tax is flat per function, and the glue is exactly where JNI crashes live. Now scale it to a real product: forty functions, a dozen types, and an iOS app that wants the same core through Swift. Hand-written JNI grows linearly in the worst currency there is: unsafe boilerplate a reviewer must re-verify on every change.

UniFFI is Mozilla’s answer to that bill. It was built to ship Rust components inside Firefox for Android and iOS, and several apps in the production table of the hub post cross the same bridge: Element X drives its whole Matrix engine through UniFFI-generated Kotlin and Swift, and Wire and Bitwarden ship their crypto cores the same way. This is not a weekend project you’re betting your app on.

Two halves get generated. On the Rust side, the macros expand into scaffolding: C ABI functions that receive serialized arguments, call your real functions, and serialize the results back. On the Kotlin side, a generator called uniffi-bindgen writes one Kotlin file containing your types plus all the deserialization code, and it loads your .so through JNA the first time you touch the API. Between the halves sits a small serialization protocol: primitives cross directly, everything else is packed into a byte buffer on one side and unpacked on the other. It’s plain code generation all the way down, which is what makes the costs predictable.

The Rust side: one macro and three derives

The crate lives at rust/uniffi-demo and stands alone for now; Part 4 moves the series crates into one Cargo workspace and adjusts the paths. Here is the complete rust/uniffi-demo/Cargo.toml:

[package]
name = "uniffi-demo"
version = "0.1.0"
edition = "2021"

[lib]
name = "uniffi_demo"
crate-type = ["cdylib", "lib"]

[[bin]]
name = "uniffi-bindgen"
path = "uniffi-bindgen.rs"

[dependencies]
uniffi = { version = "0.29", features = ["cli"] }

cdylib produces the .so for Android, and lib lets tests and the binary target link against the crate. The odd part is that [[bin]] section: the crate ships its own copy of the binding generator, and the whole of rust/uniffi-demo/uniffi-bindgen.rs is this:

fn main() {
    uniffi::uniffi_bindgen_main()
}

The reason is version discipline: the generated Kotlin and the uniffi runtime compiled into your .so must come from the same release, and building the generator from the same Cargo.lock makes drift impossible.

Now the API itself, the complete rust/uniffi-demo/src/lib.rs. It parses expense lines like "Lidl;12.99;groceries" into a typed record:

uniffi::setup_scaffolding!();

#[derive(uniffi::Record)]
pub struct Expense {
    pub merchant: String,
    pub cents: i64,
    pub category: Category,
}

#[derive(uniffi::Enum)]
pub enum Category {
    Groceries,
    Transport,
    Other,
}

#[derive(Debug, uniffi::Error)]
pub enum ParseError {
    Empty,
    BadAmount { raw: String },
}

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ParseError::Empty => write!(f, "input line was empty"),
            ParseError::BadAmount { raw } => write!(f, "could not parse amount: {raw}"),
        }
    }
}

impl std::error::Error for ParseError {}

#[uniffi::export]
pub fn parse_expense(line: String) -> Result<Expense, ParseError> {
    let line = line.trim();
    if line.is_empty() {
        return Err(ParseError::Empty);
    }
    let mut parts = line.split(';');
    let merchant = parts.next().unwrap_or("").trim().to_string();
    let raw_amount = parts.next().unwrap_or("").trim();
    let cents = parse_cents(raw_amount).ok_or(ParseError::BadAmount {
        raw: raw_amount.to_string(),
    })?;
    let category = match parts.next().map(|p| p.trim().to_ascii_lowercase()).as_deref() {
        Some("groceries") => Category::Groceries,
        Some("transport") => Category::Transport,
        _ => Category::Other,
    };
    Ok(Expense { merchant, cents, category })
}

#[uniffi::export]
pub fn total_cents(expenses: Vec<Expense>) -> i64 {
    expenses.iter().map(|e| e.cents).sum()
}

fn parse_cents(raw: &str) -> Option<i64> {
    let (whole, frac) = match raw.split_once('.') {
        Some((whole, frac)) => (whole, frac),
        None => (raw, ""),
    };
    if whole.is_empty() || whole.starts_with('-') {
        return None;
    }
    let whole: i64 = whole.parse().ok()?;
    let frac: i64 = match frac.len() {
        0 => 0,
        1 => frac.parse::<i64>().ok()? * 10,
        2 => frac.parse().ok()?,
        _ => return None,
    };
    Some(whole * 100 + frac)
}

Four annotations carry the entire interface. uniffi::setup_scaffolding!() at the top replaces the UDL file and build.rs step that older tutorials revolve around; when your whole API is described by macros, it’s the only setup you need (UniFFI proc macro docs). #[derive(uniffi::Record)] marks a plain data holder that crosses the boundary by value, and #[derive(uniffi::Enum)] does the same for the enum inside it.

The error type earns the most attention. UniFFI requires error enums to implement std::error::Error (UniFFI error docs), which is why the two small impl blocks are there: Display provides the exception message Kotlin will see, and the variant fields, like raw, cross the boundary as typed properties. Returning Result<Expense, ParseError> is the complete error story on the Rust side. You never touch a JNIEnv and you never throw an exception by hand, so there is nothing to get wrong at 2am. What happens when Rust panics instead of returning an Err is a different story, and it gets its own post, Error Handling and Panics: Keeping Rust Crashes Out of Your Android App.

Notice what’s absent. There is no symbol naming rule anywhere in this file. Rename the Kotlin package tomorrow and not one line of Rust changes.

The generated Kotlin and how it reads

One small config file controls the Kotlin output. This is rust/uniffi-demo/uniffi.toml:

[bindings.kotlin]
package_name = "dev.davthecoder.rustseries.uniffi"

Build the .so files exactly as in Part 1, then point the generator at the compiled library. This is called library mode: the generator reads metadata the macros embedded in the binary, so it never parses your source:

cd rust/uniffi-demo
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \
  -o ../../app/src/main/jniLibs build --release
cargo run --bin uniffi-bindgen -- generate \
  --library target/aarch64-linux-android/release/libuniffi_demo.so \
  --language kotlin \
  --out-dir ../../app/build/generated/uniffi

Out comes a single file, uniffi_demo.kt, in the package you configured. It’s a few hundred lines of serialization code you never edit, and its public surface maps one to one onto the Rust: a data class Expense, an enum class Category with GROCERIES, TRANSPORT and OTHER, and a sealed ParseException hierarchy, since UniFFI renames a Rust FooError to a Kotlin FooException. The two functions arrive as parseExpense, marked @Throws(ParseException::class), and totalCents. There’s no System.loadLibrary call for you to write either: the generated file loads libuniffi_demo.so itself.

Here’s the consuming side, app/src/main/java/dev/davthecoder/rustseries/ExpenseDemo.kt:

package dev.davthecoder.rustseries

import dev.davthecoder.rustseries.uniffi.Category
import dev.davthecoder.rustseries.uniffi.Expense
import dev.davthecoder.rustseries.uniffi.ParseException
import dev.davthecoder.rustseries.uniffi.parseExpense
import dev.davthecoder.rustseries.uniffi.totalCents

object ExpenseDemo {

    fun run(): String {
        val groceries: Expense = parseExpense("Lidl;12.99;groceries")
        val transport: Expense = parseExpense("BVG;3.80;transport")

        check(groceries.category == Category.GROCERIES)
        check(totalCents(listOf(groceries, transport)) == 1679L)

        return try {
            parseExpense("Lidl;twelve;groceries")
            "unreachable"
        } catch (e: ParseException.BadAmount) {
            "rejected amount: ${e.raw}"
        } catch (e: ParseException) {
            "parse failed: ${e.message}"
        }
    }
}

That catch block is the part hand-written JNI never gives you for free. ParseException.BadAmount is a real type with a real raw property, so the compiler checks your error handling, and when over the sealed hierarchy is exhaustive. In Part 2 the equivalent was a returned sentinel value and a prayer.

Wiring UniFFI into an Android Gradle build

The bindings need JNA at runtime, version 5.12.0 or newer per the UniFFI Kotlin docs. Add it to the dependencies block of app/build.gradle.kts:

dependencies {
    implementation("net.java.dev.jna:jna:5.12.0@aar")
}

Then wire two Exec tasks into the same file: one builds the .so files with cargo-ndk, the next generates the bindings from the arm64 library, and both hang off preBuild so a plain ./gradlew assembleDebug does everything. Generating into build/generated and registering that directory as a source root keeps generated code out of src/ and out of git:

val rustCrateDir = rootProject.file("rust/uniffi-demo")
val uniffiOutDir = layout.buildDirectory.dir("generated/uniffi")

val cargoNdkBuild = tasks.register<Exec>("cargoNdkBuild") {
    workingDir(rustCrateDir)
    commandLine(
        "cargo", "ndk",
        "-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64", "-t", "x86",
        "-o", project.file("src/main/jniLibs").absolutePath,
        "build", "--release",
    )
}

val generateUniffiBindings = tasks.register<Exec>("generateUniffiBindings") {
    dependsOn(cargoNdkBuild)
    workingDir(rustCrateDir)
    commandLine(
        "cargo", "run", "--bin", "uniffi-bindgen", "--",
        "generate",
        "--library", "target/aarch64-linux-android/release/libuniffi_demo.so",
        "--language", "kotlin",
        "--out-dir", uniffiOutDir.get().asFile.absolutePath,
    )
}

tasks.named("preBuild") {
    dependsOn(generateUniffiBindings)
}

android {
    sourceSets {
        getByName("main") {
            java.srcDir(uniffiOutDir)
        }
    }
}

This is the blunt version. It shells out to cargo on every build and knows nothing about build types or flavors; cargo’s own caching keeps the no-change case quick, but Gradle can’t skip the tasks because they declare no inputs or outputs. Making Gradle and Cargo behave like one incremental build, with debug and release profiles mapped properly and ABI splits for Play, is the whole of Part 4, Project Structure: Gradle + Cargo in One Build, ABI Splits, Debug/Release Flavors.

UniFFI vs hand-written JNI: where each wins

You’ve now shipped the same shape of API both ways, so this comparison can be concrete instead of tribal.

Hand-written JNI (Part 2) UniFFI (this post)
Cost per function Manual symbol, conversions, checks One annotation
Rich types Manual marshalling both directions Records, enums, errors generated
Error handling Sentinels or hand-thrown exceptions Typed sealed exceptions
Call overhead Direct call, primitives cross free Serialization plus JNA dispatch
Zero-copy options Direct ByteBuffers, array pinning None, the protocol copies
Extra dependencies None beyond your .so JNA plus generated runtime
Other platforms Rewrite per language Swift and Python from the same crate

The overhead row deserves honesty. Every UniFFI call packs its arguments into a buffer, crosses through JNA, and unpacks on the far side, and the return value makes the same trip in reverse. For a login call, a parser, or a sync engine you will never feel it. For a function called per frame with a bitmap-sized payload, you will. I’m not going to invent numbers here; the series puts real measurements on the boundary in Fast Image Processing on Android with Rust: Bitmaps, Filters, and the JNI Fast Path. Until then the working rule is simple: coarse calls belong to UniFFI, hot loops belong to JNI you control. Zero-copy follows the same logic: raw JNI offers direct ByteBuffer access and array pinning, covered in Passing Data Between Kotlin and Rust: Strings, Arrays, ByteBuffers, and Zero-Copy, while UniFFI’s protocol copies by design.

Size and coupling are the quieter costs. UniFFI adds the JNA native library to every ABI you ship plus the generated runtime code, where hand JNI adds nothing beyond your own .so. And the generated bindings are welded to the uniffi version in your Cargo.lock: bump the crate and you must regenerate, which the Gradle wiring above does for you on every build anyway.

For a real product with a wide API, that ledger still tilts hard toward UniFFI. You give up a copy cost you mostly can’t measure and get back compiler-checked errors, far less unsafe glue, and Swift bindings from the same annotations. So here is the convention for the rest of this series. Teaching posts stay on raw JNI, because the machinery is the lesson. Posts about squeezing the boundary also stay on JNI, because control is the point. For a production app with more than a handful of exported functions, use UniFFI.

Frequently Asked Questions

Do I need a UDL file to use UniFFI on Android?

No. Older tutorials are built around a .udl interface file and a build.rs scaffolding step, but the proc macro interface covers functions, records, enums, errors and objects, and uniffi::setup_scaffolding!() replaces the build script entirely (UniFFI proc macro docs). UDL remains supported, and a few features still appear there first, but a new Android project doesn’t need it.

Does UniFFI use JNI under the hood?

Indirectly. The generated Kotlin reaches your Rust through JNA, which itself rides on JNI to call native code (UniFFI Kotlin docs). You write none of that layer, but it exists, and it’s the reason a UniFFI call costs more than the direct hand-written JNI call from Part 2.

Is UniFFI production ready for Android apps?

Yes. Mozilla built it to ship Rust components in Firefox for Android and iOS, and Element X, Wire and Bitwarden reach their Rust cores through UniFFI-generated bindings; the production table in the hub post links the engineering sources. The generated Kotlin is also plain, readable code you can step through in a debugger.

Can UniFFI generate suspend functions for Kotlin coroutines?

Yes. An async fn behind #[uniffi::export] becomes a Kotlin suspend function, with a kotlinx-coroutines dependency on the Kotlin side. Runtimes, threading and calling back into Kotlin from Rust are a topic of their own, covered in Threading and Async: Tokio, Kotlin Coroutines, and Calling Back Into Kotlin from Rust.

Where this leaves you

You’ve now built the same bridge twice: by hand in Part 2, and generated here from four annotations. It’s the same .so, built by the same cargo-ndk invocation from Part 1. What changed is who writes the glue, and how many mistakes that glue can contain.

The wiring above works, but it rebuilds too eagerly and knows nothing about flavors. Part 4, Project Structure: Gradle + Cargo in One Build, ABI Splits, Debug/Release Flavors, turns it into a build you can live with every day. And if you landed here from a search result rather than from Part 1, the hub post has the map of the whole series.

Sources

Share:

Comments

Loading comments…