Im Launch-Post zu Halfword blieb eine Sache für einen eigenen Beitrag: wie die Android-Screens ohne Emulator geprüft wurden. Das hier ist dieser Beitrag.

Kurz zur Erinnerung: Halfword ist einmal in Swift geschrieben, und Skip Lite übersetzt es für Android in Kotlin. Jeder Test läuft zweimal, als Swift und als das Kotlin, das daraus geworden ist. Das deckt die Regeln, die Wortpakete und die gespeicherten Spiele ab, aber nicht, wie die Screens aussehen. Dafür muss die App auf Android laufen: auf einem Handy oder im Emulator.

Ich habe mich für keins von beiden entschieden: Die Android-Screens von Halfword zeichnet ein Unit-Test auf der JVM, auf meinem Mac. Hier steht, warum, wie und was die Bilder gefunden haben.

Der Emulator

Erst mal muss das raus. Dinge, die ich über den Android-Emulator gesagt habe, im Wortlaut:

Android simulator is shame.

android simulator is abominable

Android deserves all hate it gets simply due to their sdks

Seine Manieren hatte der Emulator schon am ersten Tag gezeigt: Der Emulator mit Android 16 war zu ausgelastet, um die instrumentierten Tests zu installieren, und antwortete mit „Can't find service: package“. Deshalb ist noch nie ein Test auf Androids eigenem SQLite gelaufen. Robolectric führt die Kotlin-Tests ohnehin auf der JVM aus, das konnte also warten.

Am nächsten Tag hatte mein Mac eine Kernel Panic. Es ist ein M2 mit 16 GB. Die Panic war ein Watchdog-Timeout, mit dem Memory Compressor bei „100% of segments limit“. In dem Moment liefen vier Builds gleichzeitig in vier Worktrees, jeder mit Swift und Xcode auf der einen Seite und Gradle für die Kotlin-Seite auf der anderen, und obendrauf noch der Android-Emulator. Meine Diagnose hinterher:

java was butchering both cpu and ram usage

Also habe ich Android auf Eis gelegt. iOS kam raus, und Android wartete. Dann habe ich es wieder angeschaltet („start working on Android milestone - simple porting“), mit einem Wunsch: „if you have a way of rendering the UI without a simulator it'd be great“.

Es gibt einen Weg.

Die Tests laufen schon auf Android. Irgendwie.

Skips eigene Android-Tests brauchen kein Gerät. Standardmäßig laufen sie unter Robolectric, das die echten Klassen des Android-Frameworks auf einer Desktop-JVM ausführt und die Hardware ersetzt. Als Android zurückkam, liefen alle 721 Tests in beiden Spalten durch, Swift und Kotlin, bis hin zum App-Modell, das einen ganzen Spieleabend durchspielt, nach zwei Korrekturen an den Tests und keiner an der App.

Die übersetzte App läuft also schon unter Robolectric. Im Native-Graphics-Modus zeichnet Robolectric mit Androids eigenem Skia auf dem Host, und Roborazzi speichert den Screen als PNG. Zusammen ergibt das einen JUnit-Test im Android-App-Modul, der:

  • die echte MainActivity der App startet, dieselbe Main.kt, die auf einem Handy läuft, mit Compose und SkipUI darunter;
  • auf einem vorgetäuschten Pixel 7 (412 × 915 dp bei 420 dpi, also Dichte 2,625, Dark Mode);
  • einen Screen öffnet, die App einen Moment laufen lässt und das Bild speichert.

Kein Emulator, kein adb, keine APK zum Installieren. Ein einziger Gradle-Lauf.

So funktioniert es

Die App hatte schon 114 Fixtures: benannte Zustände wie die Abstimmung mitten im Auszählen, der Tipp von Mr. White oder eine Kaskade von Rauswürfen, die ein Debug-Build direkt beim Start öffnet. Auf iOS startet ein Skript den Simulator mit HALFWORD_FIXTURE=round-voting und macht einen Screenshot. Auf Android schreibt dasselbe Skript für einen Emulator per adb shell run-as eine Startdatei in den Ordner files/ der App.

Der Render macht dasselbe, nur ohne adb. Gekürzt aus dem Test, FixtureShots:

@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w412dp-h915dp-normal-long-notround-port-night-420dpi-keyshidden-nonav")
class FixtureShots {
    private fun draw(fixture: String, lang: String, file: File): String? {
        // Pick the screen the way the emulator script does: a launch file in files/.
        File(folder, LaunchOptions.fileName).writeText(
            "${LaunchOptions.fixtureKey}=$fixture\n${LaunchOptions.languageKey}=$lang\n")
        HalfwordRootView.forgetLaunch()   // every picture shares one process

        val looper = shadowOf(Looper.getMainLooper())
        val activity = Robolectric.buildActivity(MainActivity::class.java).setup()
        try {
            var waited = 0L
            while (!ready.exists() && waited < FIRST_FRAME_LIMIT_MS) {  // until the fixture is up,
                step(looper); waited += FRAME_MS                         // 20 s of app time at most
            }
            …
            var settled = 0L
            while (settled < settleMs) { step(looper); settled += FRAME_MS }  // then 1.5 s of app time
            captureScreenRoboImage(file)                                       // every window, alerts too
            …
        } finally {
            activity.pause().stop().destroy()
        }
    }
}

Zwei kleine Stücke App-Code helfen dabei: Sobald die Fixture steht, schreibt die App eine „ready“-Datei, auf die das Screenshot-Skript ohnehin schon gewartet hat, und HalfwordRootView.forgetLaunch() verwirft den Start, den sich der Prozess gemerkt hat, denn alle 456 Bilder teilen sich eine JVM. Beides sind Türen nur für Debug-Builds: Ein weiterer Robolectric-Test startet die echte Activity als Release-Build mit einer Startdatei und prüft, dass sie ignoriert wird.

Die Uhr gehört mir. Robolectrics Main Looper bewegt sich nur, wenn der Test ihn bewegt. Also schiebt der Test ihn jeweils 16 ms weiter, einen Frame, bis die Fixture auf dem Screen ist, und dann noch 1,5 Sekunden App-Zeit, die Einschwingzeit des Screenshot-Skripts. Die Sprites, der Zug-Timer („0:29“) und die Enthüllung beim Rauswurf stehen am Ende genau da, wo sie nach 1,5 s wären, und niemand wartet 1,5 Sekunden.

Das war jedenfalls der Plan. Zwei Dinge mussten von Hand erledigt werden: der Layout-Durchgang, den ein Handy bei jedem Frame macht, und eine Uhr, die bei jedem Lauf dasselbe Bild liefert.

Ein Handy zeichnet jeden Frame. Robolectric nicht.

Die ersten Bilder hatten die obere Leiste und die Buttons, und nichts dazwischen. Jede scrollende Liste war leer: die Zeilen des Startbildschirms, die Abstimmung, das Ergebnis.

Compose misst und platziert einen Knoten, der nur ein Neuzeichnen angefordert hat, erst wenn das Fenster gezeichnet wird (dispatchDraw). Ein Handy zeichnet jeden Frame, der sich geändert hat. Robolectric zeichnet zwischen den Bildern nie, also wurde der Inhalt jedes GeometryReader komponiert und nie platziert, und jeder ScreenScroll (der scrollende Rahmen der App) blieb leer. Die Lösung: den ersten Schritt des Handys von Hand erledigen, in jedem Fenster, damit auch Dialoge dabei sind:

private fun layOut() {
    for (root in windowRoots()) visit(root)
}

private fun visit(view: View) {
    if (view is RootForTest) view.measureAndLayoutForTest()
    if (view is ViewGroup) for (i in 0 until view.childCount) visit(view.getChildAt(i))
}

/// Every window's root view: WindowManagerGlobal's list, by reflection (it's hidden API).
private fun windowRoots(): List<View> {
    val global = Class.forName("android.view.WindowManagerGlobal")
    val instance = global.getMethod("getInstance").invoke(null)
    val field = global.getDeclaredField("mViews").apply { isAccessible = true }
    return (field.get(instance) as List<*>).filterIsInstance<View>()
}

Dasselbe Bild bei jedem Lauf

Dann waren sich die Bilder nicht mehr einig. Von einem Lauf zum nächsten war ein Sprite in einem oder zwei der 114 englischen Bilder einen Frame daneben, und in drei bis sieben, wenn alle Kerne ausgelastet waren. Der .task einer View startet auf einem Hintergrund-Thread und springt dann auf den Main Thread, und was er dorthin schickt, bekommt den Stand der Uhr in dem Moment, in dem der Thread ankommt. Robolectrics idleFor hat eine ganze Strecke von Tasks auf einmal abgearbeitet, also hing es davon ab, wie schnell die Threads liefen, ob der Tick eines Sprites vor oder nach einem Frame landete.

Jetzt führt der Main Looper einen Task nach dem anderen aus, die Uhr bewegt sich nur zwischen den Tasks, und zwischen zweien wartet der Test, bis der Scheduler von kotlinx.coroutines ruhig ist: nichts in der Warteschlange, kein CPU-Permit vergeben, kein blockierender Task. Das ist interne API, per Reflection gelesen. Die Zustände der Worker-Threads allein reichten nicht, weil ein aufgeweckter Worker geparkt aussieht, bis das Betriebssystem ihn einplant.

/// One frame (16 ms) of app time, one main-thread task at a time.
private fun step(looper: ShadowLooper) {
    val end = SystemClock.uptimeMillis() + FRAME_MS
    while (true) {
        settle(looper)   // lay out every window, wait until the coroutine pool is quiet
        val next = looper.nextScheduledTaskTime.toMillis()
        if (next == 0L || next > end) break
        looper.runOneTask()
    }
    settle(looper)
    val rest = end - SystemClock.uptimeMillis()
    if (rest > 0L) ShadowSystemClock.advanceBy(Duration.ofMillis(rest))
}

Noch etwas: Die erste Activity eines Laufs wird gezeichnet und weggeworfen. Der erste Render eines Prozesses lädt Klassen und startet die Coroutine-Threads, und das so langsam, dass ein Sprite einen Frame zu spät kommt.

Danach kamen drei Läufe aller 114 englischen Bilder Byte für Byte gleich heraus. Schneller wurde es auch, weil keine festen Wartezeiten mehr übrig sind: Alle 456 Bilder brauchten statt 3 min 11 s nur noch 2 min 19 s.

Ein Bild, für das der Render nicht geradestehen kann, lässt den Lauf scheitern, mit einer Datei <name>.failed, die sagt, warum: Seine ready-Datei nennt eine andere Fixture (ein veralteter Start), oder eine Wartezeit ist abgelaufen („unsettled“). Außerdem entfernt das Skript vor Gradle die vier Start-Variablen (HALFWORD_FIXTURE, HALFWORD_LANG, …), und der Test weigert sich zu zeichnen, wenn noch eine davon gesetzt ist. Die App gibt ihnen Vorrang vor der Startdatei, ein exportiertes HALFWORD_LANG=de würde also sonst jedes Bild unter einem englischen Namen auf Deutsch zeichnen. Jede dieser Sicherungen wurde geprüft, indem sie mit Absicht kaputtgemacht wurde.

Der Gradle-Teil

Die Unit-Tests des App-Moduls brauchten das, was Skips generierte Modul-Builds ihren eigenen mitgeben, plus ein paar Flags:

android {
    testOptions { unitTests { isIncludeAndroidResources = true } }  // Micro 5 lives in res/font
}

dependencies {   // condensed from the app module's build.gradle.kts, Skip's catalog aliases spelled out
    testImplementation("org.robolectric:robolectric:4.16.1")
    testImplementation("androidx.test:core:1.7.0")       // ApplicationProvider
    testImplementation("androidx.test.ext:junit:1.3.0")  // AndroidJUnit4
    testImplementation("io.github.takahirom.roborazzi:roborazzi:1.76.0")
    testImplementation("net.java.dev.jna:jna:5.19.1")    // skip-sql reaches SQLite through JNA: the desktop jar
    testImplementation("org.ow2.asm:asm:9.10")           // Robolectric's own ASM can't read JDK 26 classes
}

tasks.withType<Test>().configureEach {
    // What to draw and where: the script's -Phalfword.shots.* become the test's system properties.
    listOf("fixtures", "langs", "out", "devices", "theme", "settle", "fontScale").forEach { key ->
        val name = "halfword.shots.$key"
        project.findProperty(name)?.let { systemProperty(name, it.toString()) }
    }
    systemProperty("robolectric.graphicsMode", "NATIVE")
    systemProperty("roborazzi.test.record", "true")  // no Roborazzi plugin, nothing to compare
    maxHeapSize = "2g"
}
  • Das Desktop-JAR von JNA. skip-sql erreicht SQLite über JNA, und nur mit dem Android-AAR scheiterte es mit „Could not initialize class com.sun.jna.Native“.
  • ASM 9.10. Das mitgelieferte ASM von Robolectric kann die Klassen von JDK 26 nicht lesen: „Unsupported class file major version 70“.
  • isIncludeAndroidResources, sonst kein Micro 5: Die Pixelschrift ist eine App-Ressource, und SkipUI fällt stillschweigend auf die Standard-Sans-Serif zurück.
  • roborazzi.test.record: Roborazzi ohne sein Gradle-Plugin, nur zum Aufnehmen. Es gibt nichts, womit man vergleichen könnte; die Bilder werden angeschaut.
  • Die halfword.shots.*-Schleife. Eine -P-Property bleibt bei Gradle; die Test-JVM sieht nur, was der Build ihr als System-Property weiterreicht. Ohne die Schleife kommt die Fixture-Liste des Skripts nie an, und der Test überspringt sich selbst.
  • -Pkotlin.incremental=false. Ein inkrementeller Compile von Skips eingebundenem Build scheiterte mit 30 Fehlern wie „Cannot access 'class CascadeState': it is internal in file“, reproduzierbar, nach einer einzeiligen Änderung an einem Doc-Kommentar. Ursache unbekannt. Es muss ein -P-Flag sein: gradle.properties erreicht den eingebundenen Build nicht.
  • -Pkotlin.compiler.execution.strategy=in-process: Es bleibt kein Kotlin-Daemon zurück, und den von Gradle stoppt das Skript hinterher. Auf einem Mac mit 16 GB ist eine übrig gebliebene JVM der Feind.
  • -Pandroid.onlyEnableUnitTestForTheTestedBuildType=false, nicht für den Render, sondern für den Release-Build-Test von oben: AGP 9.2 erzeugt Unit-Tests nur für den getesteten Build-Typ, ohne das Flag gibt es :app:testReleaseUnitTest also gar nicht.

Die Flags des Renders stecken in einem Shell-Skript, android-shots.sh, das (bis zu 10 Minuten) wartet, bis mindestens 25 % des Speichers frei sind, bevor es Gradle startet. Meine Regel seit der Panic: höchstens zwei schwere Builds gleichzeitig, und Gradle nur allein.

scripts/android-shots.sh --lang en,de,es,ru                     # all 114 fixtures: 456 PNGs
scripts/android-shots.sh --device small --theme light setup-rules
scripts/android-shots.sh --font-scale 2.0 round-pk-describing

Was es gefunden hat

Der erste volle Lauf zeichnete alle 456 Bilder, ohne einen einzigen Fehlschlag: jeden Screen des Spiels auf Englisch, Deutsch, Spanisch und Russisch, mit gestochen scharfer Pixelart aus ganzen Pixeln und Micro 5 direkt aus res/font. Dann fingen die Bilder an, den Code zu verpetzen.

Ein echter Bug, den die Bilder nicht zeigen konnten

In Swift laufen die Methoden einer SwiftUI-View auf dem Main Actor, also läuft die Schleife private func run() async eines Sprites auf dem Main Thread. skipstone, der Transpiler von Skip, packt jede async-Funktion, die an keinen Actor gebunden ist, in SkipLibs Async.run, also withContext(Dispatchers.Default): einen Hintergrund-Thread. Auf Android liefen sechs Schleifen dort: die Sprites, beide Enthüllungen beim Rauswurf, der Zug-Timer, das Gedrückthalten beim Aufdecken und das Zittern der Hälfte, unseres Maskottchens. Sie schrieben @State abseits des Main Threads, und beim haptischen Impuls des Timers drohte eine Race Condition mit dem des Main Threads.

// skipstone honours the attribute: the body becomes MainActor.run on Android.
@MainActor private func run() async {
    …
}

Der ehrliche Teil: Kein Bild kann eine Race Condition zeigen, und der Render serialisiert genau diese Nebenläufigkeit ohnehin. Was ein Bild zeigen kann, ist Zeit. Die erste Version des Renders setzte kotlinx.coroutines.main.delay=true und legte damit jedes Coroutine-delay auf Robolectrics Uhr, was das Problem sauber übertüncht hat, bis ein Review des Renders die Ursache fand. Ohne die Property wartet ein delay abseits des Main Threads in echter Zeit, während das Bild nach Robolectrics Uhr aufgenommen wird: Als das Attribut testweise wieder entfernt wurde, blieben beide Rauswurf-Screens beim einsamen bernsteinfarbenen Strich der Hälfte stehen statt beim Ergebnis.

Deshalb durchsucht das Skript nach dem Zeichnen das übersetzte Kotlin des App-Moduls mit grep und endet mit Exit-Code 1, wenn irgendwo noch ein Async.run steht:

if [ ! -d "$KOTLIN" ]; then
  echo "android-shots: no transpiled Kotlin in $KOTLIN: the main-thread check did not run" >&2
elif found="$(grep -rn 'Async\.run' "$KOTLIN")"; then
  echo "android-shots: these async funcs run off the main thread on Android (mark them @MainActor):" >&2
  …
fi

Dialoge in der Sprache des Handys

Die UI-Sprache von Halfword ist eine Einstellung, unabhängig von der des Handys. Auf Android haben alle Dialoge sie ignoriert.

Vorher: ein deutscher Tisch, ein englischer Dialog, auf dem Grau von Material
Vorher: ein deutscher Tisch, ein englischer Dialog, auf dem Grau von Material
Nachher: Der Dialog spricht Deutsch, auf dem eigenen Tintenschwarz der App
Nachher: Der Dialog spricht Deutsch, auf dem eigenen Tintenschwarz der App

Ein Compose-Dialog ist ein eigenes Fenster, und seine View holt sich LocalConfiguration frisch vom Handy. Die Locale, die die Root-View der App setzt, kommt dort also nie an, und genau dort löst SkipUI die Strings des Dialogs auf. Text, der schon formatiert ist, bevor er beim Dialog ankommt, kommt richtig heraus, also ist jetzt jedes Wort aller 12 Dialoge ein fertiger String in der UI-Sprache, Text(verbatim: model.string(…)). Menüs ebenso, weil auch sie Pop-up-Fenster sind (hergeleitet, nicht gesehen: Keine Fixture öffnet ein Menü). Und der String-Check lehnt jetzt einen Text aus dem Katalog in einem Dialog oder Menü ab, damit ein Katalog-Schlüssel, der dort hineingeschrieben wird, den Fehler nicht zurückbringen kann (ein Helfer, der einen Text zurückgibt, könnte das noch und wird von Hand draußen gehalten).

Keiner fliegt, auf Russisch

Auf dem russischen Regeln-Screen wurde in der Zeile für den Gleichstand „Без выбывания“ („Keiner fliegt“) an beiden Enden abgeschnitten.

Android, vorher: Jedes Segment bekommt denselben Anteil, und „Без выбывания“ verliert beide Enden
Android, vorher: Jedes Segment bekommt denselben Anteil, und „Без выбывания“ verliert beide Enden
Android, nachher: Die lange Beschriftung behält ihre Breite
Android, nachher: Die lange Beschriftung behält ihre Breite
iPhone, zum Vergleich
iPhone, zum Vergleich

SwiftUI gibt einem Segment mindestens die Breite seiner Beschriftung. SkipUIs HStack gibt jedem Kind denselben Anteil, und sein ViewThatFits prüft nur die Summe der Beschriftungen, also passte die Zeile auf dem Papier, und die breiteste Beschriftung wurde abgeschnitten. Auch dem englischen „No elimination“ fehlten 6 Punkte, es fiel nur weniger auf. SegmentedChoice misst jetzt jede Beschriftung, und ein Segment, dessen Beschriftung breiter ist als sein Anteil, bekommt die Breite seiner Beschriftung. Dafür braucht es ein .frame(width:), weil SkipUI sogar schlichten Text auf den Anteil begrenzt, und eine frische ForEach-ID, weil sich SkipUIs Container merken, dass ihr Inhalt die Zeile einmal ausgefüllt hat.

Die Kante, die nie auftauchte

Wenn eine Liste unter den Buttons weiterläuft, markiert eine Haarlinie die Kante, damit die Buttons nicht wie das Ende der Liste wirken. Auf Android tauchte sie nie auf.

Vorher: Das Ergebnis läuft unter den Buttons weiter, und nichts verrät es
Vorher: Das Ergebnis läuft unter den Buttons weiter, und nichts verrät es
Nachher: Eine Haarlinie markiert die Kante
Nachher: Eine Haarlinie markiert die Kante

SkipUI baut onGeometryChange auf boundsInRoot() von Compose auf, das die Grenzen einer View auf jeden Vorfahren zuschneidet. In einer Scroll-View scheint der Inhalt deshalb nie unterhalb der Kante zu enden. Jetzt sitzt eine 1 Punkt hohe Markierung 12 Punkte über dem Ende des Inhalts und meldet, wo sie ist oder dass sie weggeschnitten wurde, und die Kante wird gezeichnet, solange die Markierung auf oder unter dem unteren Rand liegt.

Und noch eine Handvoll

  • Die Farben des Hintergrundbilds. Ab Android 12 baut SkipUI das Farbschema von Material aus dem Hintergrundbild des Handys. Auf der JVM hieß das: die Standardpalette von Android 16, ein marineblauer Knopf auf der bernsteinfarbenen Schiene der Schalter, Dialoge auf dem Grau von Material (der Vorher-Dialog oben). Die Android-Shell setzt das Schema jetzt aus der Palette der App, und jedes Paar aus Text und Farbe ist auf Kontrast getestet.
  • Winzige Chevrons. SkipUI zeichnet ein SF Symbol als Material-Icon in einer Box von der Größe der Schrift, und die Pfeile der Abstimmung kamen bei etwa 4 × 8 dp heraus. Jetzt sind es gezeichnete Striche, auf beiden Plattformen.
  • Die Kaskade. Wenn die Verliebten zusammen rausflogen, schwebten ihre beiden Karten allein, und die Zeile, die erklärt, warum, saß etwa 200 dp tiefer. SkipUIs fixedSize misst die Reihe, lässt sie aber frei wachsen, und die Karten füllten sie aus. Jetzt misst jede Karte sich selbst, und die Reihe übernimmt die Höhe der höchsten.
  • Die Kopfzeile. „Runde 2 · Stichwahl 1 von 2 · Zug 1 von 2“ brach auf Deutsch, Spanisch und Russisch mitten in einem Teil um („Turno 1 de / 2“). Die erste Korrektur brach dann bei Androids größter Schriftgröße einen Teil zwischen zwei Buchstaben um, und das hat das Flag --font-scale des Renders erwischt.
Vorher: SF Symbols als Material-Icons, etwa 4 × 8 dp
Vorher: SF Symbols als Material-Icons, etwa 4 × 8 dp
Nachher: gezeichnete Striche, auf beiden Plattformen gleich
Nachher: gezeichnete Striche, auf beiden Plattformen gleich
Vorher: Die Karten der Verliebten schweben, und ihre Zeile steht etwa 200 dp tiefer
Vorher: Die Karten der Verliebten schweben, und ihre Zeile steht etwa 200 dp tiefer
Nachher: Karten und Zeile beisammen, wie auf dem iPhone
Nachher: Karten und Zeile beisammen, wie auf dem iPhone

Nach den Dialogen sind vier Reviewer alle 456 Bilder genau durchgegangen und haben neun weitere Fehler gefunden. Acht sind behoben, und sie gingen auf sechs Ursachen zurück. Soweit sich das aus dem Quellcode von SkipUI sagen lässt, tun SkipUI und Compose bei jedem davon genau das, was sie auch auf einem Handy tun würden; die Eigenheiten von Robolectric gingen auf das Konto des Testaufbaus selbst. Nach den Korrekturen hatten sich 193 der 456 Bilder geändert, jedes dort, wo eine Korrektur es ändern sollte, oder um einen Frame eines Sprites.

Ein paar Dinge warten noch auf mich. Die neuen Chevrons ändern auch iOS, deshalb muss ich sie mir auf einem iPhone ansehen. Und das Eingabefeld für den Tipp von Mr. White (und das Namensfeld auf dem Screen „Spieler“) sitzt auf Android 16 dp weiter innen, weil das Textfeld von SkipUI das eigene Padding von Material innerhalb des Paddings der App behält. Die Lösung bräuchte entweder eine Plattformbedingung im gemeinsamen Code, und die braucht meine Erlaubnis, oder eine Einstellung für die ganze Shell, die auch dem Feld im Umbenennen-Dialog sein Padding nehmen würde. Das entscheide also ich.

Die Zahlen

  • 456 Bilder (114 Fixtures × 4 Sprachen) in 2 min 19 s, davon etwa 130 s Zeichnen: grob 0,3 s pro Bild. Englisch allein dauert etwa 50 s.
  • Byte-identisch, Lauf für Lauf, auf einem ruhigen Mac: Drei Läufe aller 114 englischen Bilder stimmten überein. Mit acht CPU-Fressern auf den acht Kernen hatte 1 von 114 noch einen Sprite einen Frame daneben (gemessen vor der @MainActor-Korrektur; unter Last seitdem nicht wiederholt).
  • 1081 × 2401 px pro Bild: ein Pixel 7 mit w412dp-h915dp-…-night-420dpi. Oder ein kleines Handy (360 × 640 dp bei xhdpi, 720 × 1280 px), das helle Theme, jede Schriftgröße.
  • Ein Gradle-Build, eine Test-JVM von etwa 1,9 GB, der Daemon hinterher gestoppt.
  • Der Stack: Robolectric 4.16.1, Roborazzi 1.76, AGP 9.2, Kotlin 2.3, Compose BOM 2026.05.01, JDK 26.
  • Dafür gebootete Emulatoren: null.

Was es nicht verraten kann

Das sind Robolectric-Renders auf einem Mac, nicht auf einem Handy, und Folgendes bleibt dabei außen vor:

  • Text wird mit den Schriften aus Robolectrics Android-Image gezeichnet (Roboto, Kyrillisch inklusive), aber vom FreeType des Macs gerastert, also kann das Hinting um einen Pixel von dem eines Handys abweichen. Eine Samsung-Schrift sieht man nie.
  • Keine Systemleisten. Keine Statusleiste, keine Navigationsleiste, keine Notch, also sitzt die obere Leiste bei y = 0, wo ein Handy sie unter die Statusleiste setzt.
  • Schatten und Unschärfe können flach ausfallen oder fehlen. Die App selbst nutzt keine, aber der Dialog von Material hat eine Elevation.
  • Kein Touch. Kein Ripple, kein gedrückter Zustand, keine Tastatur, kein TalkBack, und nichts vibriert. Gezeichnet wird nur, was eine Fixture öffnet: Menüs, Sheets und Drags brauchen einen Menschen.
  • Kein echtes Gerät. Das Pixel 7 ist eine Handvoll Qualifier, kein Pixel 7. Unter „Über Halfword“ steht stolz „robolectric · Android 16 (API 36)“.
  • Geliehene Interna. Für den Determinismus liest der Render die Interna von kotlinx.coroutines per Reflection; wenn ein Update sie verschiebt, fällt er auf die Zustände der Threads zurück und sagt das auch.

Android steht also gut da, und niemand hat es bisher in der Hand gehabt. Debug- und Release-APK lassen sich bauen, jeder Test besteht als Swift und als Kotlin, und jeder Screen, den eine Fixture öffnet, wird gerendert. Aber noch ist keine APK auf irgendetwas installiert worden. Das kommt als Nächstes, und dafür braucht es ein Handy.

Seitdem hat der Release-Build auf dem Emulator ganze Partien durchlaufen, und Halfword ist bei Google Play erschienen.

Zum Klauen

Wenn dein Rechner beim Emulator auch zusammenzuckt: Das meiste vom Rezept steht im Code oben. Das eine Stück, das ich nur beschrieben habe, ist das Warten zwischen den Tasks des Main Threads, und genau das hat am längsten gedauert, bis es stimmte, deshalb hier gekürzt. settle ist das, was step aufruft:

/// Runs what is due on the main looper, one task at a time, laying out every window after each
/// and waiting for the coroutine pool to finish what the task started, until nothing is due.
private fun settle(looper: ShadowLooper) {
    var rounds = 0
    while (rounds < 10_000) {
        rounds += 1
        var waits = 0
        while (coroutinesBusy()) {
            if (waits == 20_000) { giveUp("the coroutine pool still busy after 2 s"); break }
            LockSupport.parkNanos(100_000L)
            waits += 1
        }
        layOut()                          // a layout can compose views, whose tasks start
        if (coroutinesBusy()) continue
        if (looper.isIdle) return
        looper.runOneTask()
    }
    giveUp("the main looper not idle after 10,000 rounds")   // the picture fails as "unsettled"
}

coroutinesBusy() fragt den Scheduler hinter Dispatchers.Default (und IO, derselbe Pool), ob ein CPU-Permit vergeben ist, ein blockierender Task läuft oder ein Task in der Warteschlange steht. Es zählt einen Task ab dem Moment, in dem er dispatcht wird, noch bevor irgendein Worker aufwacht, und genau das entgeht den Zuständen der Threads. Es ist interne API von kotlinx.coroutines 1.11, deshalb wird es per Reflection gelesen, einmal pro Prozess:

private object Pool {
    val read: (() -> Boolean)? = try {
        val dispatcher: Any = kotlinx.coroutines.Dispatchers.Default
        var type: Class<*>? = dispatcher.javaClass
        var field: java.lang.reflect.Field? = null
        while (type != null && field == null) {   // `coroutineScheduler`, somewhere up the class chain
            field = type.declaredFields.firstOrNull { it.name == "coroutineScheduler" }
            type = type.superclass
        }
        field!!.isAccessible = true
        val scheduler: Any = field.get(dispatcher)!!
        val kind = scheduler.javaClass
        val state = kind.getDeclaredField("controlState\$volatile").also { it.isAccessible = true }
        val blockingMask = kind.getDeclaredField("BLOCKING_MASK").also { it.isAccessible = true }.getLong(null)
        val blockingShift = kind.getDeclaredField("BLOCKING_SHIFT").also { it.isAccessible = true }.getInt(null)
        val core = kind.getField("corePoolSize").getInt(scheduler)
        val available = kind.getMethod("availableCpuPermits", java.lang.Long.TYPE)
        val queues = listOf(kind.getField("globalCpuQueue").get(scheduler)!!,
                            kind.getField("globalBlockingQueue").get(scheduler)!!)
        val size = queues[0].javaClass.superclass.getMethod("getSize")
        val reader: () -> Boolean = {
            val now = state.getLong(scheduler)
            val running = core - (available.invoke(scheduler, now) as Int)
            val blocking = (now and blockingMask) shr blockingShift
            running > 0 || blocking > 0L || queues.any { (size.invoke(it) as Int) > 0 }
        }
        reader()   // fail here, not halfway through a render, if an update moved something
        reader
    } catch (_: Throwable) {
        null       // then fall back on the worker threads' states, and say so in the log
    }
}

Die Kurzfassung:

  1. Gib deinem Debug-Build eine Möglichkeit, jeden Screen aus einer Datei heraus zu öffnen und zu melden, wann er steht.
  2. Schreib einen einzigen Robolectric-Test mit @GraphicsMode(NATIVE), der deine echte Activity startet, einmal pro Screen, und sie mit Roborazzis captureScreenRoboImage speichert.
  3. Beweg die Uhr selbst, einen Task des Main Threads nach dem anderen, führe dabei für jedes Fenster das Layout aus und warte zwischen den Tasks auf den Coroutine-Pool. Wirf vorher einen Aufwärm-Render weg: Die erste Activity eines Prozesses ist so langsam, dass ein Sprite einen Frame zu spät kommt.
  4. Lass jedes Bild scheitern, für das du nicht geradestehen kannst.
  5. Schau dir die Bilder an. Fast jeder Bug oben wurde beim Anschauen eines Bildes gefunden, nicht durch einen Diff.

Und die Store-Screenshots

Die Fixtures machen sich gleich doppelt bezahlt: Auch die Store-Screenshots entstehen aus ihnen, für beide Stores, gemacht von Skripten statt von mir mit einem Handy.

App Store. Ein Skript baut die Debug-App (ein Release-Build ignoriert Fixtures), bootet einen festgelegten Simulator und startet für jede von zehn Fixtures die App mit gesetztem HALFWORD_FIXTURE und HALFWORD_LANG, wartet auf ihre ready-Datei, stellt die Statusleiste auf 9:41 und macht den Screenshot. Ein Python-Skript rahmt dann jeden ein: Die Hälfte steht in 10-facher Größe auf dem Rand des Handys und sagt ihren Text in einer Sprechblase wie der auf dem Startbildschirm, in Micro 5 oder, wo Micro 5 keine Buchstaben hat (Russisch, Ukrainisch), in einer fetten Systemschrift. Headless Chrome zeichnet die Rahmen in genau 1320 × 2868, der Größe, die App Store Connect für jedes kleinere iPhone herunterskaliert, und deliver von fastlane lädt sie hoch, einen nach dem anderen, weil App Store Connect an parallelen Uploads zu einer Version erstickt.

Google Play. Dieselben Fixtures, kein Simulator: Der Render aus diesem Post zeichnet acht davon in sechs Sprachen, und dasselbe Skript rahmt sie in 1080 × 1920. Diese Größe ist nicht frei gewählt. Play lehnt einen Screenshot ab, bei dem eine Seite mehr als doppelt so lang ist wie die andere, und die 1081 × 2401 des Pixel 7 sind länger als das, also ist der Rahmen 9:16, mit dem Maskottchen, der Hälfte, in 8-facher Größe und Micro 5 bei 12 px pro Schriftpixel. Der JVM-Render hat keine Statusleiste, deshalb gibt der Rahmen dem Screen oben und unten 24 dp Tintenschwarz, wo ein Handy seine Leisten hat. Dann lädt supply von fastlane den Satz hoch, aus Lanes, die nur die internen und geschlossenen Test-Tracks von Play kennen: Die Produktion bleibt ein Button in der Play Console.

Ein veralteter Satz kann nicht hochgeladen werden. Einmal stammten App-Store-Screenshots aus einem Debug-Build, der noch von vor einer Regeländerung übrig war: die alten Titel und ein Bild, auf dem „Fixture failed“ stand. Deshalb trägt jetzt jeder Satz einen Hash der Quellen, aus denen er gezeichnet wurde, die Upload-Lanes lehnen einen Satz ab, dessen Hash nicht zum Checkout passt, und ein Lauf löscht den alten Satz, bevor er anfängt, damit ein gescheiterter Lauf nichts zum Hochladen zurücklässt.

Das war’s

Der Render braucht den Emulator nach wie vor nicht. Mein Mac zeichnet die Android-Screens von Halfword in der Zeit, in der ein Kaffee durchläuft, in vier Sprachen, mit denselben Pixeln bei jedem Lauf, solange man ihn in Ruhe lässt. Und „Без выбывания“ passt.