Skip to content
Kotlin MultiplatformKotlin Multiplatform

Rust in Kotlin Multiplatform: One Native Core for Android and iOS, and When to Skip Rust EntirelyRust en Kotlin Multiplatform: un núcleo nativo para Android e iOS, y cuándo saltarte Rust por completo

ByPor David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)Ingeniero sénior de móvil e IA aplicada (Android, Kotlin Multiplatform, IA de producto)

Updated 30 October 2026Actualizado el 30 de octubre de 202613 min read13 min de lectura
AndroidRustNDKKotlin MultiplatformUniFFIiOS
Rust in Kotlin Multiplatform: One Native Core for Android and iOS, and When to Skip Rust Entirely
Share:Compartir:

Rust + NDK on Android, Part 15 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: CI/CD for Rust + Android: GitHub Actions, Cross-Compilation Caching, Reproducible Builds. This part closes the series.

Rust in Kotlin Multiplatform comes down to one crate producing two artifacts, plus one decision about the bridge. The crate compiles to a cdylib for Android through cargo-ndk, exactly as it has since the start of this series, and to static libraries wrapped in an XCFramework for iOS. The bridge is either UniFFI generating Kotlin and Swift from one annotated interface, or a small C API under expect/actual, with JNI on the Android side and cinterop on the iOS side. The Rust itself does not change. Only the packaging does.

Because this is the closing part, it also covers the question that should come before any of that plumbing: whether your app needs Rust at all. The second half is the checklist of cases where I would tell you to skip it. This assumes the toolchain from Part 1 and the UniFFI workflow from Part 3.

Key Takeaways

  • One crate, two artifacts: cargo-ndk produces the Android .so, and xcodebuild -create-xcframework packages the iOS static libraries. All three Apple iOS targets are Tier 2 in rustc (Platform Support).
  • UniFFI generates Kotlin and Swift from the same annotated Rust. Element X ships its entire Matrix engine, the open source matrix-rust-sdk, to both platforms this way.
  • Kotlin/Native imports C and Objective-C headers, not Swift, so shared Kotlin that must call Rust on iOS goes through a C API under expect/actual (Kotlin docs).
  • Rust earns a place in a KMP app only below a profiled native need. Kotlin stays the default, the same gate as the hub post.

Where Rust fits in a Kotlin Multiplatform architecture

KMP and Rust answer different questions, and confusing the two produces the wrong architecture. Kotlin Multiplatform shares the code you would otherwise write twice: view models, repositories, networking, persistence, business rules. Rust shares the code that has to be native on both platforms anyway: the image pipeline from Part 5, the audio DSP from Part 10, the parsing core from Part 11, the crypto from Part 12.

In the layering I use in my KMP clean architecture blueprint, Rust sits one floor below commonMain. Common code owns an interface, the platform source sets own the bridge to the crate, and everything above the interface stays testable with fakes on the JVM. No use case or view model knows the implementation changed languages.

This is not a hypothetical stack. Block builds Bitkey, its bitcoin wallet, with Kotlin Multiplatform for the shared app logic and Rust for the core underneath (Block Engineering). Element X drives both its Android and iOS apps from the Rust matrix-rust-sdk through UniFFI-generated Kotlin and Swift. The shape you are about to build is the same one they ship.

One crate, two platforms

The crate for this part lives at rust/kmp-core and is deliberately tiny, because the plumbing is the lesson. Here is rust/kmp-core/Cargo.toml:

[package]
name = "kmp-core"
version = "0.1.0"
edition = "2021"

[lib]
name = "kmp_core"
crate-type = ["cdylib", "staticlib"]

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

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

Two crate types because the platforms load code differently. Android dlopens a shared object, so it gets the cdylib. Apple’s toolchain prefers to link native code into the final binary itself, so iOS gets the staticlib. The uniffi-bindgen binary is the standard UniFFI pattern for generating bindings from your own crate (UniFFI tutorial), and rust/kmp-core/uniffi-bindgen.rs is three lines:

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

The library at rust/kmp-core/src/lib.rs exposes the same logic twice, once through UniFFI for the generated-bindings route and once as a plain C function for the cinterop route:

uniffi::setup_scaffolding!();

#[derive(uniffi::Record)]
pub struct ReceiptSummary {
    pub items: u32,
    pub total_cents: i64,
}

#[uniffi::export]
pub fn summarize_receipts(amounts_cents: Vec<i64>) -> ReceiptSummary {
    ReceiptSummary {
        items: amounts_cents.len() as u32,
        total_cents: amounts_cents.iter().sum(),
    }
}

/// C entry point for the Kotlin/Native cinterop route on iOS.
#[no_mangle]
pub extern "C" fn kmp_core_sum_cents(amounts: *const i64, len: usize) -> i64 {
    if amounts.is_null() || len == 0 {
        return 0;
    }
    let amounts = unsafe { std::slice::from_raw_parts(amounts, len) };
    amounts.iter().sum()
}

The Android build is the command you have typed fourteen times by now, aimed at the shared module instead of an app module:

cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \
  -o shared/src/androidMain/jniLibs build --release -p kmp-core

One line inside the existing android block of shared/build.gradle.kts teaches AGP where those libraries live:

sourceSets["main"].jniLibs.srcDir("src/androidMain/jniLibs")

Building the XCFramework for iOS

Rust treats the Apple iOS targets as Tier 2, same as the Android ones (Platform Support): aarch64-apple-ios for devices, aarch64-apple-ios-sim and x86_64-apple-ios for simulators. You cannot merge all three with lipo, because the device and Apple Silicon simulator slices share an architecture and a fat binary cannot hold both. That collision is exactly why Apple introduced the XCFramework format (Apple Developer). The older cargo-lipo tool predates it; today you want xcodebuild -create-xcframework.

The whole build is one script at scripts/build-ios-core.sh, run from the repository root on a Mac:

#!/usr/bin/env bash
set -euo pipefail

TARGETS=(aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios)
for target in "${TARGETS[@]}"; do
  rustup target add "$target"
  cargo build -p kmp-core --release --target "$target"
done

# lipo only merges the two simulator slices; the device slice stays alone.
mkdir -p target/ios-sim-universal
lipo -create \
  target/aarch64-apple-ios-sim/release/libkmp_core.a \
  target/x86_64-apple-ios/release/libkmp_core.a \
  -output target/ios-sim-universal/libkmp_core.a

rm -rf target/KmpCore.xcframework
xcodebuild -create-xcframework \
  -library target/aarch64-apple-ios/release/libkmp_core.a \
  -headers rust/kmp-core/include \
  -library target/ios-sim-universal/libkmp_core.a \
  -headers rust/kmp-core/include \
  -output target/KmpCore.xcframework

Drop the resulting KmpCore.xcframework into the Xcode project and Xcode picks the right slice per destination. In CI this script slots into the macOS runner job from Part 14 unchanged.

Route one: UniFFI generates Kotlin and Swift from one interface

The strongest argument for UniFFI in a KMP project is that the same annotated Rust produces both bindings. One config file at rust/kmp-core/uniffi.toml names the output for each language (Kotlin config, Swift config):

[bindings.kotlin]
package_name = "dev.davthecoder.rustseries.rust"
cdylib_name = "kmp_core"

[bindings.swift]
module_name = "KmpCore"

Generation reads the compiled library’s metadata, so build for the host first, then run bindgen twice:

cargo build -p kmp-core --release

cargo run -p kmp-core --bin uniffi-bindgen -- generate \
  --library target/release/libkmp_core.dylib \
  --language kotlin \
  --out-dir shared/src/androidMain/kotlin

cargo run -p kmp-core --bin uniffi-bindgen -- generate \
  --library target/release/libkmp_core.dylib \
  --language swift \
  --out-dir ios/Generated

The Kotlin lands in androidMain, needs the JNA dependency from Part 3, and works immediately. Common code then defines the API it wants in shared/src/commonMain/kotlin/dev/davthecoder/rustseries/ReceiptCore.kt using expect/actual (Kotlin docs):

package dev.davthecoder.rustseries

data class Receipts(val items: Int, val totalCents: Long)

expect fun summarizeReceipts(amountsCents: List<Long>): Receipts

The Android actual at shared/src/androidMain/kotlin/dev/davthecoder/rustseries/ReceiptCore.android.kt is one import away from done:

package dev.davthecoder.rustseries

import dev.davthecoder.rustseries.rust.summarizeReceipts as rustSummarizeReceipts

actual fun summarizeReceipts(amountsCents: List<Long>): Receipts {
    val summary = rustSummarizeReceipts(amountsCents)
    return Receipts(
        items = summary.items.toInt(),
        totalCents = summary.totalCents,
    )
}

On iOS there is a wrinkle you should understand before committing to this route. UniFFI’s Swift output is a Swift module, and Kotlin/Native cannot import Swift, only C and Objective-C (Kotlin docs). So the generated Swift is for your SwiftUI layer, not for iosMain. If the Rust calls live at the app edge, that is perfect: define an interface in commonMain, implement it in Swift on top of the generated bindings, and inject it into shared code at startup. Element X works at this altitude. But if commonMain logic itself must call Rust on iOS, you need a C surface, which is route two.

Packaging the generated Swift so it actually builds

The Swift bindgen run above produced three files, not one: KmpCore.swift, the API your app code calls, plus KmpCoreFFI.h and KmpCoreFFI.modulemap, which declare the C symbols that Swift file binds against (UniFFI Swift docs). The build script from earlier packages only the hand-written kmp_core.h, so as written, KmpCore.swift has no KmpCoreFFI module to import and the SwiftUI layer fails to compile. Run the bindgen before the script, then add two copies to scripts/build-ios-core.sh just above the xcodebuild -create-xcframework call:

cp ios/Generated/KmpCoreFFI.h rust/kmp-core/include/
cp ios/Generated/KmpCoreFFI.modulemap rust/kmp-core/include/module.modulemap

The rename in the second copy matters: Xcode only picks up a modulemap inside a framework’s Headers directory when it is named module.modulemap. KmpCore.swift does not go into the framework at all. Add it to your app target as a plain source file.

Route two: expect/actual over JNI and cinterop

For the cinterop route, you write the C header yourself, which is also the discipline that keeps the surface small. This is rust/kmp-core/include/kmp_core.h, the same directory the XCFramework script already packages:

#pragma once

#include <stdint.h>
#include <stddef.h>

int64_t kmp_core_sum_cents(const int64_t *amounts, size_t len);

Kotlin/Native learns about it through a definition file at shared/src/nativeInterop/cinterop/kmpcore.def:

headers = kmp_core.h
package = dev.davthecoder.rustseries.cinterop

The wiring goes inside the kotlin block of shared/build.gradle.kts: each iOS target gets the cinterop for stub generation and a linker path to its matching Rust slice:

listOf(
    iosArm64() to "aarch64-apple-ios",
    iosSimulatorArm64() to "aarch64-apple-ios-sim",
    iosX64() to "x86_64-apple-ios",
).forEach { (iosTarget, rustTriple) ->
    iosTarget.compilations.getByName("main").cinterops.create("kmpcore") {
        definitionFile.set(project.file("src/nativeInterop/cinterop/kmpcore.def"))
        includeDirs(rootDir.resolve("rust/kmp-core/include"))
    }
    iosTarget.binaries.framework {
        baseName = "Shared"
        isStatic = true
        linkerOpts(
            "-L${rootDir.resolve("target/$rustTriple/release")}",
            "-lkmp_core",
        )
    }
}

Now iosMain can call the Rust function directly. The actual lives at shared/src/iosMain/kotlin/dev/davthecoder/rustseries/ReceiptCore.ios.kt:

package dev.davthecoder.rustseries

import dev.davthecoder.rustseries.cinterop.kmp_core_sum_cents
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.convert
import kotlinx.cinterop.toCValues

@OptIn(ExperimentalForeignApi::class)
actual fun summarizeReceipts(amountsCents: List<Long>): Receipts {
    val total = kmp_core_sum_cents(
        amountsCents.toLongArray().toCValues(),
        amountsCents.size.convert(),
    )
    return Receipts(items = amountsCents.size, totalCents = total)
}

Notice what the C boundary did to the design: the item count never crosses, because it never needed Rust. Hand-written C APIs push you toward flat buffers and primitive returns, the same instincts Part 6 built for JNI. That is fine for a handful of entry points. The moment your API grows records, errors, and callbacks on both platforms, maintaining a C header plus two hand bridges costs more than UniFFI’s generated code, and the crossover arrives faster than you expect.

When to skip Rust entirely

This is the half of the title most posts about Rust will not write, so I will. After fourteen parts of machinery, here are the situations where I would tell you not to use any of it.

  • Your app is a thin client over an API. CRUD screens, forms, a backend that does the real work. There is no compute for Rust to accelerate. KMP alone shares everything worth sharing, and KMP already reaches platforms as far out as tvOS without a line of native code.
  • Nobody on the team has shipped Rust. Google’s own retraining data says about a third of C++ developers feel productive in Rust within two months and about half within four (The Register, 2024). That is a real cost even for teams already writing native code. Starting from Kotlin only, it is larger, and it buys nothing without a native bottleneck to spend it on.
  • Kotlin already has the library. kotlinx.serialization, Ktor, SQLDelight, and the coroutine ecosystem cover most app infrastructure well. Part 11 was explicit that serde only wins past a measured payload threshold. Rewriting working Kotlin in Rust for purity is negative engineering. Most of what makes a mobile product, auth, billing, sync, storage, is a KMP problem, not a Rust problem.
  • You ship one platform. The shared-core argument is the strongest reason to accept FFI complexity. Android-only, a plain Kotlin app, or Kotlin plus a small JNI crate where profiling demands it, beats a KMP and Rust sandwich you built for an iOS app that does not exist yet.
  • You cannot own an FFI boundary for years. The disciplines from Part 7, Part 8, and Part 9 are a permanent tax on every change that crosses the border. If the team cannot budget for that maintenance, the boundary will rot into the crash reports it was supposed to prevent.

The rule from the hub post has not moved in fifteen parts: Kotlin stays the default, and native code enters only after profiling proves the JVM is the wall. Rust changed which language wins once you pass that gate. It did not move the gate.

The series, start to finish

Here is the full spine, in reading order:

  1. The Complete Rust Toolchain Setup for Android: rustup, cargo-ndk, and Your First .so in an APK
  2. Calling Rust from Kotlin: JNI Basics for Android Developers
  3. UniFFI: Kotlin Bindings for Rust Without Writing JNI by Hand
  4. Project Structure: Gradle + Cargo in One Build, ABI Splits, Debug/Release Flavors
  5. Fast Image Processing on Android with Rust: Bitmaps, Filters, and the JNI Fast Path
  6. Passing Data Between Kotlin and Rust: Strings, Arrays, ByteBuffers, and Zero-Copy
  7. Memory Management Across JNI: Ownership, Box::into_raw, Global Refs, and Leaks
  8. Error Handling and Panics: Keeping Rust Crashes Out of Your Android App
  9. Threading and Async: Tokio, Kotlin Coroutines, and Calling Back Into Kotlin from Rust
  10. Low-Latency Audio DSP with Rust: AAudio and Oboe from a Rust Core
  11. Heavy Parsing with Serde: Offloading JSON and CSV Crunching to Rust
  12. Cryptography with Rust on Android: ring and RustCrypto Instead of Rolling Your Own
  13. Binary Size and the 16KB Page Requirement: Optimizing Rust .so Files for Release
  14. CI/CD for Rust + Android: GitHub Actions, Cross-Compilation Caching, Reproducible Builds
  15. Rust in Kotlin Multiplatform: One Native Core for Android and iOS, and When to Skip Rust Entirely, this post.

Frequently Asked Questions

Can Kotlin Multiplatform call Rust code?

Yes, on both platforms. On Android, shared code calls Rust through UniFFI-generated Kotlin or hand-written JNI in the androidMain source set. On iOS, Kotlin/Native imports the crate’s C header through cinterop, or the Swift layer consumes UniFFI-generated Swift and injects it behind a commonMain interface.

Should I use Rust or Kotlin Multiplatform to share code between Android and iOS?

Usually KMP alone, and sometimes both, because they solve different layers. KMP shares business logic, view models, and infrastructure in Kotlin. Rust only enters for a compute core that must be native anyway, such as DSP, crypto, or heavy parsing, and it sits underneath the KMP layer, not instead of it.

Does UniFFI generate both Kotlin and Swift bindings?

Yes. One annotated Rust interface generates Kotlin, Swift, Python, and Ruby bindings, configured per language in uniffi.toml (UniFFI docs). Element X ships its whole Matrix engine to Android and iOS from one Rust codebase this way.

Do I need a Mac to build the iOS side of a Rust KMP core?

Yes. Cross-compiling the Rust itself to Apple targets needs the Apple SDKs, and packaging the XCFramework requires xcodebuild, which only runs on macOS. In practice that means a macOS job in your CI matrix, the same runner split as Part 14.

Fifteen parts later

This series opened with an empty crate and a single cargo ndk command. It closes with that same crate compiled for a second operating system, still without Google’s blessing and still without leaving Kotlin, which was the argument all along.

If you are weighing this stack for a real product, that call is what I do in architecture reviews and mentoring, and the build-out itself is the kind of work I take on as an independent engineer. The hub post has the map of all fifteen parts.

Sources

Rust + NDK on Android, Part 15 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: CI/CD for Rust + Android: GitHub Actions, Cross-Compilation Caching, Reproducible Builds. This part closes the series.

Rust en Kotlin Multiplatform se reduce a un crate que produce dos artefactos, más una decisión sobre el puente. El crate se compila a un cdylib para Android mediante cargo-ndk, exactamente como desde el principio de esta serie, y a bibliotecas estáticas envueltas en un XCFramework para iOS. El puente es o bien UniFFI generando Kotlin y Swift desde una interfaz anotada, o bien una API de C pequeña bajo expect/actual, con JNI en el lado de Android y cinterop en el de iOS. El Rust en sí no cambia. Solo cambia el empaquetado.

Como esta es la parte de cierre, también cubre la pregunta que debería ir antes de todo ese montaje: si tu app necesita Rust en absoluto. La segunda mitad es la lista de casos en los que te diría que te lo saltes. Esto asume la cadena de herramientas de la parte 1 y el flujo de UniFFI de la parte 3.

Puntos clave

  • Un crate, dos artefactos: cargo-ndk produce el .so de Android, y xcodebuild -create-xcframework empaqueta las bibliotecas estáticas de iOS. Los tres destinos de iOS de Apple son Tier 2 en rustc (Platform Support).
  • UniFFI genera Kotlin y Swift a partir del mismo Rust anotado. Element X publica así todo su motor de Matrix, el matrix-rust-sdk de código abierto, en las dos plataformas.
  • Kotlin/Native importa cabeceras de C y Objective-C, no Swift, así que el Kotlin compartido que tenga que llamar a Rust en iOS pasa por una API de C bajo expect/actual (Kotlin docs).
  • Rust se gana un sitio en una app de KMP solo por debajo de una necesidad nativa medida con el perfilador. Kotlin sigue siendo el valor por defecto, el mismo umbral que el artículo del hub.

Dónde encaja Rust en una arquitectura de Kotlin Multiplatform

KMP y Rust responden a preguntas distintas, y confundirlas produce la arquitectura equivocada. Kotlin Multiplatform comparte el código que si no escribirías dos veces: view models, repositorios, red, persistencia, reglas de negocio. Rust comparte el código que de todas formas tiene que ser nativo en las dos plataformas: el pipeline de imágenes de la parte 5, el DSP de audio de la parte 10, el núcleo de parsing de la parte 11, la cripto de la parte 12.

En el escalonado que uso en mi artículo de arquitectura limpia para KMP, Rust se sienta un piso por debajo de commonMain. El código común posee una interfaz, los source sets de plataforma poseen el puente hacia el crate, y todo lo que está por encima de la interfaz sigue siendo testeable con fakes en la JVM. Ningún caso de uso ni ningún view model sabe que la implementación ha cambiado de lenguaje.

Esto no es una pila hipotética. Block construye Bitkey, su cartera de bitcoin, con Kotlin Multiplatform para la lógica compartida de la app y Rust para el núcleo de debajo (Block Engineering). Element X mueve sus apps de Android e iOS desde el matrix-rust-sdk en Rust, mediante Kotlin y Swift generados por UniFFI. La forma que vas a construir es la misma que ellos publican.

Un crate, dos plataformas

El crate de esta parte vive en rust/kmp-core y es deliberadamente minúsculo, porque el montaje es la lección. Aquí está rust/kmp-core/Cargo.toml:

[package]
name = "kmp-core"
version = "0.1.0"
edition = "2021"

[lib]
name = "kmp_core"
crate-type = ["cdylib", "staticlib"]

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

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

Dos tipos de crate porque las plataformas cargan el código de forma distinta. Android hace dlopen de un objeto compartido, así que le toca el cdylib. La cadena de herramientas de Apple prefiere enlazar el código nativo dentro del propio binario final, así que iOS recibe el staticlib. El binario uniffi-bindgen es el patrón estándar de UniFFI para generar bindings desde tu propio crate (UniFFI tutorial), y rust/kmp-core/uniffi-bindgen.rs son tres líneas:

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

La biblioteca en rust/kmp-core/src/lib.rs expone la misma lógica dos veces, una mediante UniFFI para la ruta de bindings generados y otra como una función de C plana para la ruta de cinterop:

uniffi::setup_scaffolding!();

#[derive(uniffi::Record)]
pub struct ReceiptSummary {
    pub items: u32,
    pub total_cents: i64,
}

#[uniffi::export]
pub fn summarize_receipts(amounts_cents: Vec<i64>) -> ReceiptSummary {
    ReceiptSummary {
        items: amounts_cents.len() as u32,
        total_cents: amounts_cents.iter().sum(),
    }
}

/// C entry point for the Kotlin/Native cinterop route on iOS.
#[no_mangle]
pub extern "C" fn kmp_core_sum_cents(amounts: *const i64, len: usize) -> i64 {
    if amounts.is_null() || len == 0 {
        return 0;
    }
    let amounts = unsafe { std::slice::from_raw_parts(amounts, len) };
    amounts.iter().sum()
}

La compilación de Android es el comando que ya has tecleado catorce veces, apuntando al módulo compartido en lugar de a un módulo de app:

cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \
  -o shared/src/androidMain/jniLibs build --release -p kmp-core

Una línea dentro del bloque android que ya existe en shared/build.gradle.kts le enseña a AGP dónde viven esas bibliotecas:

sourceSets["main"].jniLibs.srcDir("src/androidMain/jniLibs")

Construir el XCFramework para iOS

Rust trata los destinos de iOS de Apple como Tier 2, igual que los de Android (Platform Support): aarch64-apple-ios para dispositivos, aarch64-apple-ios-sim y x86_64-apple-ios para simuladores. No puedes fusionar los tres con lipo, porque el dispositivo y la slice del simulador de Apple Silicon comparten arquitectura y un binario fat no puede guardar ambos. Esa colisión es exactamente la razón por la que Apple introdujo el formato XCFramework (Apple Developer). La herramienta antigua cargo-lipo es anterior; hoy quieres xcodebuild -create-xcframework.

Toda la compilación es un script en scripts/build-ios-core.sh, ejecutado desde la raíz del repositorio en un Mac:

#!/usr/bin/env bash
set -euo pipefail

TARGETS=(aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios)
for target in "${TARGETS[@]}"; do
  rustup target add "$target"
  cargo build -p kmp-core --release --target "$target"
done

# lipo only merges the two simulator slices; the device slice stays alone.
mkdir -p target/ios-sim-universal
lipo -create \
  target/aarch64-apple-ios-sim/release/libkmp_core.a \
  target/x86_64-apple-ios/release/libkmp_core.a \
  -output target/ios-sim-universal/libkmp_core.a

rm -rf target/KmpCore.xcframework
xcodebuild -create-xcframework \
  -library target/aarch64-apple-ios/release/libkmp_core.a \
  -headers rust/kmp-core/include \
  -library target/ios-sim-universal/libkmp_core.a \
  -headers rust/kmp-core/include \
  -output target/KmpCore.xcframework

Suelta el KmpCore.xcframework resultante en el proyecto de Xcode y Xcode elige la slice correcta por destino. En CI este script encaja en el job del runner de macOS de la parte 14 sin cambios.

Ruta uno: UniFFI genera Kotlin y Swift desde una interfaz

El argumento más fuerte a favor de UniFFI en un proyecto KMP es que el mismo Rust anotado produce los dos bindings. Un archivo de configuración en rust/kmp-core/uniffi.toml nombra la salida de cada lenguaje (Kotlin config, Swift config):

[bindings.kotlin]
package_name = "dev.davthecoder.rustseries.rust"
cdylib_name = "kmp_core"

[bindings.swift]
module_name = "KmpCore"

La generación lee los metadatos de la biblioteca compilada, así que primero compilas para el host y luego ejecutas bindgen dos veces:

cargo build -p kmp-core --release

cargo run -p kmp-core --bin uniffi-bindgen -- generate \
  --library target/release/libkmp_core.dylib \
  --language kotlin \
  --out-dir shared/src/androidMain/kotlin

cargo run -p kmp-core --bin uniffi-bindgen -- generate \
  --library target/release/libkmp_core.dylib \
  --language swift \
  --out-dir ios/Generated

El Kotlin aterriza en androidMain, necesita la dependencia JNA de la parte 3 y funciona de inmediato. El código común define después la API que quiere en shared/src/commonMain/kotlin/dev/davthecoder/rustseries/ReceiptCore.kt usando expect/actual (Kotlin docs):

package dev.davthecoder.rustseries

data class Receipts(val items: Int, val totalCents: Long)

expect fun summarizeReceipts(amountsCents: List<Long>): Receipts

El actual de Android en shared/src/androidMain/kotlin/dev/davthecoder/rustseries/ReceiptCore.android.kt está a un import de estar listo:

package dev.davthecoder.rustseries

import dev.davthecoder.rustseries.rust.summarizeReceipts as rustSummarizeReceipts

actual fun summarizeReceipts(amountsCents: List<Long>): Receipts {
    val summary = rustSummarizeReceipts(amountsCents)
    return Receipts(
        items = summary.items.toInt(),
        totalCents = summary.totalCents,
    )
}

En iOS hay un matiz que conviene entender antes de comprometerte con esta ruta. La salida Swift de UniFFI es un módulo Swift, y Kotlin/Native no puede importar Swift, solo C y Objective-C (Kotlin docs). Así que el Swift generado es para tu capa SwiftUI, no para iosMain. Si las llamadas a Rust viven en el edge de la app, es perfecto: define una interfaz en commonMain, impleméntala en Swift encima de los bindings generados e inyéctala en el código compartido al arrancar. Element X trabaja a esta altura. Pero si la propia lógica de commonMain tiene que llamar a Rust en iOS, necesitas una superficie en C, que es la ruta dos.

Empaquetar el Swift generado para que compile de verdad

La ejecución de bindgen para Swift de arriba produjo tres archivos, no uno: KmpCore.swift, la API a la que llama el código de tu app, más KmpCoreFFI.h y KmpCoreFFI.modulemap, que declaran los símbolos de C contra los que se enlaza ese archivo Swift (UniFFI Swift docs). El script de compilación de antes empaqueta solo el kmp_core.h escrito a mano, así que, tal como está escrito, KmpCore.swift no tiene un módulo KmpCoreFFI que importar y la capa SwiftUI no compila. Ejecuta bindgen antes del script y luego añade dos copias a scripts/build-ios-core.sh justo encima de la llamada a xcodebuild -create-xcframework:

cp ios/Generated/KmpCoreFFI.h rust/kmp-core/include/
cp ios/Generated/KmpCoreFFI.modulemap rust/kmp-core/include/module.modulemap

El renombrado de la segunda copia importa: Xcode solo recoge un modulemap dentro del directorio Headers de un framework cuando se llama module.modulemap. KmpCore.swift no entra en el framework en absoluto. Añádelo a tu target de app como un archivo fuente normal.

Ruta dos: expect/actual sobre JNI y cinterop

Para la ruta de cinterop escribes tú la cabecera de C, que es también la disciplina que mantiene la superficie pequeña. Este es rust/kmp-core/include/kmp_core.h, el mismo directorio que el script del XCFramework ya empaqueta:

#pragma once

#include <stdint.h>
#include <stddef.h>

int64_t kmp_core_sum_cents(const int64_t *amounts, size_t len);

Kotlin/Native se entera de ella mediante un archivo de definición en shared/src/nativeInterop/cinterop/kmpcore.def:

headers = kmp_core.h
package = dev.davthecoder.rustseries.cinterop

El cableado va dentro del bloque kotlin de shared/build.gradle.kts: cada destino de iOS recibe el cinterop para generar los stubs y una ruta de enlazador hacia su slice de Rust correspondiente:

listOf(
    iosArm64() to "aarch64-apple-ios",
    iosSimulatorArm64() to "aarch64-apple-ios-sim",
    iosX64() to "x86_64-apple-ios",
).forEach { (iosTarget, rustTriple) ->
    iosTarget.compilations.getByName("main").cinterops.create("kmpcore") {
        definitionFile.set(project.file("src/nativeInterop/cinterop/kmpcore.def"))
        includeDirs(rootDir.resolve("rust/kmp-core/include"))
    }
    iosTarget.binaries.framework {
        baseName = "Shared"
        isStatic = true
        linkerOpts(
            "-L${rootDir.resolve("target/$rustTriple/release")}",
            "-lkmp_core",
        )
    }
}

Ahora iosMain puede llamar a la función de Rust directamente. El actual vive en shared/src/iosMain/kotlin/dev/davthecoder/rustseries/ReceiptCore.ios.kt:

package dev.davthecoder.rustseries

import dev.davthecoder.rustseries.cinterop.kmp_core_sum_cents
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.convert
import kotlinx.cinterop.toCValues

@OptIn(ExperimentalForeignApi::class)
actual fun summarizeReceipts(amountsCents: List<Long>): Receipts {
    val total = kmp_core_sum_cents(
        amountsCents.toLongArray().toCValues(),
        amountsCents.size.convert(),
    )
    return Receipts(items = amountsCents.size, totalCents = total)
}

Fíjate en lo que la frontera de C le hizo al diseño: el número de ítems nunca cruza, porque nunca necesitó Rust. Las API de C escritas a mano te empujan hacia búferes planos y retornos primitivos, los mismos instintos que la parte 6 construyó para JNI. Eso está bien para un puñado de puntos de entrada. En el momento en que tu API crece con records, errores y callbacks en las dos plataformas, mantener una cabecera de C más dos puentes a mano cuesta más que el código generado de UniFFI, y el punto de cruce llega antes de lo que esperas.

Cuándo saltarte Rust por completo

Esta es la mitad del título que la mayoría de los artículos sobre Rust no van a escribir, así que lo hago yo. Después de catorce partes de maquinaria, aquí van las situaciones en las que te diría que no uses nada de esto.

  • Tu app es un cliente fino sobre una API. Pantallas CRUD, formularios, un backend que hace el trabajo de verdad. No hay cómputo que Rust pueda acelerar. KMP solo comparte todo lo que merece la pena compartir, y KMP ya llega a plataformas tan alejadas como tvOS sin una línea de código nativo.
  • Nadie del equipo ha puesto Rust en producción. Los propios datos de reentrenamiento de Google dicen que cerca de un tercio de quienes desarrollan en C++ se sienten productivos en Rust en dos meses, y cerca de la mitad en cuatro (The Register, 2024). Eso es un coste real incluso para equipos que ya escriben código nativo. Si partes solo de Kotlin, es mayor, y no compra nada si no hay un cuello de botella nativo en el que gastarlo.
  • Kotlin ya tiene la biblioteca. kotlinx.serialization, Ktor, SQLDelight y el ecosistema de corrutinas cubren bien la mayor parte de la infraestructura de una app. La parte 11 fue explícita: serde solo gana pasado un umbral de payload medido. Reescribir Kotlin que funciona en Rust por pureza es ingeniería negativa. Casi todo lo que hace un producto móvil, autenticación, facturación, sincronización, almacenamiento, es un problema de KMP, no de Rust.
  • Publicas una sola plataforma. El argumento del núcleo compartido es la razón más fuerte para aceptar la complejidad de FFI. Solo Android, una app de Kotlin a secas, o Kotlin más un crate JNI pequeño donde el perfilado lo exija, gana a un sándwich de KMP y Rust que construiste para una app de iOS que aún no existe.
  • No puedes hacerte cargo de una frontera FFI durante años. Las disciplinas de la parte 7, la parte 8 y la parte 9 son un impuesto permanente sobre cada cambio que cruza la frontera. Si el equipo no puede presupuestar ese mantenimiento, la frontera se pudrirá hasta convertirse en los informes de crash que debía evitar.

La regla del artículo del hub no se ha movido en quince partes: Kotlin sigue siendo el valor por defecto, y el código nativo entra solo después de que el perfilado demuestre que la JVM es el muro. Rust cambió qué lenguaje gana una vez pasas ese umbral. No movió el umbral.

La serie, de principio a fin

Aquí está el espinazo completo, en orden de lectura:

  1. La configuración completa de la cadena de herramientas de Rust para Android: rustup, cargo-ndk y tu primer .so en un APK
  2. Llamar a Rust desde Kotlin: fundamentos de JNI para quien desarrolla en Android
  3. UniFFI: bindings de Kotlin para Rust sin escribir JNI a mano
  4. Estructura del proyecto: Gradle y Cargo en una sola compilación, splits de ABI y variantes de debug y release
  5. Procesado rápido de imágenes en Android con Rust: bitmaps, filtros y la vía rápida de JNI
  6. Pasar datos entre Kotlin y Rust: cadenas, arrays, ByteBuffers y cero copias
  7. Gestión de memoria a través de JNI: ownership, Box::into_raw, referencias globales y fugas
  8. Gestión de errores y panics: que los crashes de Rust no se cuelen en tu app de Android
  9. Hilos y asincronía: Tokio, corrutinas de Kotlin y devolver la llamada a Kotlin desde Rust
  10. DSP de audio de baja latencia con Rust: AAudio y Oboe desde un núcleo de Rust
  11. Parsing pesado con Serde: llevar el trabajo de JSON y CSV a Rust
  12. Criptografía con Rust en Android: ring y RustCrypto en lugar de escribir la tuya
  13. Tamaño del binario y el requisito de páginas de 16 KB: optimizar los .so de Rust para release
  14. CI/CD para Rust y Android: GitHub Actions, caché de compilación cruzada y builds reproducibles
  15. Rust en Kotlin Multiplatform: un núcleo nativo para Android e iOS, y cuándo saltarte Rust por completo, este artículo.

Preguntas frecuentes

¿Puede Kotlin Multiplatform llamar a código Rust?

Sí, en las dos plataformas. En Android, el código compartido llama a Rust mediante Kotlin generado por UniFFI o JNI escrito a mano en el source set androidMain. En iOS, Kotlin/Native importa la cabecera de C del crate mediante cinterop, o la capa Swift consume el Swift generado por UniFFI y lo inyecta detrás de una interfaz de commonMain.

¿Debería usar Rust o Kotlin Multiplatform para compartir código entre Android e iOS?

Normalmente solo KMP, y a veces los dos, porque resuelven capas distintas. KMP comparte lógica de negocio, view models e infraestructura en Kotlin. Rust solo entra para un núcleo de cómputo que de todas formas tiene que ser nativo, como DSP, cripto o parsing pesado, y se sienta debajo de la capa de KMP, no en su lugar.

¿UniFFI genera bindings tanto de Kotlin como de Swift?

Sí. Una interfaz de Rust anotada genera bindings de Kotlin, Swift, Python y Ruby, configurados por lenguaje en uniffi.toml (UniFFI docs). Element X publica así todo su motor de Matrix para Android e iOS desde una sola codebase Rust.

¿Necesito un Mac para compilar el lado de iOS de un núcleo Rust para KMP?

Sí. Compilar de forma cruzada el propio Rust hacia destinos de Apple necesita los SDK de Apple, y empaquetar el XCFramework requiere xcodebuild, que solo se ejecuta en macOS. En la práctica eso significa un job de macOS en tu matriz de CI, el mismo reparto de runners que la parte 14.

Quince partes después

Esta serie empezó con un crate vacío y un solo comando cargo ndk. Se cierra con ese mismo crate compilado para un segundo sistema operativo, sigue sin la bendición de Google y sigue sin salir de Kotlin, que era el argumento desde el principio.

Si estás sopesando esta pila para un producto real, esa decisión es lo que hago en las revisiones de arquitectura y la mentoría, y la construcción en sí es el tipo de trabajo que asumo como ingeniero independiente. El artículo del hub tiene el mapa de las quince partes.

Fuentes

Share:Compartir:

CommentsComentarios

Loading comments…Cargando comentarios…