← Blog

Rust: 97 % des Codes, in 12 Modulen und der obersten EbeneJava: 3 %, für das, was Android nur in Java anbietetui23 %recording17 %gnss11 %oberste Ebene9 %packs9 %paths6 %store6 %backup4 %platform3 %Java3 %fusion2 %achievements2 %
Der Code von walker nach Sprache und Modul: Jede Fläche ist so groß wie ihr Anteil an den Codezeilen, die cloc ohne Leerzeilen und Kommentare zählt, am 27. September 2026. Alles außer dem kleinen orangefarbenen Block Java ist Rust.
Unter der Haube

Eine ganze Android-App in Rust statt Kotlin

Wie walker als Android-App in Rust entstand: egui auf wgpu, Offline-Vektorkarten, eine eingebettete SurrealDB und etwas Java. Was klappte und was wehtat.

· Malik · 10 Min. Lesezeit

Ein Crate, zwei Builds

Kein Review und kein Desktop-Build hat je einen Fehler im Lifecycle gefunden, also darin, wie Android eine App startet, in den Hintergrund schickt und beendet. Jeder einzelne zeigte sich erst auf einem Handy. Den größten Teil der App habe ich am Schreibtisch geschrieben und getestet, in einer einzigen Sprache. Das Handy bekam nur, was ein Schreibtisch nicht zeigen kann.

Die Sprache ist Rust. Geschrieben ist darin walker, die App für Spaziergänge, Wanderungen und Radtouren, die ich für Android entwickle. Sie behält jeden Track auf dem Handy. Die Bildschirme, die Karte, die Datenbank und die Mathematik hinter den Zahlen sind ein einziges Rust-Crate, also ein Paket, das Cargo, das Build-Werkzeug von Rust, als Einheit baut. Java bleibt für das Wenige, das Android nur Java anbietet. Kotlin und Gradle kommen nicht vor.

Das Crate wird auf zwei Arten gebaut. Auf dem Handy ist es eine Bibliothek, die Androids NativeActivity lädt (eine Activity ist bei Android ein Bildschirm einer App samt Fenster). Auf dem Rechner ist es ein Desktop-Build, der dieselbe App in einem Fenster in Handygröße öffnet. Die meiste Zeit verbringe ich in diesem Fenster und in cargo test. Das Handy brauche ich für das, was der Desktop nicht zeigen kann: Touch, die Tastatur, TLS, JNI (das Java Native Interface, die Brücke zwischen Java und nativem Code), den Lifecycle von Android und die Insets, also die Ränder des Fensters, die Statusleiste und Navigationsleiste verdecken.

Die Bausteine

Für die Oberfläche habe ich egui gewählt, über eframe. egui beschreibt sich selbst als „a simple, fast, and highly portable immediate mode GUI library for Rust“. In jedem Frame beschreibt der Code den Bildschirm neu, und es gibt keinen Widget-Baum, der mit den Daten synchron bleiben müsste. eframe kümmert sich um Eingabe und Darstellung und hat zwei Renderer, glow und wgpu.

Welcher es wurde, hat die Karte entschieden. Die Karten sind Kartenregionen, die du herunterlädst: Ausschnitte aus dem Planet-Build von Protomaps im Format PMTiles, einem Archiv aller Kacheln in einer einzigen Datei. walkers, ein Karten-Widget für egui zum Verschieben und Zoomen, zeichnet sie als Vektorkacheln, und diese Vektorkacheln brauchen den wgpu-Renderer von egui. Damit war der Renderer wgpu, nach eigener Beschreibung „a safe and portable graphics library for Rust based on the WebGPU API“. Wie die Höhenlinien und die Schummerung einer Karte auf dem Handy entstehen, zeigt der Beitrag, der auf diesen folgt: Höhenlinien und Schummerung auf Android berechnen.

Die Datenbank ist SurrealDB, eingebettet in den Prozess der App. Sie läuft auf SurrealKV, einer Key-Value-Engine in Rust, und alles, was ins Netz gehen könnte, ist abgeschaltet. Die Engine RocksDB habe ich gemieden, weil sie in C++ geschrieben ist und sich nur mühsam für Android cross-kompilieren lässt. SurrealDB ist async, egui nicht, deshalb läuft die Datenbank in einem eigenen Thread. Die Oberfläche wartet nie auf sie. Sie schickt Befehle und holt die Antworten einmal pro Frame ab, und die Typen von SurrealDB verlassen dieses Modul nie. Eine Aufzeichnung geht ganz an der Datenbank vorbei. Jeder GPS-Punkt landet in einer Journaldatei, und die Datenbank sieht einen einzigen Schreibvorgang, wenn die Aufzeichnung gespeichert wird. Was die Datenbank enthält und wie sie das Handy nur als verschlüsseltes Backup verlässt, steht in Eine App ohne Server: Local-first auf Android.

Der Teil, der aus einem verrauschten GPS-Track Strecke und Höhenmeter macht, ist ein Faktorgraph mit einem Löser nach Levenberg-Marquardt, geschrieben in walker selbst. Davon handelt GPS-Tracks glätten mit einem Faktorgraphen, und GNSS-Rohdaten auf Android: jeden Satelliten nutzen geht eine Ebene tiefer, zu den rohen Satellitenmessungen, die das Handy meldet.

Was an Java übrig bleibt

NativeActivity gibt Rust ein Fenster und Eingabeereignisse, sonst kaum etwas. Was sie nicht kann, steckt in sieben Java-Klassen und in der Portierung eines TLS-Helfers aus zwei Klassen (weiter unten). Zusammen sind das 3 % des Codes von walker, gezählt mit cloc:

Für neue Apps in C und C++ empfiehlt Google inzwischen GameActivity. Das ist eine Jetpack-Bibliothek, und sie bringt Texteingabe gleich mit. Ich bin bei NativeActivity geblieben, die Teil der Plattform ist. So kommt walker weiter ohne Kotlin, Gradle und AARs aus, die Archive, in denen Android-Bibliotheken ausgeliefert werden. Den Preis für diese Wahl zahlt die Tastatur (dazu weiter unten).

Bauen ohne Gradle

Zuerst habe ich cargo-apk verwendet und es wieder aufgegeben: Es kann weder einen Service deklarieren noch Java-Code hinzufügen. Heute baut ein einziges Shell-Skript das APK mit den Werkzeugen des Android-SDK, ohne Gradle. Die native Bibliothek kommt unkomprimiert und auf 16 KB ausgerichtet hinein. So kann Android sie direkt aus dem APK in den Speicher abbilden, statt zuerst eine Kopie zu entpacken. Dasselbe Skript baut auch das App Bundle für Google Play.

Eine Abhängigkeit in C ist trotzdem hineingerutscht. TLS bringt aws-lc-sys mit, und dessen Build scheitert für Android, bis er den C-Compiler aus dem NDK bekommt. Das NDK ist Androids Toolchain für nativen Code, also die Compiler und Werkzeuge für C und C++.

Testen

Der Quellcode hat 541 Tests. Vierzehn davon laufen standardmäßig nicht mit: Zeitmessungen, Prüfläufe, die eine lokale Datei lesen, und die einzigen zwei Tests, die das Netz brauchen. Alle laufen auf dem Rechner mit cargo test. clippy läuft für das Android-Ziel ebenso wie für den Rechner, weil ein clippy nur für den Rechner den Code, der allein für Android gebaut wird, nie zu sehen bekommt. Damit das funktioniert, gilt eine Regel: Die Fachlogik bleibt frei von Typen der Oberfläche und von Android. Für das Layout steuert ein Skript den Desktop-Build, tippt an, gibt Text ein und macht Screenshots. Der Java-Code hat keine Tests.

Übrig bleibt der Lifecycle, und dort hat der Desktop-Build nichts gefunden. Nach jeder Änderung am Service oder an der Activity arbeite ich auf einem Gerät eine Checkliste ab: zum Startbildschirm wechseln, während eine Aufzeichnung läuft; die App aus der Liste der zuletzt verwendeten Apps wischen; den Prozess beenden; einen Speicherdialog offen lassen, während Android walker zerstört.

Was wehtat

Das Zerstören der Activity ließ die App hängen

onDestroy in android-activity blockiert den Main-Thread von Java, bis die Rust-Seite zurückkehrt. winit ignoriert das Zerstören (im Quelltext steht „TODO: forward onDestroy notification to application“), und so kehrte die Rust-Seite nie zurück. GPS-Punkte kommen auf genau diesem Main-Thread an. Sie blieben aus, und Android beendete den Aufzeichnungsdienst, weil er nicht mehr reagierte. Auf meinem OnePlus 7 Pro zerstört schon der Wechsel zum Startbildschirm die Activity, das war also der Normalfall. Ein zweiter Start der App im selben Prozess scheitert ebenfalls, weil winit seine Event-Loop nur einmal pro Prozess anlegt. Die Lösung: Beim Zerstören synchronisiert die App das Journal der Aufzeichnung auf den Datenträger und beendet den Prozess. Der nächste Start bekommt einen frischen Prozess, und die Aufzeichnung kommt aus dem Journal zurück. Der Preis dafür ist, dass die Datenbank auf Android nie sauber herunterfährt. Bei jedem Start spielt sie deshalb ihr Log erneut ab.

Die Bildschirmtastatur

Die View von NativeActivity hat nichts, in das die Tastatur tippen könnte. Dafür ist TextInput da: Es nimmt die Änderungen der Tastatur entgegen und gibt sie als egui-Ereignisse an Rust weiter. Danach flackerte die Tastatur und verlor Text. Solange ein Textfeld den Fokus hatte, versteckte egui die Eingabemethode in jedem Frame und zeigte sie gleich wieder an. Das Verstecken schloss meine Tastatur, und das Anzeigen ging an die falsche View. Behoben ist es, seit egui die Tastatur auf Android gar nicht mehr anfasst. Eine Lücke ist noch offen: Ein Emoji lässt sich stückweise löschen.

JNI scheitert spät

Benennst du eine Java-Klasse, eine Methode oder ein Paket um, bricht die Rust-Seite ohne jede Meldung, und das merkst du erst, wenn diese Methode zum ersten Mal aufgerufen wird. Deshalb hat der Paketname auch keine Unterstriche: JNI kodiert sie auf eine Weise, bei der sich leicht Fehler einschleichen. Und der Service muss die Rust-Bibliothek selbst laden, weil ein Prozess, der nur für den Service startet, die Activity nie lädt.

TLS

Das Rust-Crate, das Zertifikate gegen den Zertifikatsspeicher von Android prüft, ruft über JNI eine Kotlin-Klasse auf. Das APK enthält keine Kotlin-Standardbibliothek, also habe ich die Klasse nach Java portiert. Ein Test schlägt fehl, sobald sich die Version des Crates ändert.

Abhängigkeiten, die sich nicht einig waren

SurrealDB und walkers verlangten Versionen desselben Geometrie-Crates, die sich nicht gleichzeitig erfüllen ließen, und walker hat nun eine gepatchte Kopie dabei. Der Grafiktreiber des Emulators stürzte bei den Debug-Labels von wgpu ab, auf Android sind sie deshalb abgeschaltet. Und ein Release von SurrealDB verliert einen Schreibvorgang, wenn ein Datensatz in einer Transaktion gelöscht und neu geschrieben wird.

Debug-Builds

Mit vollen Debug-Informationen war das Debug-APK rund 320 MB groß, also enthält es jetzt weniger davon. Die Abhängigkeiten werden optimiert, der Code von walker selbst aber nicht, damit er nach einer Änderung schnell neu gebaut ist. Das kostet auf dem Handy Zeit: Zwei Spaziergänge beim Start zu glätten dauerte im Debug-Build 37 Sekunden.

Größe und Tempo

Durch SurrealDB war die arm64-Bibliothek 62 MB groß. Ein Release-Profil, das Bauzeit gegen Größe tauscht, brachte sie auf 25 MB. Seitdem ist sie gewachsen, am 27. September 2026 auf 37,0 MB. 5,7 MB davon sind ein fest einkompilierter Katalog der Regionen. Ein sauberer Release-Build dauert etwa drei Minuten.

Zum Akku habe ich noch keine Messung, die ich zeigen könnte, also behaupte ich auch keine. Beschreiben kann ich den Entwurf: Während einer Aufzeichnung schreibt die App nicht jede Sekunde in die Datenbank, und die Benachrichtigung zeigt Zahlen statt einer Karte. Den Track alle paar Sekunden hineinzuzeichnen, würde Akku kosten.

Würde ich es wieder so machen?

Ja. Eine Sprache vom Sensor-Callback bis zur Datenbank heißt, dass die Typen ohne Bruch durch die ganze App reichen, und der Desktop-Build und die Tests auf dem Rechner finden die meisten Fehler in Sekunden. Was es kostet, liegt an den Rändern, wo Android Java erwartet: im Lifecycle, bei der Tastatur und bei JNI. Plane dafür Zeit ein, und halte ein Handy am Ladekabel bereit.

walker Zeichnet Spaziergänge, Wanderungen und Radtouren mit Offline-Karten auf. Nichts verlässt dein Handy. Bald bei Google Play.