Bridging Rust and Kotlin Multiplatform: Patterns for Safe, High‑Performance Mobile and Embedded Components

Bridging Rust and Kotlin Multiplatform is an increasingly popular approach to shipping high‑performance, safe mobile and embedded components—this article outlines practical interop strategies, ownership contracts, and CI practices to ship mixed Rust/Kotlin apps reliably.

Why combine Rust and Kotlin Multiplatform?

Rust provides memory safety, predictable performance, and a small runtime—ideal for compute‑intensive logic and low‑level drivers. Kotlin Multiplatform (KMP) enables sharing business logic across Android, iOS, and other platforms with idiomatic APIs. Combining the two lets teams write safety‑critical or performance hotspots in Rust and expose them to Kotlin consumers with minimal duplication.

High‑level interop patterns

Choose an interop pattern that matches the platform and runtime: JNI/native libraries for Android/JVM, C ABI for Kotlin/Native (iOS), and thin adapter layers where necessary.

1. Android (JVM) — JNI wrapper layer

  • Build Rust as a dynamic library (.so) per ABI (armv7, arm64, x86_64).
  • Expose a C ABI (extern “C”) or generate JNI functions using the jni crate to call into Rust directly from Kotlin/Android.
  • Keep a thin Java/Kotlin wrapper to convert JVM types to C-friendly representations.
// Rust: simple C API exported for JNI or C wrapper
#[no_mangle]
pub extern "C" fn compute_checksum(ptr: *const u8, len: usize) -> u64 {
    let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
    // fast checksum...
    crc::crc64::checksum_ecma(slice)
}

2. iOS / Kotlin/Native — C ABI + cinterop

  • Compile Rust into a staticlib (.a) or framework exposing a C header (use cbindgen to generate headers).
  • Use Kotlin/Native cinterop to create Kotlin bindings for C symbols; call Rust directly from shared Kotlin code.
  • Prefer stable, C‑friendly types (primitive ints, pointers to opaque structs).

3. Shared approach — thin platform adapters

Maintain a small platform adapter layer per platform that translates platform types and threading models, keeping the Rust boundary simple and versioned.

Ownership contracts and memory safety

Clearly document and enforce ownership contracts across the FFI boundary to avoid leaks, double frees, and use‑after‑free bugs.

  • Opaque handles: Export and accept opaque pointers (e.g., Box → *mut T). Provide explicit ctor/dtor functions that Kotlin must call to free resources.
  • Transfer semantics: Always define whether a pointer is owned by caller or callee. Use names like take_* / give_* to signal ownership transfer.
  • Borrowed slices: When passing buffers, pass pointer+length and document that Rust will not retain the pointer beyond the call (or provide a copy/owned API).
  • Threading rules: Specify whether handles are Send/Sync; for non‑Send types require operations to be performed on a specified thread or via explicit executor functions.
// Rust: returning an owned handle, Kotlin must call free_handle
#[repr(C)]
pub struct MyOpaque { data: Vec }

#[no_mangle]
pub extern "C" fn my_create() -> *mut MyOpaque {
    Box::into_raw(Box::new(MyOpaque { data: vec![] }))
}

#[no_mangle]
pub extern "C" fn my_free(ptr: *mut MyOpaque) {
    if !ptr.is_null() { unsafe { Box::from_raw(ptr); } }
}

Error handling and API design

FFI boundaries should avoid Rust panics and use explicit, C‑friendly error models.

  • Return error codes and optionally provide an out‑parameter for an error message (caller frees the message).
  • Or, return a tagged C struct: { ok, error_code, payload_ptr } with clear ownership semantics.
  • Avoid Rust panics crossing the boundary—wrap public extern functions in std::panic::catch_unwind.

Performance & safety tips

  • Zero‑copy: Pass buffers by pointer/length to avoid copying large datasets; when copying is necessary, document costs.
  • Minimize allocations across boundary: Allocate or free on one side only when possible to reduce ABI friction.
  • Pinning: For callbacks where Rust stores pointers to JVM/Kotlin objects, use stable/native handles (GlobalRef on JVM, stable C handles on K/N) to avoid GC or relocation problems.
  • Representation guarantees: Mark structs with #[repr(C)] and use fixed-size types (u32, i64) for predictable layout.

CI and release workflow

Automating cross‑compilation and packaging is critical to ship reliably.

  • Use a CI matrix (GitHub Actions/GitLab CI) to build Rust targets for all ABIs and platforms: Android targets (aarch64-linux-android, armv7-linux-androideabi, x86_64), iOS targets (aarch64-apple-ios, x86_64-apple-ios), and host runners for unit tests.
  • Steps to include in CI:
    • Run cargo fmt, clippy, and unit tests (cargo test) for Rust.
    • Generate C headers (cbindgen) and Kotlin interop stubs.
    • Run Kotlin multiplatform build tasks (Gradle) that depend on Rust artifacts; fail fast on ABI mismatches.
    • Package per‑ABI artifacts and publish to a package repo or GitHub Releases; attach checksum and signature.
  • Automate versioning of the FFI surface: store an API version constant and fail builds when calling code expects a different version.

Testing and observability

End‑to‑end tests that exercise Rust from Kotlin are essential; include unit tests on both sides and integration tests on emulators or devices.

  • Unit test Rust logic with cargo test and property tests (proptest).
  • Write Kotlin tests that call the compiled Rust library (Android instrumentation tests or iOS unit tests through the simulator).
  • Add logging and metrics at the boundary to capture invocation counts, latency, and errors for production diagnosis.

Example workflow summary

  1. Design a small C‑friendly Rust API with opaque handles and explicit ownership functions.
  2. Generate headers with cbindgen and build static/dynamic libraries for each platform via cargo + cross/NDK toolchains.
  3. Create Kotlin platform adapters: JNI wrappers for Android, cinterop for Kotlin/Native on iOS.
  4. Enforce contracts via tests and CI matrix; publish artifacts and keep ABI versioning in sync.

Conclusion

Bridging Rust and Kotlin Multiplatform unlocks a powerful combination of safety and cross‑platform productivity—by adopting clear ownership contracts, C‑friendly APIs, and automated CI that builds and tests each target, teams can ship reliable mixed Rust/Kotlin apps with confidence.

Ready to prototype a mixed Rust/Kotlin module? Start by designing a tiny C ABI, adding cbindgen, and wiring a single function across platforms—then iterate from there.