Skip to content
Project Structure: Gradle + Cargo in One Build, ABI Splits, Debug/Release Flavors
rust-native

Project Structure: Gradle + Cargo in One Build, ABI Splits, Debug/Release Flavors

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

Updated 14 August 202612 min read
AndroidRustNDKGradlecargo-ndkBuild Systems
Share:

Rust + NDK on Android, Part 4 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: UniFFI: Kotlin Bindings for Rust Without Writing JNI by Hand. Next: Fast Image Processing on Android with Rust: Bitmaps, Filters, and the JNI Fast Path.

The goal of this post is a build where you stop typing cargo commands. When it’s done, ./gradlew assembleDebug compiles your Rust workspace for every ABI you target, drops the .so files where the Android Gradle Plugin expects them, and skips the whole step when no Rust source changed. The Gradle and Cargo integration is small: one Exec task per build type that runs cargo-ndk, with inputs and outputs declared so Gradle’s up-to-date checks apply, hooked in right before AGP merges native libraries.

For the first three parts we ran cargo-ndk by hand, and for one crate that was fine. It stops being fine the day a teammate clones the project, presses run, and gets an UnsatisfiedLinkError because nobody told them about the manual step. Build steps that live in a README instead of the build graph always rot, so this part moves the Rust build into Gradle where it belongs.

This assumes the toolchain from Part 1 and the crates built in Part 2 and Part 3. Everything here is Kotlin DSL; if your project is still on Groovy, my Gradle to Gradle.kts conversion guide covers that migration.

Key Takeaways

  • A registered Exec task that runs cargo-ndk, with its inputs and outputs declared, is all Gradle needs to build Rust automatically and skip the step when nothing changed.
  • Point cargo-ndk’s output at app/build/rustJniLibs/<buildType> and register that directory as a jniLibs source set. Generated .so files never touch src/ or git.
  • Cargo’s dev and release profiles map directly onto Android’s debug and release build types. Keep debug = true and strip = "none" in [profile.release] so production crashes still symbolicate.
  • Ship all four ABIs through an Android App Bundle and Play delivers exactly one per device, with an average download size saving of 15 percent over a universal APK (Android Developers).

What a Gradle and Cargo integration needs to do

Gradle and cargo are both complete build systems, and neither knows the other exists. Gradle owns the APK: Kotlin compilation, resource merging, native library packaging, signing. Cargo owns the Rust: dependency resolution, incremental compilation, the target directory. An integration is a treaty between the two, with four clauses.

First, the Rust build must run before AGP collects native libraries. Second, the output must land in a directory AGP treats as a jniLibs source, separated per build type. Third, the Rust step must participate in Gradle’s up-to-date checking, or every Kotlin-only change pays the cargo toll. Fourth, Android’s debug and release build types must select the matching cargo profile.

You can get all four from Mozilla’s rust-android-gradle plugin, the one Firefox for Android builds with. I prefer forty lines of plain Kotlin DSL, every line visible in your own repo. That is what this post builds, and the same wiring runs unchanged in CI in Part 14, CI/CD for Rust + Android: GitHub Actions, Cross-Compilation Caching, Reproducible Builds.

Where the generated .so files should live

In Part 1 we pointed cargo-ndk at app/src/main/jniLibs, the directory AGP scans by default, and for a first proof of life that was the right call. As permanent structure it has two problems. Compiled binaries show up in git status next to your source code, and debug and release builds would overwrite each other in the same folder, and two tasks writing one output directory breaks Gradle’s up-to-date bookkeeping.

Generated files belong under build/. So the Rust output goes to app/build/rustJniLibs/debug and app/build/rustJniLibs/release, and each build type registers its own directory as an extra jniLibs source. Add this inside the android block of app/build.gradle.kts:

sourceSets {
    getByName("debug") {
        jniLibs.srcDir(layout.buildDirectory.dir("rustJniLibs/debug"))
    }
    getByName("release") {
        jniLibs.srcDir(layout.buildDirectory.dir("rustJniLibs/release"))
    }
}

Everything under app/build/ is already ignored by the standard Android .gitignore.

The Exec task: running cargo-ndk from Gradle

Here is the whole integration, at the bottom of app/build.gradle.kts, after the android block:

val rustDir = rootProject.file("rust")

val rustAbis = (findProperty("rustAbis") as String? ?: "arm64-v8a,armeabi-v7a,x86_64,x86")
    .split(",")

fun registerCargoBuild(buildType: String, extraCargoArgs: List<String>) =
    tasks.register<Exec>("cargoBuild${buildType.replaceFirstChar { it.uppercase() }}") {
        group = "build"
        description = "Compiles the Rust workspace for Android ($buildType)"
        workingDir = rustDir

        inputs.files(
            fileTree(rustDir) {
                include("**/*.rs", "**/Cargo.toml")
                exclude("target/**")
            }
        )
        inputs.file(File(rustDir, "Cargo.lock"))
        inputs.property("rustAbis", rustAbis)
        outputs.dir(layout.buildDirectory.dir("rustJniLibs/$buildType"))
        doFirst { delete(outputs.files) }

        commandLine(
            listOf("cargo", "ndk") +
                rustAbis.flatMap { listOf("-t", it) } +
                listOf(
                    "-o",
                    layout.buildDirectory.dir("rustJniLibs/$buildType").get().asFile.absolutePath,
                    "build",
                ) +
                extraCargoArgs
        )
    }

val cargoBuildDebug = registerCargoBuild("debug", emptyList())
val cargoBuildRelease = registerCargoBuild("release", listOf("--release"))

tasks.matching { it.name.matches(Regex("merge.*DebugJniLibFolders")) }.configureEach {
    dependsOn(cargoBuildDebug)
}
tasks.matching { it.name.matches(Regex("merge.*ReleaseJniLibFolders")) }.configureEach {
    dependsOn(cargoBuildRelease)
}

A few details in there earn their place. The inputs block covers every Rust source and manifest under rust/, plus the lock file. The exclude("target/**") line is not optional: cargo writes into target/ during the build, so if that directory counts as an input, the task modifies its own inputs and can never be up to date.

The inputs.property("rustAbis", ...) line registers the ABI list itself as an input; without it, switching from a one-ABI local build back to all four would report UP-TO-DATE with three ABIs missing. The doFirst { delete(outputs.files) } covers the opposite direction: narrowing the list would otherwise leave the dropped ABIs’ stale .so files in the output directory for the source set to package. Wiping the directory means what cargo-ndk produced this time is exactly what ships.

The outputs.dir declaration is the other half of the contract. With both declared, Gradle fingerprints file contents and skips the task when nothing changed (Gradle incremental build docs). When a Rust file did change, cargo’s own incremental compilation recompiles only the touched crates. The caches stack: a Kotlin-only edit costs nothing, a one-line Rust edit costs one crate.

The wiring at the end hooks each cargo task into AGP’s merge<Variant>JniLibFolders task, where AGP gathers native libraries from all source sets. Matching by regex means the wiring survives product flavors: a free flavor produces mergeFreeDebugJniLibFolders, which the same pattern catches. One terminology note: debug and release are build types, and product flavors are the orthogonal axis (free versus paid). The cargo profile keys off the build type because flavors almost never change how Rust compiles.

The rustAbis property exists for iteration speed. Compiling four ABIs on every debug build is wasted time when your test device only has one:

# everyday build, all four ABIs
./gradlew :app:assembleDebug

# faster local iteration, one ABI
./gradlew :app:assembleDebug -PrustAbis=arm64-v8a

arm64-v8a covers physical devices and the emulator on Apple Silicon; on an Intel machine the emulator wants x86_64. Put rustAbis=arm64-v8a in your local gradle.properties to make the narrow build your default, and leave CI building all four.

One gotcha before it bites: on macOS, Android Studio launched from the Dock does not inherit your shell’s PATH, so the Gradle daemon may not find cargo. Start Studio from a terminal once, or replace "cargo" in commandLine with its absolute path under ~/.cargo/bin.

One Cargo workspace, one lock file

With one crate, rust/ can just be that crate. The moment you have two, you want a workspace: a single target/ directory shared by all crates, a single Cargo.lock, and one place to pin dependency versions (Cargo book, Workspaces). This series adds a crate per part. The root manifest at rust/Cargo.toml:

[workspace]
resolver = "2"
members = [
    "hello",
    "jni-basics",
    "uniffi-demo",
]

[workspace.package]
version = "0.1.0"
edition = "2021"

[workspace.dependencies]
jni = "0.21"
uniffi = "0.29"

Each member crate then inherits instead of repeating. Here is rust/hello/Cargo.toml from Part 1, rewritten for the workspace:

[package]
name = "hello"
version.workspace = true
edition.workspace = true

[lib]
crate-type = ["cdylib"]

[dependencies]
jni.workspace = true

Running cargo ndk ... build at the workspace root builds every member and copies each produced cdylib into the output directory, so the Gradle task never changes as crates accumulate. Version bumps happen once, in [workspace.dependencies]. And one rule surprises people: cargo reads profile settings only from the workspace root manifest and ignores [profile.*] sections in member crates (Cargo book, Profiles).

Migrating the Part 3 wiring

If you followed Part 3, app/build.gradle.kts still has its temporary wiring: a cargoNdkBuild task writing release .so files into app/src/main/jniLibs, and a generateUniffiBindings task reading the compiled library from the crate’s own target directory. I promised there that Part 4 would replace it.

First, delete the cargoNdkBuild task and the dependsOn(cargoNdkBuild) line inside generateUniffiBindings; cargoBuildDebug and cargoBuildRelease replace it. Then delete app/src/main/jniLibs/. Skip either step and the build carries two copies of libuniffi_demo.so, one in src/main/jniLibs and one in build/rustJniLibs, and AGP’s merge step fails with 2 files found with path 'lib/arm64-v8a/libuniffi_demo.so'.

Next, rewire generateUniffiBindings. The workspace move broke the path it reads, because every crate now compiles into the shared rust/target. Bindgen only inspects the library’s metadata, so the debug build is enough:

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

The preBuild hook on generateUniffiBindings stays; the generated Kotlin has to exist before Kotlin compilation starts. Only the cargo build moved out from under it.

Mapping cargo profiles to Android build types

Cargo’s defaults line up with Android’s build types almost perfectly. The dev profile compiles at opt-level = 0 with full debug info; the release profile compiles at opt-level = 3 (Cargo book, Profiles). Our two Gradle tasks already select them: the debug task runs plain cargo ndk build, the release task appends --release. Two adjustments are worth making, both in the workspace root rust/Cargo.toml:

[profile.dev]
opt-level = 1

[profile.release]
lto = "thin"
codegen-units = 1
debug = true
strip = "none"

The dev tweak addresses a real trap. Unoptimised Rust is slow, often slower than the JIT-compiled Kotlin you wrote it to replace, and a debug build is exactly where you’ll first demo the feature. opt-level = 1 keeps compile times and debuggability close to the default while taking the embarrassment out of debug-build performance. If a debug build ever makes your Rust look worse than Kotlin, check the profile before doubting the approach.

The release settings pull in two directions on purpose. lto = "thin" and codegen-units = 1 trade compile time for a faster, smaller binary, a trade you want for a library users download; Part 13, Binary Size and the 16KB Page Requirement: Optimizing Rust .so Files for Release, goes deeper on size. debug = true with strip = "none" looks like it fights that, but it doesn’t cost your users anything: since Rust 1.77, cargo strips debug info from release builds by default (Rust Blog, Announcing Rust 1.77.0), and without symbols a production tombstone is a wall of hex. Keeping the fat .so in rust/target/ gives ndk-stack something to symbolicate against, while AGP strips the copies it actually packages into the app.

ABI splits: you build four, each user downloads one

The NDK defines four supported ABIs: arm64-v8a, armeabi-v7a, x86_64 and x86 (Android Developers, ABIs). In practice arm64-v8a is every phone that matters, armeabi-v7a covers a shrinking tail of 32-bit devices, and the two x86 ABIs exist for emulators and ChromeOS. Google Play has required 64-bit support since August 2019 (Android Developers, 64-bit requirement), so arm64-v8a is non-negotiable and the rest are business decisions.

Compiling a Rust core four times multiplies its size in the artifact, so delivery format matters. New apps have had to publish as Android App Bundles since August 2021, and from your .aab Play generates split APKs so each device downloads only its own ABI, with an average download size saving of 15 percent versus a universal APK (Android Developers, About Android App Bundles). Build all four ABIs, upload one bundle, and every user installs exactly one copy of your Rust.

Distribution outside Play still deals in APKs, and there the splitting is your job. For Firebase App Distribution, F-Droid or direct downloads, this splits block inside the android block of app/build.gradle.kts produces one APK per ABI plus an optional universal fallback:

splits {
    abi {
        isEnable = true
        reset()
        include("arm64-v8a", "armeabi-v7a", "x86_64")
        isUniversalApk = true
    }
}

Either way, your CI pays the compile cost once per release; no user pays it in download size.

What git ignores and what it keeps

The .gitignore additions for the Rust side of the repo are short. At the repository root:

# Cargo build artifacts, shared by all workspace members
/rust/target/

# stray copies from manual cargo-ndk runs:
app/src/main/jniLibs/

rust/target/ grows to gigabytes and regenerates from source; it never belongs in history. The jniLibs line is belt and braces after the Part 3 cleanup: a manual cargo-ndk run can still drop a stray copy there. I’d avoid a global *.so ignore, though. The day you vendor a prebuilt third-party library, a blanket rule like that silently drops it from the repo.

One file that does belong in git: Cargo.lock. Committing the lock file is the Cargo team’s own guidance for application builds, because it pins the exact dependency versions every build uses (Cargo book, Cargo.toml vs Cargo.lock). It is also why the Gradle task lists it as an input: cargo update changes the lock file and the Rust build correctly reruns. In Part 14 it becomes the CI cache key.

Frequently Asked Questions

How do I make Gradle build my Rust code automatically?

Register an Exec task that runs cargo-ndk, declare your Rust sources and Cargo.lock as inputs and the output directory as an output, then make AGP’s merge<Variant>JniLibFolders tasks depend on it. After that, ./gradlew assembleDebug builds Kotlin and Rust in one pass and skips the Rust step when nothing changed.

Do I need the rust-android-gradle plugin?

No. Mozilla’s plugin works and Firefox for Android ships with it, but a forty-line Exec task gives the same result without adding a dependency. The plugin earns its keep when many modules share the wiring; for a single workspace, the hand-rolled task is easier to audit when it misbehaves.

Why does Gradle rebuild my Rust code on every build?

Almost always one of three declaration mistakes. An Exec task has no inputs or outputs by default, so without them Gradle must assume it’s always stale. Including rust/target/ in the inputs makes the task modify its own inputs, so it can never be up to date. And two tasks sharing one output directory breaks the fingerprinting for both. Fix all three and unchanged builds report UP-TO-DATE.

Should I commit Cargo.lock or the compiled .so files?

Commit Cargo.lock, never the .so files. The lock file pins exact dependency versions, which makes builds reproducible across machines and CI. The .so files regenerate on every build, and with output under app/build/ they are ignored automatically.

A build you stop thinking about

The measure of this post is that nobody notices its work. A teammate clones the repo, presses run, and the Rust compiles like any other module; nothing about the native layer demands attention until someone changes it. That invisibility is also why this layer costs so much to retrofit later.

With the plumbing settled, the series can cash the performance cheque. Part 5, Fast Image Processing on Android with Rust: Bitmaps, Filters, and the JNI Fast Path, locks an Android Bitmap’s pixels from Rust and runs filters over the raw buffer, using this exact project structure. The full series lives at the hub.

Sources

Share:

Comments

Loading comments…