Recently I have kept working on a minimal roguelike game that was initially mentioned in some earlier post. Since the beginning it has been planned to work on mobiles, so as it became more-less playable I have started to port it into a native Android build.
My stack is composed mainly from Winit (0.28) and WGPU (0.17) and I am not using any 3rd party game engine. Therefore some compatibility issues had to be solved from scratch.
This post is not meant to be a complete step-by-step tutorial. Rather a loose description of some issues one might encounter, when creating an Android game with a similar setup.
The final / current game code is public and split into two repos:
Most of the platform specific issues, that will be discussed here, are related to the first "engine" repo.
In case you'd be curious about the game itself, it can be downloaded or played online on Itch.io ;)
So, let's start. One of the first issues to tackle when developing a graphical app is surely window and GPU surface creation. Winit makes it really easy to builld a shared cross-platform code. However, when targeting Android, there are some extra steps that have to be taken.
We have to use a platform-specific event loop builder, that takes a reference to some AndroidApp
instance (provided in my case by the Android-Activity
crate). It's not too complex though and a simplified window creation function might look like so:
// import the AndroidApp - as reexported by Winit to avoid version incompatibilities
#[cfg(target_os = "android")]
pub use winit::platform::android::activity::AndroidApp;
#[cfg(target_os = "android")]
pub fn build_android(app: AndroidApp) {
use winit::platform::android::EventLoopBuilderExtAndroid;
use winit::event_loop::EventLoopBuilder;
let event_loop = EventLoopBuilder::new()
.with_android_app(app)
.build();
let mut window_builder = WindowBuilder::new();
let window = window_builder.build(&event_loop)
.expect("Can't create window!");
}
The AndroidApp
instance should get injected as a parameter to the Android entry point function (it replaces the default main
for other targets):
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: AndroidApp) {
build_android(app);
// run the game here
}
If you followed the WGPU tutorials (as I did) you might be tempted to create the GPU surface immediately after the window creation. It works well on the desktop platforms and WASM. If you do so on Android though, your app will crash :) The graphics surface is not available yet at that time.
Therefore, at first we should start the standard Winit event loop (headlessly) and wait for the app to become completely ready from the OS's point of view. A safe moment to initialize the graphics seems to be indicated by the Winit's Event::Resumed
It is conveniently also called on other platforms, (at least once after the app has started ) - so, we do not need to create a separate Android-only code here.
Using the resumed
event gives us also one extra benefit. It responds to the lifecycle of an Android app, which is slightly more complex than on the desktop platforms. When you put the game to the background (like when you switch the apps) it gets suspended and might free some system resources. I noticed for example that textures uploaded to the GPU might get dropped. That could cause yet another crash if you try to use them after the app is resumed. So for safety it is advisable to reinit the GPU surface each time it is brought to the foreground. This would also include creating the bind groups and reuploading the textures again. As my games are pixel-art based, I simply keep them in the device's RAM (they take very little memory). But if your resources are heavier you might want to choose a different approach here.
The graphic's context is not the only thing I have noticed to get dropped. I had the same issue with audio, so I'd reinitialize it on the app resume as well.
Now that your app is running, you might want to store some data (user preferences in my case). On Android you can basically use the std::fs
solutions - but only after you find a right place to save those files to :) Fortunately, as Android apps are quite sandboxed, they also have assigned separate disk space for their own use. It's system path can be easily obtained from the AndroidApp
instance:
let os_path = app.internal_data_path();
The great benefit of using this location (and not some shared space - which is also possible) is that it requires zero extra system permissions. So you do not have to bother the user with some pesky ask for permission
requests.
If you're creating a game, you probably want to run it in a fullscreen mode. Thecargo-apk
that I am using for the building process, makes it possible to enable it through a theme setting in a dedicated Cargo.toml
section:
[package.metadata.android.application]
theme = "@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"
There is one problem though. It does not disable the system navigation bar (the bottom three buttons). Initially I thought it must be possible to disable it through a configuration flag of some sort as well, but did not find such a solution.
As much as I wanted to avoid interacting with Java classes, that was the only way I found working (except maybe from writing a proper Java code, which I wanted even less). In order to do so you need to import two extra crates jni
and ndk-context
Then you have to write some questionably looking Rust code with Java typings ;) As I am targeting only newer Android versions I have used the WindowInsetsController
API, which seems to be the new, non-deprecated way, of doing it. I won't describe the code in detail as it's mostly just following Android API. However note that the specific flag values can be found in the Android source code (in case you'd want something different): https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/WindowInsets.java#1470
(you could also import the flag Java class, but that's more hassle than using simple ints).
use jni::{
self,
objects::{JObject, JValueGen},
JavaVM
};
use winit::platform::android::activity::AndroidApp;
pub fn hide_ui() {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.unwrap();
let context = unsafe { JObject::from_raw(ctx.context().cast()) };
let mut env = vm.attach_current_thread().unwrap();
let activity_class = env.find_class("android/app/NativeActivity").unwrap();
let window = env.call_method(
context,
"getWindow",
"()Landroid/view/Window;",
&[]
).
unwrap()
.l()
.unwrap();
let decor_view = env.call_method(
window,
"getDecorView",
"()Landroid/view/View;",
&[]
)
.unwrap()
.l()
.unwrap();
let controller = env.call_method(
decor_view,
"getWindowInsetsController",
"()Landroid/view/WindowInsetsController;",
&[]
)
.unwrap()
.l()
.unwrap();
let val = 1 << 0 | 1 << 1 | 1 << 2;
let jval = JValueGen::Int(val);
let _ = env.call_method(
controller,
"hide",
"(I)V",
&[jval]
)
.unwrap();
}
As mentioned above, I am using the cargo-apk
build system. As it requires installation of some Android specific tools like the SDK, I use a Docker container to keep my main system cleaner.
Consistently with it's name `cargo-apk` creates only APK files (which are handy for testing and Itch.io), however if you want to upload your game to the Play Store, than you need an AAB bundle. Fortunately the creation of those has been figured out already by Macroquad
authors and the method description can be found here: https://macroquad.rs/articles/android/ (there are some differences though, as eg. my builds do not have dex
files).
The Play Store also requires you to target a few different architectures - they have to be added to the rustup
(another reason to keep this separate from the main OS).
That is how my dockerfile looks at the moment:
FROM rust:1.74
RUN apt-get update && apt-get install android-sdk -yy
RUN mkdir /usr/lib/android-sdk/cmdline-tools/
WORKDIR /usr/lib/android-sdk/cmdline-tools
RUN wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip
RUN unzip commandlinetools-linux-9477386_latest.zip
RUN mv /usr/lib/android-sdk/cmdline-tools/cmdline-tools/ /usr/lib/android-sdk/cmdline-tools/latest/
RUN yes | /usr/lib/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses
RUN /usr/lib/android-sdk/cmdline-tools/latest/bin/sdkmanager --install "ndk;25.2.9519653" --channel=0
RUN /usr/lib/android-sdk/cmdline-tools/latest/bin/sdkmanager "platform-tools" "platforms;android-33"
RUN rustup update
RUN rustup target add aarch64-linux-android
RUN rustup target add armv7-linux-androideabi
RUN rustup target add i686-linux-android
RUN rustup target add x86_64-linux-android
RUN cargo install cargo-apk
RUN apt-get update && apt-get install zip
RUN mkdir /usr/lib/extra/
WORKDIR /usr/lib/extra/
RUN wget https://github.com/google/bundletool/releases/download/1.15.6/bundletool-all-1.15.6.jar
RUN wget https://dl.google.com/dl/android/maven2/com/android/tools/build/aapt2/8.2.0-10154469/aapt2-8.2.0-10154469-linux.jar
RUN chmod +x bundletool-all-1.15.6.jar
RUN unzip aapt2-8.2.0-10154469-linux.jar -d aapt2
COPY run.sh /usr/lib/run.sh
RUN chmod +x /usr/lib/run.sh
ENV ANDROID_HOME=/usr/lib/android-sdk
ENV ANDROID_NDK_ROOT=/usr/lib/android-sdk/ndk/25.2.9519653
WORKDIR /app/crates/hike
CMD /usr/lib/run.sh
And the entry run.sh
script:
cargo apk build --release
set -e
TEMP=$(mktemp -d)
/usr/lib/extra/aapt2/aapt2 convert ../../target/release/apk/tower.apk --output-format proto -o $TEMP/app_proto.apk
cd $TEMP
unzip app_proto.apk
mkdir manifest
mv AndroidManifest.xml manifest/
rm app_proto.apk
rm META-INF -rf
zip -r base.zip *
cd -
java -jar /usr/lib/extra/bundletool-all-1.15.6.jar build-bundle --modules=$TEMP/base.zip --output=../../output/android/bundle.aab
jarsigner -keystore $CARGO_APK_RELEASE_KEYSTORE -storepass $CARGO_APK_RELEASE_KEYSTORE_PASSWORD ../../output/android/bundle.aab tower
The signing process and keystore creation is also described well on the Macroquad
site. (In general it has been a great help for me in figuring this whole thing out)
These are mostly all the hiccups I have encountered (and remember) when building for Android. However definitely they're not all the possible issues. For example I have not mentioned at all loading of the resources. My game is small and cross-platform (incl. WASM), so I have decided to use the include_bytes
macro heavily and keep everything inside of the binary. That simplifies things greatly, but might not be possible for more resource heavy games.
I also do not touch anything network related - like telemetry or multiplayer mode. That might create another set of issues to solve.