Rust and Kotlin Interop: Bridging Performance and Productivity for Cross‑Platform Apps
In today’s mobile and desktop world, developers often wrestle with a trade‑off: write elegant, expressive UI code in Kotlin while still getting the raw speed of low‑level languages for compute‑heavy tasks. Rust and Kotlin interop offers a powerful solution—leveraging Kotlin Native to call into Rust libraries and thus accelerating core logic without sacrificing the developer experience that Kotlin provides. This article walks you through why this combination matters, how to set it up, and best practices for a smooth collaboration between the two languages.
Why Interop Between Rust and Kotlin Matters
Kotlin has become the language of choice for Android and is steadily expanding into desktop, web, and server with Kotlin Multiplatform. However, when you need to perform high‑performance operations—cryptographic hashing, image decoding, scientific calculations—Kotlin’s JVM or Android runtime can become a bottleneck. Rust, on the other hand, is engineered for speed, zero‑cost abstractions, and memory safety, making it ideal for such critical sections.
By calling Rust code from Kotlin via Kotlin Native, you can:
- Keep UI and business logic in Kotlin, ensuring maintainability.
- Move CPU‑intensive workloads to Rust, gaining up to 5× speed improvements.
- Avoid the overhead of JNI or C interop that often introduces complexity and security risks.
- Benefit from Rust’s robust compile‑time checks across language boundaries.
Kotlin Native: The Bridge to Native Performance
Kotlin Native compiles Kotlin code directly to machine code using LLVM. It exposes a clean foreign function interface (FFI) that can interoperate with C, and by extension Rust, which can emit C‑compatible headers and binaries. The process involves:
- Compiling Rust to a static or dynamic library.
- Generating C headers for the Rust API using
rustc --crate-type cdylib. - Using Kotlin’s
cinteroptool to import the headers and create Kotlin bindings.
The resulting Kotlin code can then call Rust functions as if they were native Kotlin functions, with automatic type conversions for basic types.
How to Expose Rust Functions for Kotlin
Let’s walk through the steps required to expose a simple Rust library to Kotlin.
1. Create a Rust Library
$ cargo new --lib rust_math $ cd rust_math
Add the following to src/lib.rs:
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
The #[no_mangle] attribute ensures the function name remains unchanged in the compiled binary, and extern "C" guarantees C ABI compatibility.
2. Build the Library
$ cargo build --release
The compiled shared library will be in target/release/librust_math.{so,dylib}, depending on your OS.
3. Generate C Header
To make the Rust functions visible to Kotlin, generate a C header file:
$ rustc --crate-type cdylib --emit=metadata,link -C prefer-dynamic=true src/lib.rs $ rustc --crate-type cdylib --emit=metadata,link -C prefer-dynamic=true -Z print-link-args src/lib.rs
Alternatively, use cbindgen:
$ cargo install cbindgen $ cbindgen --config cbindgen.toml --crate rust_math --output rust_math.h
Place rust_math.h in a folder accessible to the Kotlin project.
4. Create Kotlin Bindings with cinterop
In your Kotlin Multiplatform project, add the following to the build.gradle.kts:
kotlin {
iosArm64("ios") {
binaries {
framework {
baseName = "InteropDemo"
}
}
}
jvm {
compilations.all {
kotlinOptions {
jvmTarget = "1.8"
}
}
}
sourceSets {
val commonMain by getting
val iosMain by getting {
dependencies {
implementation(project(":rust_math"))
}
}
}
targets.withType(org.jetbrains.kotlin.native.plugin.KotlinNativeTarget::class).all {
val targetName = name
tasks.register("cinterop$targetName") {
group = "interop"
description = "Generate Kotlin bindings for Rust"
val headerDir = "$projectDir/src/commonMain/cinterop"
val outputDir = "$buildDir/generated/cinterop/$targetName"
inputs.dir(headerDir)
outputs.dir(outputDir)
doLast {
val command = listOf(
"cinterop",
"-def", "$headerDir/interop.def",
"-compiler-options", "-I$headerDir",
"-linker-options", "-L$projectDir/libs/$targetName -lrust_math",
"-o", outputDir
)
exec {
commandLine(command)
}
}
}
}
}
Create interop.def with the following content:
headers = rust_math.h headerFilter = rust_math.h target = native
Running ./gradlew cinteropIosArm64 will generate the bindings.
Calling Rust from Kotlin: A Practical Example
With the bindings in place, you can now call Rust functions directly:
import kotlin.native.internal.Platform
import kotlinx.cinterop.*
import platform.posix.*
fun main() {
val result = Rust.add(7, 5)
println("7 + 5 = $result") // Output: 7 + 5 = 12
}
Notice the seamless integration: Kotlin code invokes Rust.add as if it were a native function. The binding layer handles marshalling of Int values between the two languages.
Managing Memory Across the Boundary
While Rust guarantees memory safety, you must still manage allocations that cross the FFI boundary. Common patterns include:
- Using opaque pointers: Rust returns
*mut c_voidthat Kotlin stores as aCOpaquePointer. Release the pointer with a Rust deallocator. - Borrowing data: Pass a
*const c_charfor read‑only strings. Kotlin can convertStringtoCOpaquePointerviatoKString(). - Avoiding double frees: Ensure Rust is the sole owner of allocated memory; never deallocate from Kotlin.
Using Rust’s alloc::alloc::handle_alloc_error and custom #[no_mangle] free functions keeps the contract clear.
Common Pitfalls and How to Avoid Them
- ABI Mismatch: Ensure the Rust compiler targets the same ABI as the Kotlin Native toolchain (LLVM). Use
cargo rustc -- -C target-cpu=nativeif needed. - Data Layout Differences: Stick to C‑compatible types (e.g.,
i32,f64). Complex structs require manual layout annotations. - Threading Issues: Rust’s safety guarantees assume single ownership. If you pass data between threads, clone or use
Arcand expose safe Rust APIs. - Error Handling: Rust’s
Resulttypes cannot cross the FFI boundary directly. Convert errors to integer codes or usepanic!()with#[no_mangle]wrappers that translate to Kotlin exceptions. - Build System Complexity: Keep Rust and Kotlin projects modular. Use Gradle’s
cinteroptasks to automate binding generation, and pin Rust dependencies to specific versions to avoid ABI drift.
When to Use Rust, When to Use Kotlin
Adopting interop doesn’t mean replacing Kotlin with Rust everywhere. Here are guidelines:
- Use Rust for: Compute‑heavy algorithms, cryptography, image processing, and any code that benefits from zero‑cost abstractions.
- Use Kotlin for: UI layers, networking abstractions, state management, and any code that requires rapid iteration or relies on Kotlin’s extensive library ecosystem.
- Cross‑platform code: Rust libraries can be compiled to native binaries for iOS, Android, macOS, Windows, Linux, and WebAssembly. Kotlin Multiplatform can then consume those binaries on each platform, ensuring consistent performance.
Tools and Libraries to Simplify Interop
While manual binding is powerful, several tools reduce boilerplate:
- cbindgen: Automates C header generation from Rust code.
- Rust‑Kotlin-Interop (official Kotlin Native interop library): Provides utilities for mapping Rust types.
- ffi-rs: Simplifies FFI calls from Rust to C and back.
- kotlinx.cinterop: The Kotlin native package for managing pointers and memory.
Future Outlook: Multi‑Platform Kotlin with Rust
The Kotlin community is increasingly embracing multiplatform capabilities, and Rust’s growth in the systems space means the interop ecosystem is expanding rapidly. Future trends include:
- More comprehensive Rust
crate-typestargeting Kotlin directly, eliminating the need for manual header generation. - Gradle plugin enhancements that automatically detect Rust projects and manage dependencies.
- Enhanced error handling bridges, converting Rust
Resultinto KotlinResultwithout manual translation. - Better tooling for testing cross‑language boundaries, ensuring regressions are caught early.
By embracing Rust and Kotlin interop, developers can build cross‑platform applications that are both fast and expressive, unlocking new possibilities for performance‑critical workloads while keeping the codebase maintainable and developer‑friendly.
Conclusion
Combining Kotlin’s concise syntax and multiplatform capabilities with Rust’s performance and safety yields a potent developer experience. With Kotlin Native’s FFI and the right tooling, you can effortlessly call Rust libraries, accelerate core logic, and maintain a clean, productive codebase.
Ready to dive in? Start a new Kotlin Multiplatform project, add a Rust library, and experience the power of seamless interop today.
