В посте о запуске Halfword одна тема осталась на отдельный пост: как экраны для Android проверяли без эмулятора. Вот он.

Коротко напомню: Halfword написан один раз на Swift, а Skip Lite переводит его в Kotlin для Android. Каждый тест прогоняется дважды: как Swift и как Kotlin, в который он превратился. Так проверяются правила, наборы слов и сохранённые игры, но не то, как выглядят экраны. Для этого приложение нужно запустить на Android: на телефоне или в эмуляторе.

Я не выбрал ни то, ни другое: экраны Halfword для Android рисует юнит-тест на JVM, прямо на моём Mac. Рассказываю, почему, как и что эти картинки поймали.

Эмулятор

Сначала выговорюсь. Вот что я говорил про эмулятор Android, дословно:

Android simulator is shame.

android simulator is abominable

Android deserves all hate it gets simply due to their sdks

Свой характер эмулятор показал в первый же день: эмулятор Android 16 был так загружен, что не смог установить инструментальные тесты, и ответил «Can't find service: package». Так что ни один тест до сих пор не запускался на SQLite самого Android. Kotlin-тесты всё равно гоняет Robolectric на JVM, так что это могло подождать.

На следующий день мой Mac словил kernel panic. Это M2 с 16 ГБ. Паника случилась по таймауту watchdog, а компрессор памяти упёрся в «100% of segments limit». В тот момент в четырёх worktree одновременно шли четыре сборки, у каждой с одной стороны Swift и Xcode, с другой — Gradle для Kotlin, а сверху ещё работал эмулятор Android. Мой диагноз задним числом:

java was butchering both cpu and ram usage

Так что Android я поставил на паузу. iOS-версия вышла, а Android ждал. Потом я включил его обратно («начинаем Android-майлстоун, простой порт») с одним пожеланием: «если есть способ отрендерить UI без симулятора, было бы здорово».

Способ есть.

Тесты и так уже идут на Android. Ну, почти

Собственным Android-тестам Skip устройство не нужно. По умолчанию они идут под Robolectric: он запускает настоящие классы фреймворка Android на десктопной JVM и подменяет собой железо. Когда Android вернулся, все 721 тест прошли в обеих колонках, Swift и Kotlin, вплоть до модели приложения, которая играет целый вечер партий. Понадобились две правки в тестах и ни одной в приложении.

Значит, транспилированное приложение уже работает под Robolectric. В режиме нативной графики Robolectric рисует той же Skia, что и Android, прямо на хосте, а Roborazzi сохраняет экран в PNG. Вместе это один JUnit-тест в модуле Android-приложения, который:

  • запускает настоящую MainActivity приложения, тот же Main.kt, что и на телефоне, с Compose и SkipUI под капотом;
  • на ненастоящем Pixel 7 (412 × 915 dp при 420 dpi, то есть плотность 2,625, тёмная тема);
  • открывает экран, даёт приложению немного поработать и сохраняет картинку.

Ни эмулятора, ни adb, ни APK для установки. Один запуск Gradle.

Как это работает

В приложении уже было 114 фикстур: именованных состояний вроде голосования посреди подсчёта, попытки Mr. White или цепочки выбываний, которые отладочная сборка открывает сразу при запуске. На iOS скрипт запускает симулятор с HALFWORD_FIXTURE=round-voting и делает скриншот. Для Android тот же скрипт кладёт файл запуска в папку files/ приложения через adb shell run-as, это для эмулятора.

Рендер делает то же самое, только без adb. Сокращённая версия теста 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()
        }
    }
}

Помогают два маленьких кусочка кода в самом приложении. Когда фикстура открылась, приложение пишет файл ready, которого скрипт скриншотов и так ждал. А HalfwordRootView.forgetLaunch() забывает запуск, который запомнил процесс: все 456 картинок живут в одной JVM. Обе лазейки работают только в отладочной сборке: другой тест на Robolectric запускает настоящую activity как релизную сборку, подложив файл запуска, и проверяет, что тот проигнорирован.

Часами управляю я. Главный лупер Robolectric двигается, только когда его двигает тест. Так что тест двигает его по 16 мс, по одному кадру, пока фикстура не появится на экране, а потом ещё на 1,5 секунды времени приложения: столько же скрипт скриншотов даёт экрану устояться. Спрайты, таймер хода («0:29») и раскрытие на экране выбывания оказываются ровно там, где были бы через 1,5 с, и никто эти полторы секунды не ждёт.

Так, по крайней мере, было задумано. Две вещи пришлось делать руками: проход раскладки, который телефон делает при отрисовке каждого кадра, и часы, которые при каждом запуске дают одну и ту же картинку.

Телефон рисует каждый кадр. Robolectric — нет

На первых картинках были верхняя панель и кнопки, а между ними ничего. Все прокручиваемые списки были пустыми: строки главного экрана, голосование, итоги.

Узел, который только попросил перерисовку, Compose измеряет и размещает в начале отрисовки окна (dispatchDraw). Телефон рисует каждый изменившийся кадр. Robolectric между картинками не рисует вообще, поэтому содержимое каждого GeometryReader компоновалось, но так и не размещалось, и каждый ScreenScroll (прокручиваемая рамка экрана в приложении) выходил пустым. Решение — сделать первый шаг телефона руками, причём в каждом окне, включая алерты:

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>()
}

Одна и та же картинка при каждом запуске

Потом картинки перестали совпадать сами с собой. От запуска к запуску в одной-двух из 114 английских картинок спрайт съезжал на кадр, а когда все ядра были заняты, то в трёх–семи. .task у вью стартует в фоновом потоке и перепрыгивает в главный, а всё, что он туда отправляет, получает метку времени по часам на тот момент, когда поток туда добрался. idleFor у Robolectric прогонял сразу целый отрезок задач, так что попадёт ли тик спрайта до кадра или после, зависело от того, как быстро бегут потоки.

Теперь главный лупер выполняет по одной задаче за раз, часы двигаются только между задачами, а между двумя задачами тест ждёт, пока планировщик kotlinx.coroutines не затихнет: в очереди пусто, ни одно разрешение на CPU не занято, блокирующих задач нет. Это внутренний API, и читается он через рефлексию. Одних состояний рабочих потоков не хватало: разбуженный воркер выглядит припаркованным, пока ОС не даст ему процессор.

/// 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))
}

И ещё одно: первая activity за прогон рисуется и выбрасывается. Первый рендер в процессе загружает классы и запускает потоки корутин, и делает это достаточно медленно, чтобы спрайт опоздал на кадр.

После этого три прогона всех 114 английских картинок совпали байт в байт. А ещё стало быстрее, раз фиксированных пауз не осталось: все 456 картинок теперь рисуются за 2 мин 19 с вместо 3 мин 11 с.

Картинка, за которую рендер не может поручиться, валит прогон, а рядом ложится <name>.failed с причиной: её файл ready называет другую фикстуру (протухший запуск) или какое-то ожидание сдалось («unsettled»). А ещё скрипт перед запуском Gradle сбрасывает четыре ключа запуска (HALFWORD_FIXTURE, HALFWORD_LANG, …), и тест отказывается рисовать, если какой-то из них всё ещё задан. Приложение ставит их выше файла запуска, так что экспортированный HALFWORD_LANG=de иначе нарисовал бы каждую картинку на немецком под английским именем. Каждую из этих защит проверили, нарочно её сломав.

Часть про Gradle

Юнит-тестам модуля приложения понадобилось то, что сгенерированные Skip сборки модулей дают своим тестам, плюс несколько флагов:

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"
}
  • Десктопный jar JNA. skip-sql ходит в SQLite через JNA, и с одним только Android AAR он падал с «Could not initialize class com.sun.jna.Native».
  • ASM 9.10. Встроенный в Robolectric ASM не умеет читать классы JDK 26: «Unsupported class file major version 70».
  • isIncludeAndroidResources, иначе никакого Micro 5: пиксельный шрифт — это ресурс приложения, и без него SkipUI молча откатывается на стандартный sans serif.
  • roborazzi.test.record: Roborazzi без своего Gradle-плагина, только запись. Сравнивать не с чем, картинки смотрят глазами.
  • Цикл halfword.shots.*. Свойство -P остаётся у Gradle, а тестовая JVM видит только то, что сборка передаст ей как системное свойство. Без цикла список фикстур из скрипта до теста не доходит, и тест сам себя пропускает.
  • -Pkotlin.incremental=false. Инкрементальная компиляция included build от Skip падала с 30 ошибками вроде «Cannot access 'class CascadeState': it is internal in file», причём воспроизводимо, после правки одной строчки в doc-комментарии. Причина неизвестна. И это обязательно флаг -P: gradle.properties до included build не доходит.
  • -Pkotlin.compiler.execution.strategy=in-process: после сборки не остаётся демона Kotlin, а демон Gradle скрипт потом останавливает сам. На Mac с 16 ГБ лишняя JVM — это враг.
  • -Pandroid.onlyEnableUnitTestForTheTestedBuildType=false, но это не для рендера, а для теста релизной сборки, о котором выше: AGP 9.2 делает юнит-тесты только для тестируемого типа сборки, так что без этого флага :app:testReleaseUnitTest просто не существует.

Флаги рендера завёрнуты в shell-скрипт android-shots.sh, который перед запуском Gradle ждёт (до 10 минут), пока не освободится хотя бы 25% памяти. Моё правило со времён той паники: не больше двух тяжёлых сборок одновременно, а Gradle только в одиночку.

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

Что оно поймало

Первый полный прогон нарисовал все 456 картинок без единого сбоя: каждый экран игры на английском, немецком, испанском и русском, с чёткой пиксельной графикой из целых пикселей и Micro 5 прямо из res/font. А потом картинки начали стучать на код.

Настоящий баг, который картинки показать не могли

В Swift методы SwiftUI-вью работают на главном акторе, так что цикл спрайта private func run() async крутится в главном потоке. skipstone, транспилятор Skip, оборачивает каждую async-функцию, не привязанную к актору, в Async.run из SkipLib, а это withContext(Dispatchers.Default), то есть фоновый поток. На Android там крутились шесть циклов: спрайты, оба раскрытия на экранах выбывания, таймер хода, удержание карточки и дрожь Половинки. Они писали в @State не из главного потока, а тактильный импульс таймера мог устроить гонку с таким же импульсом из главного потока.

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

Теперь честно: гонку не покажет ни одна картинка, а рендер к тому же сериализует именно эту конкурентность. Зато картинка может показать время. Первая версия рендера ставила kotlinx.coroutines.main.delay=true, то есть сажала каждый корутинный delay на часы Robolectric, и это начисто замазывало проблему, пока ревью рендера не нашло причину. Без этого свойства delay вне главного потока ждёт по настоящим часам, а картинку снимают по часам Robolectric: стоило снова убрать атрибут, и оба экрана выбывания застревали на одинокой янтарной чёрточке Половинки вместо результата.

Поэтому теперь скрипт после отрисовки грепает транспилированный Kotlin модуля приложения и выходит с кодом 1, если там остался хоть один Async.run:

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

Алерты на языке телефона

Язык интерфейса в Halfword — отдельная настройка, не зависящая от языка телефона. На Android её игнорировал каждый алерт.

До: игра на немецком, алерт на английском, на сером фоне Material
До: игра на немецком, алерт на английском, на сером фоне Material
После: алерт говорит по-немецки, на собственном тёмном фоне приложения
После: алерт говорит по-немецки, на собственном тёмном фоне приложения

Диалог Compose — это отдельное окно, и его вью берёт LocalConfiguration заново у телефона, так что локаль, которую задаёт корень приложения, туда не доходит, а SkipUI ищет строки алерта именно там. Текст, отформатированный до того, как попасть в алерт, выходит правильным, поэтому каждое слово во всех 12 алертах теперь — готовая строка на языке интерфейса, Text(verbatim: model.string(…)). С меню то же самое, они ведь тоже всплывающие окна (это вывод, а не наблюдение: ни одна фикстура меню не открывает). А проверка строк теперь не пропускает Text из каталога внутри алерта или меню, так что ключ каталога, вписанный в алерт или меню, баг не вернёт (хелпер, который возвращает Text, всё ещё мог бы, и за этим следят руками).

«Без выбывания»

На русском экране «Правила» в строке «При ничьей» у варианта «Без выбывания» обрезало оба конца.

Android, до: каждому сегменту одинаковая доля, и у «Без выбывания» обрезаны оба конца
Android, до: каждому сегменту одинаковая доля, и у «Без выбывания» обрезаны оба конца
Android, после: длинная подпись сохраняет свою ширину
Android, после: длинная подпись сохраняет свою ширину
iPhone, для сравнения
iPhone, для сравнения

SwiftUI даёт сегменту как минимум ширину его подписи. HStack в SkipUI даёт каждому дочернему элементу одинаковую долю, а его ViewThatFits проверяет только сумму ширин подписей, так что на бумаге строка помещалась, а самую широкую подпись обрезало. Английское «No elimination» тоже теряло 6 пунктов, просто не так заметно. Теперь SegmentedChoice измеряет каждую подпись, и сегмент, чья подпись шире его доли, сохраняет ширину подписи. Для этого нужен .frame(width:), потому что SkipUI ограничивает долей даже обычный текст, и новый id в ForEach, потому что контейнеры SkipUI помнят, что их содержимое когда-то заполняло строку.

Край, который так и не появился

Когда список продолжается под кнопками, край отмечает тонкая линия, чтобы кнопки не выглядели концом списка. На Android она так ни разу и не появилась.

До: итоги продолжаются под кнопками, и ничто об этом не говорит
До: итоги продолжаются под кнопками, и ничто об этом не говорит
После: край отмечен тонкой линией
После: край отмечен тонкой линией

SkipUI строит onGeometryChange на boundsInRoot() из Compose, а тот обрезает границы вью по каждому предку. Внутри scroll view содержимое будто бы никогда не уходит ниже края. Теперь маркер высотой в 1 пункт стоит на 12 пунктов выше конца содержимого и сообщает, где он, или что его обрезало целиком, а край рисуется, пока маркер находится на уровне нижней границы или ниже.

И ещё горстка

  • Цвета обоев. На Android 12 и новее SkipUI строит цветовую схему Material из обоев телефона. На JVM это означало стандартную палитру Android 16: тёмно-синий ползунок на янтарной дорожке переключателей и алерты на сером фоне Material (алерт «до» выше). Теперь Android-оболочка задаёт схему из палитры приложения, и каждая пара «текст на цвете» проверена на контраст.
  • Крошечные стрелки. SkipUI рисует SF Symbol как иконку Material в рамке размером с кегль шрифта, и стрелки в голосовании выходили примерно 4 × 8 dp. Теперь это нарисованные штрихи, на обеих платформах.
  • Цепочка выбываний. Когда Влюблённые выбывали вместе, две их карточки висели сами по себе, а строка, объясняющая почему, оказывалась примерно на 200 dp ниже. fixedSize в SkipUI измеряет ряд, но оставляет ему свободу расти, и карточки его заполняли. Теперь каждая карточка измеряет себя сама, а ряд берёт высоту самой высокой.
  • Верхняя строка. «Раунд 2 · Переигровка 1 из 2 · Ход 1 из 2» ломалась посреди части на немецком, испанском и русском («Ход 1 / из 2»). А первое исправление стало ломать часть уже между буквами при самом крупном шрифте Android, и это поймал флаг рендера --font-scale.
До: SF Symbols в виде иконок Material, примерно 4 × 8 dp
До: SF Symbols в виде иконок Material, примерно 4 × 8 dp
После: нарисованные штрихи, одинаковые на обеих платформах
После: нарисованные штрихи, одинаковые на обеих платформах
До: карточки Влюблённых висят отдельно, а их строка примерно на 200 dp ниже
До: карточки Влюблённых висят отдельно, а их строка примерно на 200 dp ниже
После: карточки и строка вместе, как на iPhone
После: карточки и строка вместе, как на iPhone

После алертов четыре ревьюера внимательно прошлись по всем 456 картинкам и нашли ещё девять дефектов. Восемь исправлены, и причин у них нашлось шесть. Насколько можно судить по исходникам SkipUI, каждый из них — это SkipUI и Compose, которые делают ровно то же, что делали бы на телефоне, а причуды Robolectric касались только самой обвязки. После исправлений изменились 193 из 456 картинок, каждая там, где её и должно было изменить исправление, или на кадр спрайта.

Кое-что ждёт меня. Новые стрелки меняют и iOS, так что на них мне нужно посмотреть самому, на iPhone. А поле, куда Mr. White вписывает свою догадку (и поле имени на экране «Игроки»), на Android сдвинуто на 16 dp внутрь, потому что текстовое поле SkipUI сохраняет собственный отступ Material внутри отступа приложения. Для исправления нужно либо платформенное условие в общем коде, а на это нужно моё разрешение, либо настройка на всю оболочку, которая заодно отнимет отступ у поля в алерте переименования. Так что это решать мне.

Цифры

  • 456 картинок (114 фикстур × 4 языка) за 2 мин 19 с, из них около 130 с уходит на саму отрисовку: примерно 0,3 с на картинку. Только английский — около 50 с.
  • Байт в байт, прогон за прогоном, на спокойном Mac: три прогона всех 114 английских картинок совпали. Когда восемь нагрузчиков CPU занимали все восемь ядер, в 1 из 114 спрайт всё равно съехал на кадр (замерено до исправления с @MainActor, под нагрузкой с тех пор не перепроверялось).
  • 1081 × 2401 пикселей на картинку: Pixel 7 в w412dp-h915dp-…-night-420dpi. Или маленький телефон (360 × 640 dp при xhdpi, 720 × 1280 пикселей), светлая тема, любой масштаб шрифта.
  • Одна сборка Gradle, тестовая JVM примерно на 1,9 ГБ, демон потом останавливается.
  • Стек: Robolectric 4.16.1, Roborazzi 1.76, AGP 9.2, Kotlin 2.3, Compose BOM 2026.05.01, JDK 26.
  • Эмуляторов, запущенных ради всего этого: ноль.

Чего оно не покажет

Это рендеры Robolectric на Mac, а не телефон, и вот что остаётся за кадром:

  • Текст рисуется шрифтами из Android-образа Robolectric (Roboto, кириллица тоже есть), но растеризует его FreeType на Mac, так что хинтинг может отличаться от телефонного на пиксель. Шрифт Samsung так и не увидишь.
  • Нет системных панелей. Ни строки состояния, ни панели навигации, ни выреза, так что верхняя панель стоит на y = 0, а на телефоне она оказывается под строкой состояния.
  • Тени и размытие могут выйти плоскими или вовсе пропасть. У самого приложения их нет, но у алерта Material есть elevation.
  • Никаких касаний. Ни ripple, ни нажатого состояния, ни клавиатуры, ни TalkBack, и ничего не вибрирует. Рисуется только то, что открывает фикстура: меню, шторки и перетаскивания требуют живого человека.
  • Никакого настоящего устройства. Pixel 7 здесь — это набор квалификаторов, а не Pixel 7. Экран «О Halfword» гордо сообщает «robolectric · Android 16 (API 36)».
  • Чужие внутренности. Детерминизм держится на внутренностях kotlinx.coroutines, прочитанных через рефлексию. Если обновление их сдвинет, рендер откатится на состояния потоков и честно об этом скажет.

Так что с Android всё хорошо, только в руках его ещё никто не держал. Отладочный и релизный APK собираются, каждый тест проходит и как Swift, и как Kotlin, и каждый экран, который открывает фикстура, рендерится. Но ни один APK ещё никуда не установлен. Это следующий шаг, и для него нужен телефон.

С тех пор релизная сборка прошла на эмуляторе целые партии, а Halfword вышел в Google Play.

Утащи себе

Если твоя машина тоже вздрагивает от эмулятора, большая часть рецепта уже есть в коде выше. Единственное, что я только описал словами, — ожидание между задачами главного потока, а довести его до ума было дольше всего, так что вот оно, в сокращении. settle — это то, что вызывает step:

/// 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() спрашивает у планировщика за Dispatchers.Default (и за IO, это тот же пул), занято ли разрешение на CPU, идёт ли блокирующая задача и стоит ли что-то в очереди. Задача считается с момента, когда её отправили на выполнение, ещё до того, как проснётся хоть один воркер, и это ровно то, что упускают состояния потоков. Это внутренний API kotlinx.coroutines 1.11, поэтому он читается через рефлексию, один раз на процесс:

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
    }
}

Если коротко:

  1. Дай отладочной сборке способ открыть любой экран из файла и сообщить, когда он готов.
  2. Напиши один тест на Robolectric с @GraphicsMode(NATIVE), который запускает твою настоящую activity, по разу на экран, и сохраняет картинку через captureScreenRoboImage из Roborazzi.
  3. Двигай часы сам, по одной задаче главного потока за раз, по ходу дела раскладывай каждое окно и жди пул корутин между задачами. Сначала выброси один прогревочный рендер: первая activity в процессе достаточно медленная, чтобы спрайт опоздал на кадр.
  4. Считай проваленной любую картинку, за которую не можешь поручиться.
  5. Смотри на картинки. Почти каждый баг выше нашли, посмотрев на картинку, а не по диффу.

И скриншоты для сторов

Фикстуры отрабатывают своё дважды: из них же делаются скриншоты для обоих сторов, причём делают их скрипты, а не я с телефоном в руках.

App Store. Скрипт собирает Debug-версию приложения (релизная сборка фикстуры игнорирует), загружает один закреплённый симулятор и для каждой из десяти фикстур запускает приложение с HALFWORD_FIXTURE и HALFWORD_LANG, ждёт файла ready, выставляет в строке состояния 9:41 и делает снимок. Потом скрипт на Python обрамляет каждый: Половинка стоит на краю телефона в масштабе 10× и произносит подпись в облачке, как на главном экране. Подпись набрана Micro 5, а там, где у Micro 5 нет букв (русский, украинский), жирным системным шрифтом. Headless Chrome рисует рамки ровно в 1320 × 2868: этот размер App Store Connect сам уменьшает для всех iPhone поменьше. А deliver из fastlane загружает их по одной, потому что App Store Connect давится параллельными загрузками в одну версию.

Google Play. Те же фикстуры, только без симулятора: рендер из этого поста рисует восемь из них на шести языках, а тот же скрипт обрамляет их в 1080 × 1920. Этот размер выбирал не я. Play не принимает скриншот, у которого одна сторона больше другой вдвое с лишним, а 1081 × 2401 у Pixel 7 как раз длиннее, так что рамка 9:16, Половинка в масштабе 8×, а Micro 5 по 12 пикселей на пиксель шрифта. У рендера на JVM нет строки состояния, поэтому рамка оставляет экрану по 24 dp тёмного фона сверху и снизу, там, где у телефона свои панели. Затем supply из fastlane загружает набор, причём из лейнов, которые знают только внутреннее и закрытое тестирование Play: продакшен остаётся кнопкой в Play Console.

Устаревший набор в стор не уедет. Однажды набор скриншотов для App Store сняли с Debug-сборки, оставшейся с тех времён, когда правила ещё не поменялись: старые заголовки и одна картинка с надписью «Fixture failed». Поэтому теперь каждый набор помечен хешем исходников, из которых он нарисован, лейны загрузки отказываются брать набор, чей хеш не совпадает с текущим checkout, а прогон перед стартом удаляет старый набор, так что после неудачного прогона загружать нечего.

Вот и всё

Рендеру эмулятор по-прежнему не нужен. Мой Mac рисует экраны Halfword для Android за то время, пока варится кофе, на четырёх языках, и если ему не мешать, пиксели каждый раз одни и те же. И «Без выбывания» помещается.