Rust + NDK on Android, Part 2 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: The Complete Rust Toolchain Setup for Android: rustup, cargo-ndk, and Your First .so in an APK. Next: UniFFI: Kotlin Bindings for Rust Without Writing JNI by Hand.
Calling Rust from Kotlin takes three pieces: a Kotlin function marked external, a Rust function exported under a name the Android runtime can find, and one System.loadLibrary call to bring the compiled library into your process. The entire contract is a naming convention plus a calling convention, and both fit in your head.
This assumes the toolchain from Part 1: rustup with the Android targets, the NDK, and cargo-ndk. On top of that we’ll build a small crate, jni-basics, that exports three functions: integer math, a string reverse, and a greeting builder that mixes a string with a boolean. Every snippet compiled and ran on my machine; the terminal outputs are pasted from those runs.
I’m showing raw JNI on purpose. Part 3 introduces UniFFI, which generates all of this, but when a binding generator misbehaves, this is the layer you end up debugging.
Key Takeaways
- JNI resolves native methods by a name pattern:
Java_plus package, class, and method with dots turned into underscores. Get one token wrong and you getUnsatisfiedLinkErrorat first call, not at load time.#[no_mangle]keeps rustc from renaming your symbol, andextern "system"selects the calling convention JNI expects. Each has a one-paragraph explanation.- Primitives cross the boundary as plain machine types: Kotlin
Intisjintis Rusti32. Strings are object references that must be converted, and JNI uses modified UTF-8, not real UTF-8.- Keep the logic in plain Rust functions with host-run unit tests and make the JNI wrappers thin. The bridge should be boring.
What external fun does at runtime
Start on the side you already know. The whole Kotlin surface of this module lives in app/src/main/java/dev/davthecoder/rustseries/JniBasics.kt:
package dev.davthecoder.rustseries
object JniBasics {
init {
System.loadLibrary("jnibasics")
}
external fun add(a: Int, b: Int): Long
external fun reverse(input: String): String
external fun greet(name: String, premium: Boolean): String
}
external is Kotlin’s spelling of Java’s native keyword. The compiler emits a method with no body, flagged as native in the bytecode, and promises the JVM that an implementation will show up at runtime. System.loadLibrary("jnibasics") turns the name into libjnibasics.so, finds it inside your APK’s native library directory for the device’s ABI, and loads it with the platform’s dynamic linker.
ART does not check that add, reverse, and greet exist when the library loads. It waits until the first time each method is called, then builds the expected symbol name and searches the loaded libraries for it (Android Developers, JNI tips). A miss throws UnsatisfiedLinkError at the call site. Your app can run happily for minutes before one tap hits an unresolved native method and crashes. In debug builds I like to call the cheapest native function once at startup, purely to fail fast if the wiring is broken.
The JNI naming rule, token by token
The runtime finds your Rust function by name, and the name is derived mechanically from the Kotlin declaration. For JniBasics.reverse the expected symbol is Java_dev_davthecoder_rustseries_JniBasics_reverse, which decomposes like this:
| Token | Meaning |
|---|---|
Java_ |
Fixed prefix for every statically resolved JNI function |
dev_davthecoder_rustseries |
The package, with each dot replaced by an underscore |
JniBasics |
The class, or in our case the Kotlin object |
reverse |
The method name |
The mapping is defined in the JNI specification itself, under Resolving Native Method Names (Oracle, JNI specification). Three escape rules matter in practice. An underscore inside a package or class name becomes _1, because a plain underscore already means a dot. An overloaded native method gets a double underscore followed by a mangled parameter signature, so two external fun reverse overloads would need two different, uglier symbol names. And non-ASCII characters become _0 followed by four hex digits.
You can dodge all three rules by convention: keep JNI-facing package names free of underscores, give each crate one bridge class, and never overload an external fun. That’s why this series uses dev.davthecoder.rustseries and one Kotlin object per module.
#[no_mangle] and extern "system", explained
Both attributes have real jobs.
By default, rustc renames every symbol so that different crates, versions, and generic instantiations can coexist in one binary. On my host build of this crate, nm lists the plain add function as __RNvCs8f3uGV3I3E_9jnibasics3add. ART would never find that. #[no_mangle] opts one function out of mangling so the symbol survives exactly as written, and together with pub and the cdylib crate type it lands in the dynamic symbol table where the runtime’s lookup can see it.
One toolchain note before you copy anything: cargo new creates crates on the 2024 edition now, and there the attribute must be written #[unsafe(no_mangle)] (Rust 2024 edition guide). This series pins edition = "2021" in every Cargo.toml, which matches most JNI code in the wild. Same behavior, different spelling, and a confusing compiler error if you mix them up.
extern "system" sets the function’s calling convention: the register and stack protocol used to pass arguments. JNI’s headers declare every native entry point with the JNICALL convention, and Rust’s "system" ABI string means “whatever the platform’s system interface uses” (The Rust Reference, external blocks). On every Android ABI this is identical to extern "C", and the distinction only ever mattered on 32-bit Windows x86, where system calls used stdcall. Write "system" anyway; it’s correct on every platform and costs nothing.
A complete Rust JNI module for Kotlin
The crate manifest lives at rust/jni-basics/Cargo.toml:
[package]
name = "jni-basics"
version = "0.1.0"
edition = "2021"
[lib]
name = "jnibasics"
crate-type = ["cdylib", "lib"]
[dependencies]
jni = "0.21"
Cargo would name the library libjni_basics.so by default, replacing the dash with an underscore, so I set an explicit [lib] name to keep the loadLibrary string clean. And crate-type lists both cdylib for the Android artifact and lib so cargo test can link the crate on your laptop.
Every JNI function receives two arguments before your own. The first is JNIEnv, your handle into the running VM: it converts strings, throws exceptions, looks up classes, and calls Java methods back (docs.rs, jni crate). The second is the receiver. For an instance method it’s the object the method was called on, typed JObject; for a static method it’s the class, typed JClass. Members of a Kotlin object are instance methods on the singleton, so the honest type here is JObject. Plenty of samples write JClass and get away with it because both are pointers underneath and the argument goes unused, but this post is about not cargo-culting, so we write the true one.
Here is the complete rust/jni-basics/src/lib.rs:
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring, JNI_TRUE};
use jni::JNIEnv;
pub fn add(a: i32, b: i32) -> i64 {
a as i64 + b as i64
}
pub fn reverse(input: &str) -> String {
input.chars().rev().collect()
}
pub fn greet(name: &str, premium: bool) -> String {
if premium {
format!("Welcome back, {name}. Premium is active.")
} else {
format!("Hello, {name}.")
}
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_JniBasics_add(
_env: JNIEnv,
_this: JObject,
a: jint,
b: jint,
) -> jlong {
add(a, b)
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_JniBasics_reverse(
mut env: JNIEnv,
_this: JObject,
input: JString,
) -> jstring {
let input: String = env
.get_string(&input)
.map(|s| s.into())
.unwrap_or_default();
let output = reverse(&input);
env.new_string(output)
.expect("failed to build Java string")
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_JniBasics_greet(
mut env: JNIEnv,
_this: JObject,
name: JString,
premium: jboolean,
) -> jstring {
let name: String = env
.get_string(&name)
.map(|s| s.into())
.unwrap_or_else(|_| "unknown".to_string());
let output = greet(&name, premium == JNI_TRUE);
env.new_string(output)
.expect("failed to build Java string")
.into_raw()
}
#[cfg(test)]
mod tests {
use super::{add, greet, reverse};
#[test]
fn add_survives_int_overflow() {
assert_eq!(add(i32::MAX, 1), 2_147_483_648);
}
#[test]
fn reverse_reverses() {
assert_eq!(reverse("Rust"), "tsuR");
}
#[test]
fn greet_includes_name() {
assert!(greet("Android", true).contains("Android"));
}
}
The logic lives in three plain Rust functions with no JNI types anywhere near them, and each exported wrapper does nothing but convert arguments, call the real function, and convert the result. That split is what lets the tests at the bottom run on your laptop without a device or an emulator:
running 3 tests
test tests::add_survives_int_overflow ... ok
test tests::greet_includes_name ... ok
test tests::reverse_reverses ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Build the Android libraries from the rust/ directory with the same cargo-ndk invocation as Part 1, plus -p to pick the crate:
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \
-o ../app/src/main/jniLibs build --release -p jni-basics
Then verify the export happened. llvm-nm ships in the NDK’s toolchain directory, and -gD shows the dynamic symbol table, exactly the set ART can resolve against:
$ llvm-nm -gD app/src/main/jniLibs/arm64-v8a/libjnibasics.so | grep Java_
00000000000192d8 T Java_dev_davthecoder_rustseries_JniBasics_add
00000000000192e4 T Java_dev_davthecoder_rustseries_JniBasics_greet
00000000000194f8 T Java_dev_davthecoder_rustseries_JniBasics_reverse
Three symbols, three external fun declarations, character for character. When those match, the Kotlin side just works:
val sum = JniBasics.add(Int.MAX_VALUE, 1) // 2147483648L
val flipped = JniBasics.reverse("Rust") // "tsuR"
val message = JniBasics.greet("Ada", premium = true)
Primitives, strings, and booleans across the boundary
The three functions cover the kinds of traffic you’ll actually send across JNI. Primitives map directly between the type systems:
| Kotlin | JNI type | Rust type |
|---|---|---|
Int |
jint |
i32 |
Long |
jlong |
i64 |
Boolean |
jboolean |
u8 |
Float |
jfloat |
f32 |
Double |
jdouble |
f64 |
Byte |
jbyte |
i8 |
Short |
jshort |
i16 |
Char |
jchar |
u16 |
Primitives are copied by value with the same machine representation on both sides, so there is nothing to convert and nothing to free. The add function shows the one thing worth thinking about: widths. Kotlin’s Int is exactly i32, so Int.MAX_VALUE + 1 overflows in Kotlin, but our add does its arithmetic in i64 and returns jlong, so the same inputs come back as a correct 2147483648L.
Booleans hide a small trap. jboolean is an unsigned 8-bit integer, not a Rust bool, because that’s how the JNI ABI defines it. The greet wrapper compares against JNI_TRUE to get a real bool. Don’t be tempted to transmute: a Rust bool must be exactly 0 or 1, and trusting a foreign byte to respect that is undefined behavior waiting for an audience.
Strings are a different species entirely. A JString is not text; it’s a reference to an object inside the VM heap, and Rust can’t read it directly. env.get_string copies the contents out and decodes them into an owned Rust String, and env.new_string builds a fresh Java string from Rust text on the way back. The subtlety is the encoding: JNI’s string functions speak modified UTF-8, in which the null character and supplementary code points like emoji are encoded differently from standard UTF-8 (Oracle, JNI specification). The jni crate converts correctly in both directions, which is one reason to use it.
Both directions copy the full string, which is fine for names and messages and wrong for megabytes. Moving serious data needs arrays, ByteBuffers, and zero-copy techniques, and that’s a whole post: Part 6, Passing Data Between Kotlin and Rust: Strings, Arrays, ByteBuffers, and Zero-Copy, covers exactly that.
chars() iterates Unicode scalar values, so combining accents will detach from their base characters. Correct grapheme-level reversal needs the unicode-segmentation crate; this is a teaching function, not a text engine.
Static naming vs RegisterNatives
There is a second way to connect the two worlds: export a single JNI_OnLoad function and call RegisterNatives from it, handing the VM a table that maps method names to arbitrary Rust function pointers. The jni crate exposes this as register_native_methods. You gain real advantages: your Rust functions can have normal names, a typo in the table fails at load time instead of at first call, and the library exports almost nothing. Android’s own JNI tips page recommends this pattern for C++ codebases (Android Developers, JNI tips).
I still recommend static naming at this stage. It needs one attribute instead of a registration table, and every connection is a symbol you can inspect with llvm-nm. When something breaks, the error names the exact missing symbol. Once your API grows past a handful of functions you shouldn’t be scaling hand-written JNI at all; that’s what Part 3 is for.
Frequently Asked Questions
Do I need javah or C header files to call Rust from Kotlin?
No. Header generation was only ever a convenience for C and C++ implementations, and the javah tool was removed back in JDK 10 (OpenJDK, JEP 313). In Rust you produce the correctly named symbol yourself with #[no_mangle], and llvm-nm confirms it’s there.
Is JNI call overhead a problem when calling Rust from Kotlin?
A JNI call costs more than a regular Kotlin call, and string arguments add a copy and an encoding conversion in each direction. The design rule is to make crossings coarse: one call that does real work beats a chatty loop of tiny calls. For bulk data there are cheaper vehicles than strings, which Part 6 covers in depth.
Every new method means a hand-matched signature on both sides and a naming rule that punishes typos at runtime. Part 3, UniFFI: Kotlin Bindings for Rust Without Writing JNI by Hand, generates the whole bridge from annotated Rust. The hub post has the full series map.
Rust + NDK on Android, Part 2 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: The Complete Rust Toolchain Setup for Android: rustup, cargo-ndk, and Your First .so in an APK. Next: UniFFI: Kotlin Bindings for Rust Without Writing JNI by Hand.
Llamar a Rust desde Kotlin exige tres piezas: una función de Kotlin marcada como external, una función de Rust exportada con un nombre que el runtime de Android pueda encontrar, y una llamada a System.loadLibrary para meter la biblioteca compilada en tu proceso. El contrato entero es una convención de nombres más una convención de llamada, y ambas caben en la cabeza.
Esto da por hecha la cadena de herramientas de la parte 1: rustup con los targets de Android, el NDK y cargo-ndk. Encima de eso vamos a construir un crate pequeño, jni-basics, que exporta tres funciones: aritmética de enteros, invertir una cadena y un constructor de saludos que mezcla una cadena con un booleano. Cada fragmento compiló y se ejecutó en mi máquina; las salidas de terminal están pegadas de esas ejecuciones.
Muestro JNI en crudo a propósito. La parte 3 presenta UniFFI, que genera todo esto, pero cuando un generador de enlaces se porta mal, esta es la capa que acabas depurando.
Ideas clave
- JNI resuelve los métodos nativos con un patrón de nombre:
Java_más el paquete, la clase y el método, con los puntos convertidos en guiones bajos. Si fallas un solo token, obtienesUnsatisfiedLinkErroren la primera llamada, no en el momento de la carga.#[no_mangle]impide que rustc renombre tu símbolo, yextern "system"selecciona la convención de llamada que espera JNI. Cada uno tiene una explicación de un párrafo.- Los primitivos cruzan la frontera como tipos de máquina simples: el
Intde Kotlin esjinty es eli32de Rust. Las cadenas son referencias a objetos que hay que convertir, y JNI usa UTF-8 modificado, no UTF-8 de verdad.- Deja la lógica en funciones de Rust normales, con pruebas unitarias que se ejecutan en el host, y haz finos los wrappers JNI. El puente debería ser aburrido.
Qué hace external fun en tiempo de ejecución
Empieza por el lado que ya conoces. Toda la superficie Kotlin de este módulo vive en app/src/main/java/dev/davthecoder/rustseries/JniBasics.kt:
package dev.davthecoder.rustseries
object JniBasics {
init {
System.loadLibrary("jnibasics")
}
external fun add(a: Int, b: Int): Long
external fun reverse(input: String): String
external fun greet(name: String, premium: Boolean): String
}
external es la forma que tiene Kotlin de escribir la palabra clave native de Java. El compilador emite un método sin cuerpo, marcado como nativo en el bytecode, y le promete a la JVM que en tiempo de ejecución aparecerá una implementación. System.loadLibrary("jnibasics") convierte el nombre en libjnibasics.so, la encuentra dentro del directorio de bibliotecas nativas de tu APK para la ABI del dispositivo y la carga con el enlazador dinámico de la plataforma.
ART no comprueba que add, reverse y greet existan cuando se carga la biblioteca. Espera a la primera vez que se llama a cada método, construye el nombre de símbolo esperado y lo busca en las bibliotecas cargadas (Android Developers, JNI tips). Un fallo lanza UnsatisfiedLinkError en el punto de llamada. Tu aplicación puede funcionar tan contenta durante minutos antes de que un toque dé con un método nativo sin resolver y se caiga. En las compilaciones de depuración me gusta llamar una vez en el arranque a la función nativa más barata, solo para fallar pronto si el cableado está roto.
La regla de nombres de JNI, token a token
El runtime encuentra tu función de Rust por el nombre, y ese nombre se deriva de forma mecánica de la declaración de Kotlin. Para JniBasics.reverse el símbolo esperado es Java_dev_davthecoder_rustseries_JniBasics_reverse, que se descompone así:
| Token | Significado |
|---|---|
Java_ |
Prefijo fijo de toda función JNI resuelta de forma estática |
dev_davthecoder_rustseries |
El paquete, con cada punto sustituido por un guion bajo |
JniBasics |
La clase o, en nuestro caso, el object de Kotlin |
reverse |
El nombre del método |
La correspondencia está definida en la propia especificación de JNI, en el apartado Resolving Native Method Names (Oracle, JNI specification). En la práctica importan tres reglas de escape. Un guion bajo dentro de un nombre de paquete o de clase se convierte en _1, porque un guion bajo a secas ya significa un punto. Un método nativo sobrecargado recibe un doble guion bajo seguido de una firma de parámetros con mangling, así que dos sobrecargas de external fun reverse necesitarían dos nombres de símbolo distintos y más feos. Y los caracteres no ASCII se convierten en _0 seguido de cuatro dígitos hexadecimales.
Puedes esquivar las tres reglas por convención: mantén sin guiones bajos los nombres de paquete expuestos a JNI, dale a cada crate una clase puente y no sobrecargues nunca un external fun. Por eso esta serie usa dev.davthecoder.rustseries y un object de Kotlin por módulo.
#[no_mangle] y extern "system", explicados
Los dos atributos tienen un trabajo real.
Por defecto, rustc renombra cada símbolo para que crates, versiones e instanciaciones genéricas distintas puedan convivir en un mismo binario. En mi compilación de host de este crate, nm lista la función add a secas como __RNvCs8f3uGV3I3E_9jnibasics3add. ART jamás encontraría eso. #[no_mangle] saca a una función del mangling para que el símbolo sobreviva exactamente como está escrito y, junto con pub y el tipo de crate cdylib, aterrice en la tabla de símbolos dinámicos, donde la búsqueda del runtime puede verlo.
Una nota de cadena de herramientas antes de que copies nada: cargo new crea crates en la edición 2024, y ahí el atributo debe escribirse #[unsafe(no_mangle)] (guía de la edición Rust 2024). Esta serie fija edition = "2021" en cada Cargo.toml, lo que coincide con la mayor parte del código JNI que hay por ahí. El mismo comportamiento, distinta grafía, y un error de compilador confuso si los mezclas.
extern "system" fija la convención de llamada de la función: el protocolo de registros y de pila que se usa para pasar argumentos. Las cabeceras de JNI declaran cada punto de entrada nativo con la convención JNICALL, y la cadena de ABI "system" de Rust significa «lo que use la interfaz de sistema de la plataforma» (The Rust Reference, external blocks). En todas las ABI de Android esto es idéntico a extern "C", y la distinción solo importó alguna vez en Windows x86 de 32 bits, donde las llamadas de sistema usaban stdcall. Escribe "system" de todos modos: es correcto en todas las plataformas y no cuesta nada.
Un módulo JNI de Rust completo para Kotlin
El manifiesto del crate vive en rust/jni-basics/Cargo.toml:
[package]
name = "jni-basics"
version = "0.1.0"
edition = "2021"
[lib]
name = "jnibasics"
crate-type = ["cdylib", "lib"]
[dependencies]
jni = "0.21"
Cargo nombraría la biblioteca libjni_basics.so por defecto, sustituyendo el guion por un guion bajo, así que fijé un name explícito en [lib] para mantener limpia la cadena de loadLibrary. Y crate-type lista tanto cdylib, para el artefacto de Android, como lib, para que cargo test pueda enlazar el crate en tu portátil.
Toda función JNI recibe dos argumentos antes de los tuyos. El primero es JNIEnv, tu manejador de la máquina virtual en ejecución: convierte cadenas, lanza excepciones, busca clases y llama de vuelta a métodos Java (docs.rs, jni crate). El segundo es el receptor. En un método de instancia es el objeto sobre el que se llamó al método, con tipo JObject; en un método estático es la clase, con tipo JClass. Los miembros de un object de Kotlin son métodos de instancia del singleton, así que el tipo honesto aquí es JObject. Montones de ejemplos escriben JClass y se salen con la suya, porque por debajo ambos son punteros y el argumento no se usa, pero este artículo trata de no copiar el ritual sin entenderlo, así que escribimos el de verdad.
Aquí está el rust/jni-basics/src/lib.rs completo:
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring, JNI_TRUE};
use jni::JNIEnv;
pub fn add(a: i32, b: i32) -> i64 {
a as i64 + b as i64
}
pub fn reverse(input: &str) -> String {
input.chars().rev().collect()
}
pub fn greet(name: &str, premium: bool) -> String {
if premium {
format!("Welcome back, {name}. Premium is active.")
} else {
format!("Hello, {name}.")
}
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_JniBasics_add(
_env: JNIEnv,
_this: JObject,
a: jint,
b: jint,
) -> jlong {
add(a, b)
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_JniBasics_reverse(
mut env: JNIEnv,
_this: JObject,
input: JString,
) -> jstring {
let input: String = env
.get_string(&input)
.map(|s| s.into())
.unwrap_or_default();
let output = reverse(&input);
env.new_string(output)
.expect("failed to build Java string")
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_JniBasics_greet(
mut env: JNIEnv,
_this: JObject,
name: JString,
premium: jboolean,
) -> jstring {
let name: String = env
.get_string(&name)
.map(|s| s.into())
.unwrap_or_else(|_| "unknown".to_string());
let output = greet(&name, premium == JNI_TRUE);
env.new_string(output)
.expect("failed to build Java string")
.into_raw()
}
#[cfg(test)]
mod tests {
use super::{add, greet, reverse};
#[test]
fn add_survives_int_overflow() {
assert_eq!(add(i32::MAX, 1), 2_147_483_648);
}
#[test]
fn reverse_reverses() {
assert_eq!(reverse("Rust"), "tsuR");
}
#[test]
fn greet_includes_name() {
assert!(greet("Android", true).contains("Android"));
}
}
La lógica vive en tres funciones de Rust normales, sin tipos JNI cerca, y cada wrapper exportado no hace más que convertir argumentos, llamar a la función real y convertir el resultado. Esa separación es lo que permite que las pruebas del final se ejecuten en tu portátil, sin dispositivo ni emulador:
running 3 tests
test tests::add_survives_int_overflow ... ok
test tests::greet_includes_name ... ok
test tests::reverse_reverses ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Compila las bibliotecas de Android desde el directorio rust/ con la misma invocación de cargo-ndk que en la parte 1, más -p para elegir el crate:
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \
-o ../app/src/main/jniLibs build --release -p jni-basics
Luego comprueba que la exportación ocurrió. llvm-nm viene en el directorio de la cadena de herramientas del NDK, y -gD muestra la tabla de símbolos dinámicos, justo el conjunto contra el que ART puede resolver:
$ llvm-nm -gD app/src/main/jniLibs/arm64-v8a/libjnibasics.so | grep Java_
00000000000192d8 T Java_dev_davthecoder_rustseries_JniBasics_add
00000000000192e4 T Java_dev_davthecoder_rustseries_JniBasics_greet
00000000000194f8 T Java_dev_davthecoder_rustseries_JniBasics_reverse
Tres símbolos, tres declaraciones external fun, carácter por carácter. Cuando coinciden, el lado Kotlin simplemente funciona:
val sum = JniBasics.add(Int.MAX_VALUE, 1) // 2147483648L
val flipped = JniBasics.reverse("Rust") // "tsuR"
val message = JniBasics.greet("Ada", premium = true)
Primitivos, cadenas y booleanos al cruzar la frontera
Las tres funciones cubren el tipo de tráfico que de verdad vas a enviar a través de JNI. Los primitivos se corresponden directamente entre los sistemas de tipos:
| Kotlin | Tipo JNI | Tipo de Rust |
|---|---|---|
Int |
jint |
i32 |
Long |
jlong |
i64 |
Boolean |
jboolean |
u8 |
Float |
jfloat |
f32 |
Double |
jdouble |
f64 |
Byte |
jbyte |
i8 |
Short |
jshort |
i16 |
Char |
jchar |
u16 |
Los primitivos se copian por valor, con la misma representación de máquina en ambos lados, así que no hay nada que convertir ni nada que liberar. La función add muestra lo único en lo que merece la pena pensar: las anchuras. El Int de Kotlin es exactamente i32, así que Int.MAX_VALUE + 1 desborda en Kotlin, pero nuestro add hace la aritmética en i64 y devuelve jlong, de modo que las mismas entradas vuelven como un 2147483648L correcto.
Los booleanos esconden una trampa pequeña. jboolean es un entero sin signo de 8 bits, no un bool de Rust, porque así lo define la ABI de JNI. El wrapper de greet compara con JNI_TRUE para obtener un bool de verdad. No caigas en la tentación de hacer transmute: un bool de Rust tiene que ser exactamente 0 o 1, y fiarte de que un byte ajeno respete eso es comportamiento indefinido esperando público.
Las cadenas son otra especie por completo. Un JString no es texto; es una referencia a un objeto dentro del montón de la máquina virtual, y Rust no puede leerlo directamente. env.get_string copia el contenido hacia fuera y lo descodifica en un String de Rust con propiedad, y env.new_string construye una cadena Java nueva a partir del texto de Rust en el camino de vuelta. La sutileza está en la codificación: las funciones de cadena de JNI hablan UTF-8 modificado, en el que el carácter nulo y los puntos de código suplementarios, como los emoji, se codifican de forma distinta al UTF-8 estándar (Oracle, JNI specification). El crate jni convierte bien en las dos direcciones, y esa es una razón para usarlo.
Las dos direcciones copian la cadena entera, lo cual está bien para nombres y mensajes, y mal para megabytes. Mover datos serios exige arrays, ByteBuffers y técnicas de copia cero, y eso es un artículo entero: la parte 6, Pasar datos entre Kotlin y Rust: cadenas, arrays, ByteBuffers y copia cero, cubre exactamente eso.
chars() itera valores escalares Unicode, así que los acentos combinantes se despegarán de sus caracteres base. Una inversión correcta a nivel de grafema necesita el crate unicode-segmentation; esta es una función didáctica, no un motor de texto.
Nombrado estático frente a RegisterNatives
Hay una segunda forma de conectar los dos mundos: exportar una sola función JNI_OnLoad y llamar a RegisterNatives desde ella, entregando a la máquina virtual una tabla que asigna nombres de método a punteros de función de Rust arbitrarios. El crate jni expone esto como register_native_methods. Ganas ventajas reales: tus funciones de Rust pueden tener nombres normales, una errata en la tabla falla en el momento de la carga y no en la primera llamada, y la biblioteca no exporta casi nada. La propia página de consejos JNI de Android recomienda este patrón para codebases en C++ (Android Developers, JNI tips).
Aun así, en esta fase recomiendo el nombrado estático. Necesita un atributo en lugar de una tabla de registro, y cada conexión es un símbolo que puedes inspeccionar con llvm-nm. Cuando algo se rompe, el error nombra el símbolo exacto que falta. Cuando tu API pase de un puñado de funciones, no deberías escalar JNI escrito a mano en absoluto; para eso está la parte 3.
Preguntas frecuentes
¿Hace falta javah o archivos de cabecera C para llamar a Rust desde Kotlin?
No. Generar cabeceras solo fue alguna vez una comodidad para implementaciones en C y C++, y la herramienta javah se eliminó allá por el JDK 10 (OpenJDK, JEP 313). En Rust produces tú mismo el símbolo con el nombre correcto mediante #[no_mangle], y llvm-nm confirma que está ahí.
¿El sobrecoste de una llamada JNI es un problema al llamar a Rust desde Kotlin?
Una llamada JNI cuesta más que una llamada normal de Kotlin, y los argumentos de tipo cadena añaden una copia y una conversión de codificación en cada dirección. La regla de diseño es hacer gruesos los cruces: una llamada que hace trabajo de verdad gana a un bucle locuaz de llamadas minúsculas. Para datos en bloque hay vehículos más baratos que las cadenas, y la parte 6 lo cubre a fondo.
Cada método nuevo implica una firma casada a mano en los dos lados y una regla de nombres que castiga las erratas en tiempo de ejecución. La parte 3, UniFFI: enlaces de Kotlin para Rust sin escribir JNI a mano, genera el puente entero a partir de Rust anotado. El artículo central tiene el mapa completo de la serie.

Loading comments…Cargando comentarios…