Rust + NDK on Android, Part 13 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: Cryptography with Rust on Android: ring and RustCrypto Instead of Rolling Your Own. Next: CI/CD for Rust + Android: GitHub Actions, Cross-Compilation Caching, Reproducible Builds.
Your Rust .so has two release problems, and both are solved in build configuration. The first is compliance: since November 1, 2025, Google Play requires 16KB page size support from every new app and every update that targets Android 15 (API level 35) or higher on 64-bit devices (Android Developers, 2025). The second is waste: a default release build of even a small Rust crate carries formatting machinery, unwinding tables and monomorphized generics that nobody asked for. Not one line of crate source changes in this entire post.
The alignment half is quick, because the series already did most of the work: Part 1 pinned NDK r28, which links 16KB-aligned by default, and made llvm-readelf part of the build routine. The size half takes longer. Four Cargo profile levers do most of the work, cargo-bloat names the dependency eating your megabytes when the levers are not enough, and a measurement script keeps the whole exercise honest. The only prerequisite is the toolchain from Part 1, which is also why this part can be read out of order if the Play deadline is what brought you here.
Key Takeaways
- Since November 1, 2025, Google Play requires 16KB page size support for new apps and updates targeting Android 15 or higher. The requirement covers the arm64-v8a and x86_64
.sofiles in your app, including ones you did not build (Android Developers).- NDK r28 links 16KB-aligned by default. On r27 or older, Rust needs
-Wl,-z,max-page-size=16384passed throughrustflags, andllvm-readelf -lWproves the result either way.- Four profile levers do almost all the size work:
lto = "fat",codegen-units = 1,opt-level = "z"andpanic = "abort". The last one kills thecatch_unwindfirewall from Part 8, so it is a trade, not a freebie.- The Android Gradle Plugin strips the native libraries it packages. Keep symbols in the cargo output for ndk-stack, and measure the stripped copy, because that is what users download.
What Android’s 16KB page size requirement demands from a Rust library
A page is the unit the kernel manages memory in, and every Android device you have ever shipped to used 4KB pages. Android 15 added OS support for 16KB pages because larger pages mean fewer page faults and less kernel bookkeeping. Google measured the payoff on 16KB devices: app launch times improved 3.16 percent on average under memory pressure, some apps gained up to 30 percent, and system boot improved by roughly 8 percent, about 950 milliseconds (Android Developers, 2025).
The catch lives in your ELF files. A shared library records, in its program headers, the alignment its LOAD segments were linked for. A library linked for 4KB pages cannot be mapped by a 16KB kernel, and the failure is not subtle: System.loadLibrary throws on a 16KB device and the app is dead on first launch. That is why Play enforces the requirement at submission time instead of letting users find out.
Scope matters here. The requirement applies to 64-bit ABIs, arm64-v8a and x86_64, and it applies to every .so in the bundle, not just the ones you compiled. A Kotlin-only app that pulls in an SDK with native code is on the hook exactly as much as this series is. For your Rust crates, compliance is a property of the toolchain, not of the code: the right NDK gives it to you for free, and an old NDK can be fixed with one linker flag.
Verify the alignment before Play does
Part 1 made this check part of the routine, and it does not change now. Point llvm-readelf at each library that ships, here the parser crate from Part 11:
llvm-readelf -lW app/src/main/jniLibs/arm64-v8a/libparser.so | grep LOAD
The final column of each LOAD row is the segment alignment. 0x4000 is 16384 bytes, which passes. 0x1000 is 4096 bytes, which fails on a 16KB device. Part 1 shows the full captured output for a healthy library; the shape is identical for every crate in this series.
The per-file check is necessary but not sufficient, because your APK contains libraries you did not build. Three whole-app checks close the gap. Android Studio’s APK Analyzer flags misaligned .so files in the lib folder. Google publishes a check_elf_alignment.sh script that sweeps an entire APK (Android Developers). And zipalign verifies the packaging side, which matters because uncompressed native libraries must also sit at 16KB offsets inside the zip:
zipalign -v -c -P 16 4 app-release.apk
If a third-party SDK fails these checks, your only fix is a vendor update. File the ticket early; you cannot relink someone else’s binary.
Fixing alignment from the Rust build
On NDK r28 or newer there is nothing to do. The linker emits 16KB-aligned segments by default, which is the reason Part 1 pinned 28.2.13676358 for the whole series. If a company toolchain pins you to r27 or older, pass the alignment flags to the linker yourself. For Rust the clean place is a cargo config file at rust/.cargo/config.toml, which applies to every crate in the workspace:
[target.aarch64-linux-android]
rustflags = [
"-C", "link-arg=-Wl,-z,max-page-size=16384",
"-C", "link-arg=-Wl,-z,common-page-size=16384",
]
[target.x86_64-linux-android]
rustflags = [
"-C", "link-arg=-Wl,-z,max-page-size=16384",
"-C", "link-arg=-Wl,-z,common-page-size=16384",
]
max-page-size is the correctness flag: it sets the LOAD alignment the kernel checks. common-page-size is a layout optimization the Android docs recommend alongside it (Android Developers). Only the two 64-bit targets need entries, because the Play requirement covers 64-bit devices.
The Gradle side has its own version floor. The Android Gradle Plugin zipaligns uncompressed native libraries to 16KB boundaries from AGP 8.5.1 onward, and versions between 8.3 and 8.5 align the libraries but leave a bundletool gap that can still trip Play (Android Developers). The build wiring from Part 4 already sits above that floor. Whatever you change, re-run the readelf check afterwards; trust the ELF header, not the changelog.
Cutting the binary size: the release profile
Now the second problem. Rust links its standard library statically into every cdylib, keeps panic and formatting machinery around by default, and stamps out a fresh copy of each generic function per concrete type. There is no R8 for native code: LLVM and the linker are your shrinker, and they only remove what the profile allows. The levers live in the workspace rust/Cargo.toml, and this is the block the series ships:
[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "unwind"
debug = true
strip = "none"
# audiofx stays speed-first: size wins must never cost audio deadlines.
[profile.release.package.audiofx]
opt-level = 3
Each line is a decision, so here is each decision (The Cargo Book, Profiles):
lto = "fat" runs link-time optimization across the whole program, inlining and discarding dead code across crate boundaries. It is the closest native analogue to R8’s whole-program shrinking, and it usually moves the number more than any other single line. The price is link time, which is why it belongs in release only.
codegen-units = 1 compiles the crate as one LLVM module instead of up to sixteen parallel ones. Optimization sees everything at once, output shrinks, and the build gets slower. Release builds pay that gladly.
opt-level = "z" optimizes for size instead of speed. For boundary glue and parsers the speed cost is irrelevant. For the DSP core from Part 10 it is not, which is what the per-package override is for: audiofx keeps opt-level = 3 while everything else shrinks. Package overrides handle exactly this split so you never trade audio deadlines for kilobytes.
panic = "unwind" is the default, spelled out because it is a real choice. Switching to panic = "abort" deletes unwinding tables and landing pads, and in my builds it is often the largest single lever after LTO. It also breaks things this series built on purpose: the catch_unwind firewall from Part 8 stops working, and UniFFI’s panic-to-exception mapping needs unwinding too. My position is unchanged from Part 8: for code living inside an app process I keep unwinding and spend the size. If every boundary in your app maps errors through Result and you accept that any surviving bug becomes a native crash, abort is yours to take.
debug = true with strip = "none" looks like it belongs in the opposite playbook, but it costs your users nothing. The Android Gradle Plugin strips the native libraries it packages, so the fat symbols never reach the APK unless you opt out with keepDebugSymbols. Cargo itself has stripped debuginfo from release builds by default since Rust 1.77, so being explicit here keeps the unstripped copy that ndk-stack needs for symbolication, exactly as the hub’s debugging section sets up. The stripped copy goes to users; the fat one stays with you for the day a crash report needs it.
Finding the offenders with cargo-bloat
The profile shrinks what the compiler generates. It cannot fix a dependency tree that generates too much, and guessing at the culprit wastes afternoons. cargo-bloat reads the compiled artifact and attributes size to functions and crates. It analyzes cdylib targets directly, which is exactly what we build:
cargo install cargo-bloat
cargo ndk -t arm64-v8a bloat --release --lib --crates -n 12
Running it through cargo ndk keeps the analysis on the artifact you actually ship. If your cargo-ndk version refuses a non-build subcommand, run cargo bloat --release --lib --crates against the host build instead; the absolute numbers differ, the ranking rarely does. Drop --crates to see individual functions, which is where monomorphization shows up as the same generic repeated for every concrete type it was called with. The tool’s own documentation calls the attribution guesswork, so treat it as a ranking, not a ledger.
What you do with the ranking is ordinary dependency hygiene. Most oversized Rust libraries are fat because of default features nobody turned off, not because of the author’s code. default-features = false on a heavy dependency, followed by adding back only the features you use, is the highest-value edit in this whole post for many real crates. The serde and tokio setups from Part 11 and Part 9 already follow this rule. Past that, the recurring offenders are a regex engine pulled in for one literal match, and derive macros generating serialization for types that never cross the boundary. Nightly’s -Z build-std can rebuild the standard library with your size flags; for an app library the maintenance cost outweighs the savings.
Measuring the playbook: scripts/size-report.sh
Every size claim in this series has to survive the same rule as every benchmark claim: measured or not stated. The methodology is one lever at a time, applied cumulatively, with all four levers pinned explicitly at every step so each delta is attributable to exactly one change. The measured artifact is the stripped arm64 library, because stripping is what AGP does before packaging, and the stripped copy is what a user downloads. Save the following as scripts/size-report.sh next to your workspace and it walks the whole ladder:
#!/usr/bin/env bash
# Measures the stripped arm64 parser library after each size lever.
# Levers are cumulative; every step pins all four so deltas are attributable.
set -euo pipefail
ABI="arm64-v8a"
CRATE="rust/parser"
SO="libparser.so"
STRIP=("$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/llvm-strip)
OUT="$(mktemp -d)"
measure() {
local label="$1"
shift
env "$@" cargo ndk -t "$ABI" -o "$OUT" build --release \
--manifest-path "$CRATE/Cargo.toml" >/dev/null 2>&1
"${STRIP[0]}" "$OUT/$ABI/$SO"
printf '%-20s %10d bytes\n' "$label" "$(wc -c < "$OUT/$ABI/$SO")"
}
measure "baseline" \
CARGO_PROFILE_RELEASE_LTO=off \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \
CARGO_PROFILE_RELEASE_PANIC=unwind
measure "+ lto fat" \
CARGO_PROFILE_RELEASE_LTO=fat \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \
CARGO_PROFILE_RELEASE_PANIC=unwind
measure "+ codegen-units 1" \
CARGO_PROFILE_RELEASE_LTO=fat \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \
CARGO_PROFILE_RELEASE_PANIC=unwind
measure "+ opt-level z" \
CARGO_PROFILE_RELEASE_LTO=fat \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
CARGO_PROFILE_RELEASE_OPT_LEVEL=z \
CARGO_PROFILE_RELEASE_PANIC=unwind
measure "+ panic abort" \
CARGO_PROFILE_RELEASE_LTO=fat \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
CARGO_PROFILE_RELEASE_OPT_LEVEL=z \
CARGO_PROFILE_RELEASE_PANIC=abort
The CARGO_PROFILE_RELEASE_* environment variables override the profile in Cargo.toml per invocation, which is what lets one script walk the ladder without editing files between builds (The Cargo Book, Environment Variables). Change ABI and rerun to get the other architectures.
There is no results table under that script, on purpose. The deltas depend on your dependency tree far more than on anything I control, so my numbers would tell you little about your APK. Run the ladder against your own library instead: it takes a few minutes, and the output describes what you actually ship. Two things hold whatever it prints. Each ABI carries its own full copy of everything, which is why the app bundle setup from Part 4 matters, since a device downloads one ABI, not four. And the big steps come early. The last rung, panic = "abort", is there for apps that can afford it; mine keep unwinding.
Frequently Asked Questions
Does the 16KB page size requirement apply to armeabi-v7a?
No. Google Play’s requirement covers 64-bit devices, so it applies to the arm64-v8a and x86_64 libraries in your app (Android Developers). If you still ship armeabi-v7a for old devices, those libraries are out of scope, which is why the cargo config above only touches the two 64-bit targets.
Does panic = “abort” break the error handling from Part 8?
Yes, completely. catch_unwind only works while panics unwind, so the firewall from Part 8 silently becomes a process abort, and UniFFI’s panic-to-exception mapping stops working too. Take the size win only if every expected failure already crosses the boundary as a Result and you accept a native crash for genuine bugs.
Why is a Rust .so bigger than the equivalent C++ one?
Mostly linkage, not language. Rust links its standard library statically into each cdylib, while Android C++ builds usually share libc++_shared.so, so the C++ number hides a library the APK still carries. Add panic machinery and monomorphized generics, and an unoptimized comparison flatters C++. Compare stripped, LTO release builds before drawing conclusions.
My app is all Kotlin. Can I ignore the 16KB requirement?
Only if your APK contains no native code at all. Open the lib folder in APK Analyzer: analytics, database, ML and crypto SDKs routinely ship .so files you never compiled. If anything is in there and you target Android 15 or higher, the requirement applies to your next update, and your fix is updating the SDK that shipped the misaligned library.
Where this leaves you
Your libraries now pass the same readelf check on every ABI they ship for, and the release profile is six deliberate lines instead of defaults nobody chose. None of it touched crate source, which is what makes it durable: the whole playbook lives in two config files and one script that code review can actually see.
The weak point is that config drifts when only humans watch it. Part 14, CI/CD for Rust + Android: GitHub Actions, Cross-Compilation Caching, Reproducible Builds, puts these checks where they belong, in a pipeline that fails the build when alignment or size regresses. If you landed here from a Play Console warning rather than from Part 12, the series hub has the map of everything else.
Sources
- Android Developers, “Support 16 KB page sizes”, retrieved 2026-07-23, https://developer.android.com/guide/practices/page-sizes
- Android Developers, “zipalign”, retrieved 2026-07-23, https://developer.android.com/tools/zipalign
- The Cargo Book, “Profiles”, retrieved 2026-07-23, https://doc.rust-lang.org/cargo/reference/profiles.html
- The Cargo Book, “Environment Variables”, retrieved 2026-07-23, https://doc.rust-lang.org/cargo/reference/environment-variables.html
- The Cargo Book, “Configuration”, retrieved 2026-07-23, https://doc.rust-lang.org/cargo/reference/config.html
- crates.io, cargo-bloat, retrieved 2026-07-23, https://crates.io/crates/cargo-bloat
- crates.io, cargo-ndk, retrieved 2026-07-23, https://crates.io/crates/cargo-ndk
Rust + NDK on Android, Part 13 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: Cryptography with Rust on Android: ring and RustCrypto Instead of Rolling Your Own. Next: CI/CD for Rust + Android: GitHub Actions, Cross-Compilation Caching, Reproducible Builds.
Tu .so de Rust tiene dos problemas de release, y ambos se resuelven en la configuración de compilación. El primero es de cumplimiento: desde el 1 de noviembre de 2025, Google Play exige soporte de páginas de 16 KB en cada app nueva y en cada actualización que apunte a Android 15 (nivel de API 35) o superior en dispositivos de 64 bits (Android Developers, 2025). El segundo es desperdicio: una compilación de release por defecto, incluso de un crate pequeño de Rust, arrastra maquinaria de formato, tablas de unwinding y genéricos monomorfizados que nadie ha pedido. En todo este artículo no cambia ni una línea del código fuente del crate.
La mitad de la alineación es rápida, porque la serie ya hizo casi todo el trabajo: la parte 1 fijó el NDK r28, que enlaza alineado a 16 KB por defecto, e incorporó llvm-readelf a la rutina de compilación. La mitad del tamaño lleva más tiempo. Cuatro palancas del perfil de Cargo hacen casi todo el trabajo, cargo-bloat señala la dependencia que se come tus megabytes cuando las palancas no bastan, y un script de medición mantiene el ejercicio honesto. El único requisito previo es la cadena de herramientas de la parte 1, y por eso esta parte se puede leer fuera de orden si lo que te ha traído aquí es el plazo de Play.
Puntos clave
- Desde el 1 de noviembre de 2025, Google Play exige soporte de páginas de 16 KB en apps nuevas y actualizaciones que apunten a Android 15 o superior. El requisito cubre los
.sode arm64-v8a y x86_64 de tu app, incluidos los que no has compilado tú (Android Developers).- El NDK r28 enlaza alineado a 16 KB por defecto. En r27 o anterior, Rust necesita
-Wl,-z,max-page-size=16384pasado medianterustflags, yllvm-readelf -lWdemuestra el resultado en ambos casos.- Cuatro palancas del perfil hacen casi todo el trabajo de tamaño:
lto = "fat",codegen-units = 1,opt-level = "z"ypanic = "abort". La última anula el cortafuegos decatch_unwindde la parte 8, así que es un intercambio, no un regalo.- El Android Gradle Plugin elimina los símbolos de las bibliotecas nativas que empaqueta. Conserva los símbolos en la salida de cargo para ndk-stack y mide la copia sin símbolos, porque eso es lo que descargan los usuarios.
Qué exige el requisito de páginas de 16 KB de Android a una biblioteca de Rust
Una página es la unidad con la que el kernel gestiona la memoria, y todos los dispositivos Android a los que has publicado usaban páginas de 4 KB. Android 15 añadió soporte del sistema para páginas de 16 KB porque las páginas más grandes significan menos fallos de página y menos contabilidad del kernel. Google midió el beneficio en dispositivos de 16 KB: los tiempos de arranque de las apps mejoraron un 3,16 por ciento de media bajo presión de memoria, algunas apps ganaron hasta un 30 por ciento y el arranque del sistema mejoró en torno a un 8 por ciento, unos 950 milisegundos (Android Developers, 2025).
La trampa está en tus archivos ELF. Una biblioteca compartida registra, en sus cabeceras de programa, la alineación para la que se enlazaron sus segmentos LOAD. Una biblioteca enlazada para páginas de 4 KB no la puede mapear un kernel de 16 KB, y el fallo no es sutil: System.loadLibrary lanza una excepción en un dispositivo de 16 KB y la app muere en el primer arranque. Por eso Play impone el requisito en el momento del envío, en lugar de dejar que lo descubran los usuarios.
Aquí importa el alcance. El requisito se aplica a las ABI de 64 bits, arm64-v8a y x86_64, y se aplica a cada .so del bundle, no solo a los que compilas tú. Una app solo de Kotlin que incorpora un SDK con código nativo está tan obligada como esta serie. En tus crates de Rust, el cumplimiento es una propiedad de la cadena de herramientas, no del código: el NDK adecuado te lo da gratis, y un NDK antiguo se corrige con una sola opción del enlazador.
Comprueba la alineación antes de que lo haga Play
La parte 1 convirtió esta comprobación en parte de la rutina, y ahora no cambia. Apunta llvm-readelf a cada biblioteca que publiques; aquí, el crate del parser de la parte 11:
llvm-readelf -lW app/src/main/jniLibs/arm64-v8a/libparser.so | grep LOAD
La última columna de cada fila LOAD es la alineación del segmento. 0x4000 son 16384 bytes, y eso aprueba. 0x1000 son 4096 bytes, y eso falla en un dispositivo de 16 KB. La parte 1 muestra la salida completa capturada de una biblioteca sana; la forma es idéntica en todos los crates de esta serie.
La comprobación archivo por archivo es necesaria, pero no suficiente, porque tu APK contiene bibliotecas que no has compilado. Tres comprobaciones de toda la app cierran el hueco. El APK Analyzer de Android Studio marca los .so mal alineados en la carpeta lib. Google publica un script check_elf_alignment.sh que recorre un APK entero (Android Developers). Y zipalign verifica el lado del empaquetado, que importa porque las bibliotecas nativas sin comprimir también deben quedar en desplazamientos de 16 KB dentro del zip:
zipalign -v -c -P 16 4 app-release.apk
Si un SDK de terceros no supera estas comprobaciones, tu única corrección es una actualización del proveedor. Abre el ticket pronto: no puedes reenlazar el binario de otra persona.
Corregir la alineación desde la compilación de Rust
En el NDK r28 o posterior no hay nada que hacer. El enlazador emite segmentos alineados a 16 KB por defecto, y esa es la razón por la que la parte 1 fijó 28.2.13676358 para toda la serie. Si la cadena de herramientas de tu empresa te ata a r27 o anterior, pasa tú mismo las opciones de alineación al enlazador. En Rust, el sitio limpio es un archivo de configuración de cargo en rust/.cargo/config.toml, que se aplica a todos los crates del workspace:
[target.aarch64-linux-android]
rustflags = [
"-C", "link-arg=-Wl,-z,max-page-size=16384",
"-C", "link-arg=-Wl,-z,common-page-size=16384",
]
[target.x86_64-linux-android]
rustflags = [
"-C", "link-arg=-Wl,-z,max-page-size=16384",
"-C", "link-arg=-Wl,-z,common-page-size=16384",
]
max-page-size es la opción de corrección: fija la alineación LOAD que comprueba el kernel. common-page-size es una optimización de disposición que la documentación de Android recomienda junto a ella (Android Developers). Solo los dos destinos de 64 bits necesitan entradas, porque el requisito de Play cubre dispositivos de 64 bits.
El lado de Gradle tiene su propio suelo de versión. El Android Gradle Plugin alinea con zipalign las bibliotecas nativas sin comprimir a límites de 16 KB a partir de AGP 8.5.1, y las versiones entre 8.3 y 8.5 alinean las bibliotecas pero dejan un hueco de bundletool que aún puede hacer tropezar a Play (Android Developers). El cableado de compilación de la parte 4 ya está por encima de ese suelo. Cambies lo que cambies, vuelve a ejecutar la comprobación de readelf después; fíate de la cabecera ELF, no del changelog.
Recortar el tamaño del binario: el perfil de release
Ahora el segundo problema. Rust enlaza su biblioteca estándar de forma estática en cada cdylib, mantiene por defecto la maquinaria de panic y de formato, y estampa una copia nueva de cada función genérica por tipo concreto. No hay un R8 para el código nativo: LLVM y el enlazador son tu reductor, y solo eliminan lo que el perfil permite. Las palancas viven en el rust/Cargo.toml del workspace, y este es el bloque que publica la serie:
[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "unwind"
debug = true
strip = "none"
# audiofx stays speed-first: size wins must never cost audio deadlines.
[profile.release.package.audiofx]
opt-level = 3
Cada línea es una decisión, así que aquí va cada decisión (The Cargo Book, Profiles):
lto = "fat" ejecuta la optimización en tiempo de enlace sobre todo el programa, con inlining y descarte de código muerto a través de los límites entre crates. Es el análogo nativo más cercano a la reducción de programa completo de R8, y suele mover la cifra más que cualquier otra línea sola. El precio es el tiempo de enlace, y por eso solo pertenece a release.
codegen-units = 1 compila el crate como un único módulo de LLVM en lugar de hasta dieciséis en paralelo. La optimización lo ve todo a la vez, la salida encoge y la compilación se vuelve más lenta. Las compilaciones de release pagan eso de buena gana.
opt-level = "z" optimiza por tamaño en lugar de por velocidad. En el pegamento de la frontera y en los parsers, el coste de velocidad es irrelevante. En el núcleo DSP de la parte 10 no lo es, y para eso está la anulación por paquete: audiofx mantiene opt-level = 3 mientras todo lo demás se reduce. Las anulaciones por paquete cubren exactamente esta división, para que nunca cambies plazos de audio por kilobytes.
panic = "unwind" es el valor por defecto, escrito de forma explícita porque es una elección real. Pasar a panic = "abort" borra las tablas de unwinding y los landing pads, y en mis compilaciones suele ser la palanca individual más grande después de LTO. También rompe cosas que esta serie construyó a propósito: el cortafuegos de catch_unwind de la parte 8 deja de funcionar, y el mapeo de panic a excepción de UniFFI también necesita unwinding. Mi postura no ha cambiado desde la parte 8: para código que vive dentro del proceso de una app mantengo el unwinding y asumo el tamaño. Si cada frontera de tu app mapea los errores mediante Result y aceptas que cualquier bug que sobreviva se convierta en un crash nativo, abort es tuyo si lo quieres.
debug = true con strip = "none" parece propio del manual contrario, pero no les cuesta nada a tus usuarios. El Android Gradle Plugin elimina los símbolos de las bibliotecas nativas que empaqueta, así que los símbolos gordos nunca llegan al APK salvo que optes por lo contrario con keepDebugSymbols. El propio Cargo ha eliminado la debuginfo de las compilaciones de release por defecto desde Rust 1.77, así que ser explícito aquí conserva la copia sin strip que ndk-stack necesita para la simbolización, exactamente como la prepara la sección de depuración del hub. La copia sin símbolos va a los usuarios; la gorda se queda contigo para el día en que un informe de crash la necesite.
Encontrar a los culpables con cargo-bloat
El perfil reduce lo que genera el compilador. No puede arreglar un árbol de dependencias que genera demasiado, y adivinar al culpable te hace perder tardes. cargo-bloat lee el artefacto compilado y atribuye el tamaño a funciones y crates. Analiza destinos cdylib directamente, que es justo lo que compilamos:
cargo install cargo-bloat
cargo ndk -t arm64-v8a bloat --release --lib --crates -n 12
Ejecutarlo a través de cargo ndk mantiene el análisis sobre el artefacto que de verdad publicas. Si tu versión de cargo-ndk rechaza un subcomando que no es de build, ejecuta cargo bloat --release --lib --crates contra la compilación del host; los números absolutos cambian, el ranking casi nunca. Quita --crates para ver funciones individuales, que es donde la monomorfización aparece como el mismo genérico repetido para cada tipo concreto con el que se llamó. La propia documentación de la herramienta llama a la atribución un trabajo de estimación, así que trátala como un ranking, no como un libro de cuentas.
Lo que haces con el ranking es higiene ordinaria de dependencias. La mayoría de las bibliotecas de Rust sobredimensionadas están gordas por features por defecto que nadie desactivó, no por el código del autor. default-features = false en una dependencia pesada, seguido de volver a añadir solo las features que usas, es la edición de más valor de todo este artículo para muchos crates reales. Las configuraciones de serde y tokio de la parte 11 y la parte 9 ya siguen esta regla. A partir de ahí, los culpables recurrentes son un motor de regex incorporado para una sola coincidencia literal, y macros derive que generan serialización para tipos que nunca cruzan la frontera. El -Z build-std de nightly puede recompilar la biblioteca estándar con tus opciones de tamaño; para una biblioteca de app, el coste de mantenimiento pesa más que el ahorro.
Medir el manual: scripts/size-report.sh
Cada afirmación de tamaño de esta serie tiene que sobrevivir a la misma regla que cada afirmación de benchmark: se mide o no se afirma. La metodología es una palanca cada vez, aplicada de forma acumulativa, con las cuatro palancas fijadas de forma explícita en cada paso para que cada delta se atribuya exactamente a un cambio. El artefacto medido es la biblioteca arm64 sin símbolos, porque el strip es lo que hace AGP antes de empaquetar, y la copia sin símbolos es lo que descarga un usuario. Guarda lo siguiente como scripts/size-report.sh junto a tu workspace y recorre toda la escalera:
#!/usr/bin/env bash
# Measures the stripped arm64 parser library after each size lever.
# Levers are cumulative; every step pins all four so deltas are attributable.
set -euo pipefail
ABI="arm64-v8a"
CRATE="rust/parser"
SO="libparser.so"
STRIP=("$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/llvm-strip)
OUT="$(mktemp -d)"
measure() {
local label="$1"
shift
env "$@" cargo ndk -t "$ABI" -o "$OUT" build --release \
--manifest-path "$CRATE/Cargo.toml" >/dev/null 2>&1
"${STRIP[0]}" "$OUT/$ABI/$SO"
printf '%-20s %10d bytes\n' "$label" "$(wc -c < "$OUT/$ABI/$SO")"
}
measure "baseline" \
CARGO_PROFILE_RELEASE_LTO=off \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \
CARGO_PROFILE_RELEASE_PANIC=unwind
measure "+ lto fat" \
CARGO_PROFILE_RELEASE_LTO=fat \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \
CARGO_PROFILE_RELEASE_PANIC=unwind
measure "+ codegen-units 1" \
CARGO_PROFILE_RELEASE_LTO=fat \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \
CARGO_PROFILE_RELEASE_PANIC=unwind
measure "+ opt-level z" \
CARGO_PROFILE_RELEASE_LTO=fat \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
CARGO_PROFILE_RELEASE_OPT_LEVEL=z \
CARGO_PROFILE_RELEASE_PANIC=unwind
measure "+ panic abort" \
CARGO_PROFILE_RELEASE_LTO=fat \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
CARGO_PROFILE_RELEASE_OPT_LEVEL=z \
CARGO_PROFILE_RELEASE_PANIC=abort
Las variables de entorno CARGO_PROFILE_RELEASE_* anulan el perfil de Cargo.toml en cada invocación, y eso es lo que permite que un solo script recorra la escalera sin editar archivos entre compilaciones (The Cargo Book, Environment Variables). Cambia ABI y vuelve a ejecutarlo para obtener las otras arquitecturas.
No hay tabla de resultados bajo ese script, a propósito. Los deltas dependen de tu árbol de dependencias mucho más que de cualquier cosa que yo controle, así que mis números te dirían poco sobre tu APK. Recorre la escalera contra tu propia biblioteca: tarda unos minutos, y la salida describe lo que de verdad publicas. Dos cosas se mantienen sea lo que sea lo que imprima. Cada ABI lleva su propia copia completa de todo, y por eso importa la configuración de app bundle de la parte 4: un dispositivo descarga una ABI, no cuatro. Y los pasos grandes llegan pronto. El último peldaño, panic = "abort", está ahí para las apps que pueden permitírselo; las mías mantienen el unwinding.
Preguntas frecuentes
¿El requisito de páginas de 16 KB se aplica a armeabi-v7a?
No. El requisito de Google Play cubre dispositivos de 64 bits, así que se aplica a las bibliotecas arm64-v8a y x86_64 de tu app (Android Developers). Si aún publicas armeabi-v7a para dispositivos antiguos, esas bibliotecas quedan fuera de alcance, y por eso la configuración de cargo de arriba solo toca los dos destinos de 64 bits.
¿panic = “abort” rompe la gestión de errores de la parte 8?
Sí, por completo. catch_unwind solo funciona mientras los panics hacen unwind, así que el cortafuegos de la parte 8 se convierte en silencio en un abort del proceso, y el mapeo de panic a excepción de UniFFI también deja de funcionar. Quédate la ganancia de tamaño solo si cada fallo esperado ya cruza la frontera como un Result y aceptas un crash nativo para los bugs auténticos.
¿Por qué un .so de Rust es más grande que el C++ equivalente?
Sobre todo por el enlace, no por el lenguaje. Rust enlaza su biblioteca estándar de forma estática en cada cdylib, mientras que las compilaciones de C++ en Android suelen compartir libc++_shared.so, así que la cifra de C++ esconde una biblioteca que el APK sigue llevando. Suma la maquinaria de panic y los genéricos monomorfizados, y una comparación sin optimizar favorece a C++. Compara compilaciones de release con strip y LTO antes de sacar conclusiones.
Mi app es todo Kotlin. ¿Puedo ignorar el requisito de 16 KB?
Solo si tu APK no contiene código nativo en absoluto. Abre la carpeta lib en el APK Analyzer: los SDK de analítica, bases de datos, ML y cripto publican con frecuencia archivos .so que nunca has compilado. Si hay algo ahí y apuntas a Android 15 o superior, el requisito se aplica a tu próxima actualización, y tu corrección es actualizar el SDK que publicó la biblioteca mal alineada.
Dónde te deja esto
Tus bibliotecas superan ya la misma comprobación de readelf en cada ABI para la que publicas, y el perfil de release son seis líneas deliberadas en lugar de valores por defecto que nadie eligió. Nada de ello ha tocado el código fuente del crate, y eso es lo que lo hace duradero: todo el manual vive en dos archivos de configuración y un script que una revisión de código puede ver de verdad.
El punto débil es que la configuración se desvía cuando solo la vigilan personas. La parte 14, CI/CD para Rust + Android: GitHub Actions, caché de compilación cruzada y builds reproducibles, coloca estas comprobaciones donde corresponden, en un pipeline que falla la compilación cuando la alineación o el tamaño retroceden. Si has llegado aquí por un aviso de Play Console y no desde la parte 12, el hub de la serie tiene el mapa de todo lo demás.
Fuentes
- Android Developers, “Support 16 KB page sizes”, consultado el 2026-07-23, https://developer.android.com/guide/practices/page-sizes
- Android Developers, “zipalign”, consultado el 2026-07-23, https://developer.android.com/tools/zipalign
- The Cargo Book, “Profiles”, consultado el 2026-07-23, https://doc.rust-lang.org/cargo/reference/profiles.html
- The Cargo Book, “Environment Variables”, consultado el 2026-07-23, https://doc.rust-lang.org/cargo/reference/environment-variables.html
- The Cargo Book, “Configuration”, consultado el 2026-07-23, https://doc.rust-lang.org/cargo/reference/config.html
- crates.io, cargo-bloat, consultado el 2026-07-23, https://crates.io/crates/cargo-bloat
- crates.io, cargo-ndk, consultado el 2026-07-23, https://crates.io/crates/cargo-ndk

Loading comments…Cargando comentarios…