Compiling Rust libraries for Android apps: a deep dive
In a previous blog post, I mentioned how to use CPU-specific instructions in Rust to speed up the Shamir’s Secret Sharing algorithm. In my initial implementation, I only wrote an optimized version for Intel CPUs, but ARM supports similar instructions, so I mentioned that it would be nice to add an optimized implementation for it as well.
However, how to test this ARM-specific implementation?
My development laptop runs on Intel, so running unit tests with cargo test
would not test the ARM instructions.
I thought of several approaches.
- Purchasing an ARM-based laptop, which would be quite expensive just to test a few instructions on a specific project.
- Using an emulator, assuming it correctly implements all the instructions I want to test.
For example, the
cross
crate aims at streamlining that. - Or why not using my Android phone, which is real hardware that runs on ARM? I already have one, so no purchase needed.
As you may guess from the title, I went for this last option. What started from testing one instruction on ARM ended up being quite a journey, including patching the Rust compiler! In this first blog post of the series, I’ll review how to compile Rust libraries for Android apps, and making sure we’re targeting the correct CPU architecture.
For reproducibility, you can find my code on GitHub.
- Introduction: Building an Android app with the command-line tools
- Creating an Android application with Rust code
- Testing CPU architecture detection from Rust
- Production tips
- Next up: detecting CPU features
Introduction: Building an Android app with the command-line tools
The first step is to create a traditional Java-based Android app. The default IDE for that is Android Studio, which integrates a code editor, the build tools, and plenty of features like a layout editor, a profiler, etc. However, as an IDE, this is heavyweight and may not be suitable for continuous integration (or if you only care about building the app).
Instead, in this section I’ll explain how to compile the app manually with the Android command line tools. I’ll provide the instructions in the Dockerfile format (documentation). If you prefer to use Android Studio you can skip to the next section.
Before we get started, we’ll need a few Debian packages:
wget
andunzip
to download the Android command line tools,build-essential
to have a C++ toolchain and be able to build some Rust libraries – you’ll likely have a dependency needing it,openjdk-11-jdk-headless
to provide a Java runtime to Gradle, the build tool used by Android.
FROM debian:testing-slim
RUN apt-get update \
&& apt-get install -y \
wget \
unzip \
build-essential \
openjdk-11-jdk-headless \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
Let’s also configure a non-root user in our Docker container.
RUN useradd --uid 1000 --create-home --shell /bin/bash dev
USER dev
WORKDIR "/home/dev"
Gradle
The first component we’ll need to install is Gradle, the build tool used by Android.
If your application’s source code already contains a Gradle Wrapper (
gradlew
script at the root), which is the case by default with Android Studio, you can skip this step.
Although there exists a Debian package for Gradle, in practice it is widely outdated, which causes some issues. We can instead download and unpack it directly – here I’m using the latest release currently available, checking the corresponding SHA-256 checksum after download.
# Set an environment variable for convenience.
ENV GRADLE_ROOT=/home/dev/opt/gradle
RUN mkdir -p ${GRADLE_ROOT}
RUN wget https://services.gradle.org/distributions/gradle-7.5.1-bin.zip -O gradle-7.5.1-bin.zip \
&& sha256sum gradle-7.5.1-bin.zip \
&& echo "f6b8596b10cce501591e92f229816aa4046424f3b24d771751b06779d58c8ec4 gradle-7.5.1-bin.zip" | sha256sum -c - \
&& unzip gradle-7.5.1-bin.zip -d ${GRADLE_ROOT} \
&& rm gradle-7.5.1-bin.zip
# Add the relevant directories to the $PATH.
ENV PATH=${PATH}:${GRADLE_ROOT}/gradle-7.5.1/bin
Android SDK
Next, we download the Android command-line tools, here using the latest version currently available on the Android website.
# Set the ${ANDROID_HOME} variable, so that the tools can find our installation.
# See https://developer.android.com/studio/command-line/variables#envar.
ENV ANDROID_HOME=/home/dev/opt/android-sdk
# Download and extract the command-line tools into ${ANDROID_HOME}.
RUN mkdir -p ${ANDROID_HOME}
RUN wget https://dl.google.com/android/repository/commandlinetools-linux-8512546_latest.zip \
-O ${HOME}/commandlinetools-linux-8512546_latest.zip \
&& sha256sum commandlinetools-linux-8512546_latest.zip \
&& echo "2ccbda4302db862a28ada25aa7425d99dce9462046003c1714b059b5c47970d8 commandlinetools-linux-8512546_latest.zip" | sha256sum -c - \
&& unzip commandlinetools-linux-8512546_latest.zip -d ${ANDROID_HOME}/cmdline-tools \
&& rm commandlinetools-linux-8512546_latest.zip
# Add the relevant directories to the $PATH.
ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/cmdline-tools/bin:${ANDROID_HOME}/platform-tools
Once these tools are installed, the sdkmanager
program is available to install the specific parts of the SDK that we actually need.
- The first step is to accept the software licenses, by answering “yes” to
sdkmanager --licenses
. - We then install the build tools for Android SDK 33, as well as the native development kit (NDK) which allows implementing part of the app in a native programming language – in our case Rust.
RUN yes | sdkmanager --licenses \
&& sdkmanager --verbose \
"build-tools;30.0.3" \
"ndk;25.1.8937393" \
"platforms;android-33"
ENV NDK_HOME=${ANDROID_HOME}/ndk/25.1.8937393
Running sdkmanager --list_installed
should give you something like the following.
Path | Version | Description | Location
------- | ------- | ------- | -------
build-tools;30.0.3 | 30.0.3 | Android SDK Build-Tools 30.0.3 | build-tools/30.0.3
emulator | 31.3.12 | Android Emulator | emulator
ndk;25.1.8937393 | 25.1.8937393 | NDK (Side by side) 25.1.8937393 | ndk/25.1.8937393
patcher;v4 | 1 | SDK Patch Applier v4 | patcher/v4
platform-tools | 33.0.3 | Android SDK Platform-Tools | platform-tools
platforms;android-33 | 2 | Android SDK Platform 33 | platforms/android-33
You may wonder why I’m using the
build-tools;30.0.3
rather than the latest version (currently 33.0.0). This is because of a tight coupling between the Gradle version, thecom.android.tools.build:gradle
package mentioned in the project’sbuild.gradle
file, the build tools, and the Android SDK.In my case, if I installed the build tools version 33.0.0, Gradle would later complain that it couldn’t find the correct build tools. This is also explained by the fact that
com.android.tools.build:gradle
7.3.0 depends on Android tools 30.3.0 according to the Maven Repository.FAILURE: Build failed with an exception. * What went wrong: Could not determine the dependencies of task ':app:compileDebugJavaWithJavac'. Failed to find Build Tools revision 30.0.3
Besides, the
com.android.tools.build:gradle
package must be compatible with your installed Gradle version, as somewhat explained in this StackOverflow question. If you’re using a different Gradle version, you may explore the options here.Lastly, only some Android SDK versions are compatible with a given Gradle version. For example, I’ve briefly tested with the currently outdated gradle package from Debian: this Gradle 4.4.1 worked with
com.android.tools.build:gradle:3.1.4
,build-tools;27.0.3
and Android SDK 27 (platforms;android-27
).
With these installation steps, we can already build a regular Android app (without any Rust code), by going into its top-level folder and running the following commands.
echo "sdk.dir=${ANDROID_HOME}" > local.properties
gradle build
This will create compiled APKs in the following locations:
app/build/outputs/apk/release/app-release-unsigned.apk
,app/build/outputs/apk/debug/app-debug.apk
.
Android emulator
If you want to test your app in an Android emulator, you can also install one from the command line tools.
For this, you first need to choose a system image to install via the sdkmanager
.
I’ve chosen one for Android 29 running on x86-64 (matching my host architecture).
There are also newer images, but these require more memory to run.
RUN sdkmanager --verbose "system-images;android-29;default;x86_64"
Next, we create a new Android virtual device.
RUN avdmanager create avd \
-n test_avd \
-d pixel \
-k "system-images;android-29;default;x86_64"
And we can launch it in the Android emulator.
${ANDROID_HOME}/emulator/emulator -verbose -avd test_avd
Running the emulator within Docker requires the following permissions:
- being a member of the
kvm
group,- having access to the
/dev/kvm
and/dev/dri
files,- having access to the X11 environment – I’ve described in this blog post how to do that with Docker.
Starting from version 31.3.12, the Android emulator seems to require more than 7 GB of disk to store the virtual device’s “user data”. This doesn’t seem configurable, but as a workaround you can replace the default emulator by an earlier one, following the instructions in the emulator archive.
Creating an Android application with Rust code
Let’s move on to the gist of this article: compiling Rust code for Android applications.
My starting point was a post from 2017 on Mozilla’s blog describing an experiment to compile a Rust library and embed it into an Android application. However, this post is now 5 years old, and I discovered a mistake affecting the dispatch of Android ABIs. As we will see, this mistake would generally go unnoticed, but would lead to sub-optimal code on 64-bit ARM architectures and potentially affect the CPU-specific instructions I wanted to test!
The next step is to build a Rust library targeting the Android platform. Or rather build one Rust library for each of the currently 4 supported Android ABIs.
Installing and configuring the Rust build tools for Android
Because we install custom Rust targets, we’ll be using the rustup
toolchain installer rather than the default Debian packages.
The following commands download rustup
, verify the checksum and do a bit of cleanup, but the main part is to install the relevant Android targets via rustup target add
.
Here I’m installing a stable and a nightly Rust compiler.
RUN wget https://sh.rustup.rs -O rustup.sh \
&& sha256sum rustup.sh \
&& echo "173f4881e2de99ba9ad1acb59e65be01b2a44979d83b6ec648d0d22f8654cbce rustup.sh" | sha256sum -c - \
&& sh rustup.sh -y \
&& rm rustup.sh \
&& rm .profile \
&& ${HOME}/.cargo/bin/rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
i686-linux-android \
x86_64-linux-android \
&& ${HOME}/.cargo/bin/rustup toolchain install nightly \
&& ${HOME}/.cargo/bin/rustup target add --toolchain nightly \
aarch64-linux-android \
armv7-linux-androideabi \
i686-linux-android \
x86_64-linux-android
ENV PATH=${PATH}:/home/dev/.cargo/bin
We then need to configure Rust to find the target-specific linkers, by adding the following lines to the ${HOME}/.cargo/config
file.
Note that you’ll need to expand the ${NDK_HOME}
to an explicit absolute path.
[target.aarch64-linux-android]
linker = "${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android30-clang"
[target.armv7-linux-androideabi]
linker = "${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi30-clang"
[target.i686-linux-android]
linker = "${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android30-clang"
[target.x86_64-linux-android]
linker = "${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android30-clang"
Here I’ve used the “Android 30” flavor of the linker to match our build tools for the Java part of the app, but it probably doesn’t matter much.
This configuration file will tell Cargo where to find the relevant linker to cross-compile for the Android NDK, and avoid linker errors like the following.
error: linking with `cc` failed: exit status: 1
|
...
= note: /usr/bin/ld: /home/dev/build/android-simd/target/aarch64-linux-android/release/deps/simd.simd.4ca11142-cgu.0.rcgu.o: Relocations in generic ELF (EM: 183)
...
/usr/bin/ld: /home/dev/build/android-simd/target/aarch64-linux-android/release/deps/simd.simd.4ca11142-cgu.0.rcgu.o: error adding symbols: file in wrong format
collect2: error: ld returned 1 exit status
error: could not compile `simd` due to previous error
Building the Rust library
Now that we’ve installed the relevant toolchains, we can focus on the library itself.
The main point here is that the Android application will be able to load dynamic libraries (.so
files on Linux), so we’ll need to tell the Rust compiler to create a .so
file.
This can be done via the crate-type
field in your Cargo.toml
file (see also the linkage section of the Rust reference).
[lib]
crate-type = ["dylib"]
With these steps, we can now use Cargo to compile the library, invoking it with the relevant --target
CPU architecture, and the NDK toolchain binaries in the $PATH
.
export PATH=$PATH:${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin
cargo build --target aarch64-linux-android --release
cargo build --target armv7-linux-androideabi --release
cargo build --target i686-linux-android --release
cargo build --target x86_64-linux-android --release
Including the Rust library into the Android app
The last build step is to connect our Rust library to the Android app.
This is relatively straightforward, and done by creating a jniLibs/
folder inside of the app/src/main/
of the application’s source code, and putting the libraries (for each architecture) there.
cd app/src/main
mkdir -p jniLibs
mkdir -p jniLibs/arm64-v8a
mkdir -p jniLibs/armeabi-v7a
mkdir -p jniLibs/x86
mkdir -p jniLibs/x86_64
ln -sf <android-library>/target/aarch64-linux-android/release/libsimd.so \
jniLibs/arm64-v8a/libsimd.so
ln -sf <android-library>/target/armv7-linux-androideabi/release/libsimd.so \
jniLibs/armeabi-v7a/libsimd.so
ln -sf <android-library>/target/i686-linux-android/release/libsimd.so \
jniLibs/x86/libsimd.so
ln -sf <android-library>/target/x86_64-linux-android/release/libsimd.so \
jniLibs/x86_64/libsimd.so
The
gradle
build system won’t check that you used the correctjniLibs/
sub-folders, and the Android system will also happily fallback to a different architecture for devices that support it!In particular, the original post on Mozilla’s blog used
arm64
andarmeabi
instead ofarm64-v8a
andarmeabi-v7a
respectively. This was incorrect becausearm64
doesn’t exist as an Android ABI name – it should have beenarm64-v8a
. Likewisearmeabi
is a more generic ABI for any ARM CPU than thearmv7-linux-androideabi
we compiled for with our Rust code – which corresponds toarmeabi-v7a
.This means that on the one hand a phone running a 64-bit ARM CPU would fallback to the generic
armeabi
library, which is sub-optimal for a 64-bit CPU. On the other hand, a phone that supportsarmeabi
but notarmeabi-v7a
would incorrectly load our Rust library that we compiled forarmv7-linux-androideabi
. This is probably anecdotal nowadays, but on such old/low-end phones the app could crash if there are any instructions fromarmeabi-v7a
that don’t exist inarmeabi
(notably the Android ABI docs mention thatarmeabi-v7a
is “incompatible with ARMv5/v6 devices”).
Invoking the Rust library from Java
One remaining question is how to bridge the app’s Java code with our native Rust code. This is done via the Java Native Interface (JNI), which allows to interact with Java objects and functions from native code. This JNI was originally created to bridge with C/C++, but it can likewise be called from Rust.
The easiest for that is to use the jni
crate, which conveniently provides wrappers with Rust types around the raw JNI functions.
Let’s add this dependency in our Cargo.toml
file – note the target_os="android"
restriction.
[target.'cfg(target_os="android")'.dependencies]
jni = { version = "0.20", default-features = false }
On the Java side, we’ll need two pieces.
- Loading the native library, with
System.loadLibrary()
in astatic
block (so that the library is loaded before any native function is invoked). - Declaring
native
function(s) that will be implemented in Rust.
package com.example.myrustapplication;
public class NativeLibrary {
// Load the native library "libsimd.so".
static {
System.loadLibrary("simd");
}
public String run() {
return nativeRun();
}
// Native function implemented in Rust.
private static native String nativeRun();
}
Lastly, we need to implement the corresponding function(s) in Rust.
- Each native function needs to be named
Java_<package>_<class>_<function>
. We use the#[no_mangle]
attribute to make sure that this name appears as is in the.so
file, and also need toallow(non_snake_case)
given that such a function name doesn’t follow Rust’s preferred style. - We use
extern "C"
so that the function can be correctly invoked by the JNI. - The function takes as basic parameters a
JNIEnv
(the basic object allowing to interact with the JNI), and either aJClass
(for astatic
function like here), or aJObject
(referencingthis
). The function’s parameters follow (here there are none). - Lastly, we wrap all the native functions in a block gated by
cfg(target_os = "android")
. This isn’t mandatory if you’re only compiling for Android, but can be useful if you want to reuse the library on other platforms as well.
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
pub mod android {
use jni::objects::JClass;
use jni::sys::jstring;
use jni::JNIEnv;
// The native function implemented in Rust.
#[no_mangle]
pub unsafe extern "C" fn Java_com_example_myrustapplication_NativeLibrary_nativeRun(
env: JNIEnv,
_: JClass,
) -> jstring {
todo!("Implement something useful.")
}
}
Testing CPU architecture detection from Rust
Now that we have built the Android app, let’s test that our native library is able to detect the CPU architecture. Here is a function to do that – simply returning the architecture string.
fn get_arch_name() -> &'static str {
#[cfg(target_arch = "x86")]
return "x86";
#[cfg(target_arch = "x86_64")]
return "x86_64";
#[cfg(target_arch = "arm")]
return "arm";
#[cfg(target_arch = "aarch64")]
return "aarch64";
#[cfg(not(any(
target_arch = "x86",
target_arch = "x86_64",
target_arch = "arm",
target_arch = "aarch64",
)))]
return "unknown";
}
The next step is to print this string somewhere.
We could display it on the application via a TextView
widget for example, but if we’ll do a lot of debugging, it would be more convenient to print a message to the console, via Android’s USB debugging.
However, Android redirects the standard input/output to /dev/null
, so we cannot simply use Rust’s println!
to write to the console.
Instead, we can print messages via the android.util.Log
class, and use adb logcat
to read them into our terminal.
For convenience, I’ve written the following wrapper class around it (logger.rs
on GitHub).
struct AndroidLogger<'a> {
/// JNI environment.
env: JNIEnv<'a>,
/// Reference to the android.util.Log class.
log_class: JClass<'a>,
/// Tag for log messages.
tag: JString<'a>,
}
impl<'a> AndroidLogger<'a> {
pub fn new(env: JNIEnv<'a>, tag: &str) -> Result<Self, jni::errors::Error> {
Ok(Self {
env,
log_class: env.find_class("android/util/Log")?,
tag: env.new_string(tag)?,
})
}
/// Prints a message at the debug level.
pub fn d(&self, message: impl AsRef<str>) -> Result<(), jni::errors::Error> {
self.env.call_static_method(
self.log_class,
"d",
"(Ljava/lang/String;Ljava/lang/String;)I",
&[
JValue::Object(JObject::from(self.tag)),
JValue::Object(JObject::from(self.env.new_string(message)?))
]
)?;
Ok(())
}
}
This AndroidLogger
utility can be used as follows.
let logger = AndroidLogger::new(env, "MyRustSimdApplication")?;
logger.d("Hello Rust world")?;
logger.d(&format!("Your CPU architecture is {}", get_arch_name()))?;
This should give the following output on an Android device with a 64-bit ARM CPU.
D MyRustSimdApplication: Hello Rust world
D MyRustSimdApplication: Your CPU architecture is aarch64
With that, we can test the behavior I mentioned above about what happens when we incorrectly place our Rust library in a folder named arm64
instead of arm64-v8a
.
I’m also adding the output of the following Build
properties, as computed on the Java side of the application.
Log.i(TAG, "SUPPORTED_ABIS: " + Arrays.toString(Build.SUPPORTED_ABIS));
Log.i(TAG, "SUPPORTED_32_BIT_ABIS: " + Arrays.toString(Build.SUPPORTED_32_BIT_ABIS));
Log.i(TAG, "SUPPORTED_64_BIT_ABIS: " + Arrays.toString(Build.SUPPORTED_64_BIT_ABIS));
Log.i(TAG, "CPU_ABI [deprecated]: " + Build.CPU_ABI);
Log.i(TAG, "CPU_ABI2 [deprecated]: " + Build.CPU_ABI2);
Log.i(TAG, "OS.ARCH: " + System.getProperty("os.arch"));
With the correct folder jniLibs/arm64-v8a/
, our 64-bit ARM library is loaded.
I MyRustSimdApplication: SUPPORTED_ABIS: [arm64-v8a, armeabi-v7a, armeabi]
I MyRustSimdApplication: SUPPORTED_32_BIT_ABIS: [armeabi-v7a, armeabi]
I MyRustSimdApplication: SUPPORTED_64_BIT_ABIS: [arm64-v8a]
I MyRustSimdApplication: CPU_ABI [deprecated]: arm64-v8a
I MyRustSimdApplication: CPU_ABI2 [deprecated]:
I MyRustSimdApplication: OS.ARCH: aarch64
D MyRustSimdApplication: Hello Rust world
D MyRustSimdApplication: Your CPU architecture is aarch64
With the incorrect folder jniLibs/arm64/
, the 32-bit version of our Rust library is loaded instead (from jniLibs/armeabi/
).
An interesting aspect is that this also affects the following build properties as observed on the Java side: CPU_ABI
, CPU_ABI2
and OS.ARCH
!
I MyRustSimdApplication: SUPPORTED_ABIS: [arm64-v8a, armeabi-v7a, armeabi]
I MyRustSimdApplication: SUPPORTED_32_BIT_ABIS: [armeabi-v7a, armeabi]
I MyRustSimdApplication: SUPPORTED_64_BIT_ABIS: [arm64-v8a]
I MyRustSimdApplication: CPU_ABI [deprecated]: armeabi-v7a
I MyRustSimdApplication: CPU_ABI2 [deprecated]: armeabi
I MyRustSimdApplication: OS.ARCH: armv8l
D MyRustSimdApplication: Hello Rust world
D MyRustSimdApplication: Your CPU architecture is arm
Production tips
In this section, I want to mention some important optimizations to reduce the size of your application and get it ready for production.
Optimizing the Rust library size, dividing the APK size by 3
You may think that by compiling your Rust library in --release
mode it will already be optimized.
However, even in this mode, the compiler includes a lot of debug symbols in the final .so
library by default, even though we have no use for them in an Android library.
Fortunately, we can generally remove these symbols with the strip
tool.
The manual way is to invoke strip
on each libsimd.so
, as follows.
${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip \
<android-library>/target/aarch64-linux-android/release/libsimd.so
${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip \
<android-library>/target/armv7-linux-androideabi/release/libsimd.so
${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip \
<android-library>/target/i686-linux-android/release/libsimd.so
${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip \
<android-library>/target/x86_64-linux-android/release/libsimd.so
A more ergonomic way is to directly use the strip
profile option of Rust, which was stabilized last year in Cargo.
All you need is adding the following lines to your Cargo.toml
file.
[profile.release]
strip = true
I’ve tested both methods, and they gave the same results, so there is no need to use the manual method anymore.
Still, don’t forget to enable strip = true
in your Cargo.toml
, as the size difference can be quite significant!
In principle, Gradle also tries to strip debug symbols of native libraries, but in practice this step fails.
> Task :app:stripDebugDebugSymbols Unable to strip the following libraries, packaging them as they are: libsimd.so. > Task :app:stripReleaseDebugSymbols Unable to strip the following libraries, packaging them as they are: libsimd.so.
For my small example, the .so
libraries are 3-4x smaller after stripping.
Raw size of libsimd.so
- aarch64 : 7008552 bytes
- armv7 : 5770196 bytes
- i686 : 6029356 bytes
- x86_64 : 6684280 bytes
...
Stripped size of libsimd.so
- aarch64 : 1873488 bytes
- armv7 : 1338996 bytes
- i686 : 2078292 bytes
- x86_64 : 2005960 bytes
Another interesting statistic is the size of the (un)stripped libraries in the resulting APK.
The unzip -lv
command gives details about how much space each file takes in a ZIP archive, both in uncompressed and compressed form.
In principle, the ZIP archive could apply some compression to the native libraries, but this is disabled by default.
For my example, the sizes with and without stripping are the following (compiled with Rust nightly 1.66.0).
I’m also including the Java part for comparison (classes.dex
) – in this example you can see that the Rust libraries constitute most of the APK.
Without stripping symbols:
$ unzip -lv /home/dev/build/android-simd-app-release.apk
Length Method Size Cmpr Date Time CRC-32 Name
-------- ------ ------- ---- ---------- ----- -------- ----
...
907024 Defl:N 414306 54% 1981-01-01 01:01 910b9b83 classes.dex
7008552 Stored 7008552 0% 1981-01-01 01:01 89b74bd4 lib/arm64-v8a/libsimd.so
5770196 Stored 5770196 0% 1981-01-01 01:01 a9dd6549 lib/armeabi-v7a/libsimd.so
6029356 Stored 6029356 0% 1981-01-01 01:01 c46a2705 lib/x86/libsimd.so
6684280 Stored 6684280 0% 1981-01-01 01:01 c0d60c4d lib/x86_64/libsimd.so
...
-------- ------- --- -------
27024889 26391227 2% 399 files
With stripping symbols:
$ unzip -lv /home/dev/build/android-simd-app-release.apk
Length Method Size Cmpr Date Time CRC-32 Name
-------- ------ ------- ---- ---------- ----- -------- ----
...
907024 Defl:N 414306 54% 1981-01-01 01:01 910b9b83 classes.dex
1873488 Stored 1873488 0% 1981-01-01 01:01 014e8eaf lib/arm64-v8a/libsimd.so
1338996 Stored 1338996 0% 1981-01-01 01:01 a6fcda7e lib/armeabi-v7a/libsimd.so
2078292 Stored 2078292 0% 1981-01-01 01:01 8b5871e8 lib/x86/libsimd.so
2005960 Stored 2005960 0% 1981-01-01 01:01 f51e7839 lib/x86_64/libsimd.so
...
-------- ------- --- -------
8829241 8195575 7% 399 files
Without stripping, the Rust libraries represent 96.6% (25.5 MiB) of the final compressed APK (26.4 MiB). After stripping, this goes down to 89.0% (7.3 MiB) of the APK (8.2 MiB). Overall, stripping our Rust libraries made the resulting APK more than 3 times smaller!
For more ideas to reduce the size of your Rust libraries, you can follow the advice from the min-sized-rust repository. In my experience, stripping debug symbols was by far the biggest low-hanging fruit.
Shrinking and testing the release APK
On the Java side, I encourage you to enable Java code shrinking, as documented by Android.
In particular, turning on the minifyEnabled
parameter in the app/build.gradle
configuration made my classes.dex
more than 2x smaller!
However, by default gradle
compiles two APKs, with different level of optimizations:
app/build/outputs/apk/debug/app-debug.apk
– ready to use for debugging, but without all these Java optimizations,app/build/outputs/apk/release/app-release-unsigned.apk
– with all the optimizations applied, but not signed.
It is particularly important to test the release APK when linking a native library (in Rust or other language), because the shrinking tool may remove Java classes or methods that don’t seem used from Java, even if you actually use them in your Rust code via JNI.
However, as the name goes this APK is not signed, which prevents loading it on a device (or emulator). We’ll therefore need to sign it, even for local testing.
$ adb install /home/dev/build/android-simd-app-release-unsigned.apk
Performing Streamed Install
adb: failed to install /home/dev/build/android-simd-app-release-unsigned.apk: Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES: Failed to collect certificates from /data/app/vmdl1326288569.tmp/base.apk: Attempt to get length of null array]
If you intend to distribute your APK on the Play Store, you can already follow the application signing steps with your official signing key, and you should obtain a signed app ready to test locally.
But if you just want to do some quick early testing, you can simply use the debug key (same as the debug APK) without having to generate your own developer key.
For that, we can simply use the apksigner
tool – that we already have within the Android build tools – and use the debug key from ~/.android/debug.keystore
.
${ANDROID_HOME}/build-tools/30.0.3/apksigner \
sign \
--in app/build/outputs/apk/release/app-release-unsigned.apk \
--out app/build/outputs/apk/release/app-release.apk \
--ks ~/.android/debug.keystore \
--ks-key-alias androiddebugkey \
--ks-pass pass:android \
--key-pass pass:android
Once signed and running on a device, it may happen that your release app crashes, with errors like ClassNotFoundException
recorded in logcat
.
This can happen if you invoke some Java class from Rust only: in this case, because the Java shrinking tool is not analyzing your native Rust code, it will consider that the Java class is dead code and remove it.
--------- beginning of crash
E AndroidRuntime: FATAL EXCEPTION: pool-3-thread-1
E AndroidRuntime: Process: com.example.foo, PID: 12345
E AndroidRuntime: java.lang.Error: java.lang.ClassNotFoundException: com.example.foo.Bar
E AndroidRuntime: at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:42)
E AndroidRuntime: at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:42)
E AndroidRuntime: at java.lang.Thread.run(Thread.java:42)
E AndroidRuntime: Caused by: java.lang.ClassNotFoundException: com.example.foo.Bar
E AndroidRuntime: at com.example.foo.NativeLibrary.nativeRun(Native Method)
E AndroidRuntime: at com.example.foo.MainActivity.a(Unknown Source:42)
E AndroidRuntime: at com.example.foo.MainActivity$3.run(Unknown Source:42)
E AndroidRuntime: at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:42)
E AndroidRuntime: ... 2 more
To overcome these errors, you’ll need to manually customize the code to keep in the app/proguard-rules.pro
file.
You’ll find more details about the syntax and examples in the ProGuard manual.
# Keep some constructor.
-keep public class com.example.foo.Bar {
private <init>(long);
}
# Keep all public members.
-keep public class com.example.bar.Foo {
public *;
}
Another recommended tool to improve your app’s RAM usage is zipalign, but it seems already enabled behind the scenes.
Next up: detecting CPU features
That’s it, we have built step-by-step an Android app that loads Rust code for the correct CPU architecture. You can find the code on GitHub.
Since Mozilla’s blog post from 2017, the Rust ecosystem has boomed on Android.
Notably, the android-ndk-rs project (which includes the cargo-apk
crate) aims at creating Android applications fully in Rust (disclaimer: I haven’t tested it).
On the system side as well, native OS components can be written in Rust.
In the next blog post, we’ll see how to dynamically detect which instructions are supported on a specific CPU. On ARM, we’ll see that this is more complex than it seems, and we’ll even patch the Rust compiler to test that on Android!
Comments
To react to this blog post please check the Reddit thread and the Twitter thread.
You may also like