[{"data":1,"prerenderedAt":3075},["ShallowReactive",2],{"page:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering":3,"translations:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering":3056,"series:ru:halfword":3065},{"id":4,"title":5,"author":6,"body":7,"date":3042,"description":3043,"draft":3044,"extension":3045,"image":3046,"listed":346,"meta":3047,"navigation":346,"part":3048,"path":3049,"seo":3050,"series":3051,"stem":3052,"tags":3053,"theme":3051,"toc":346,"__hash__":3055},"content_ru\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering.md","Я проверил свои экраны для Android без эмулятора. Все 114, на четырёх языках.","slnt_opp",{"type":8,"value":9,"toc":3021},"minimark",[10,20,30,33,38,41,47,52,57,60,68,73,76,79,83,94,101,123,126,130,149,156,595,602,608,611,616,619,634,927,931,942,949,1139,1142,1149,1168,1172,1175,1499,1577,1584,1637,1641,1648,1652,1675,1716,1734,1739,1839,1843,1846,1850,1868,1872,1875,1878,1901,1905,1908,1911,1922,1926,1959,1962,1965,1968,1972,2015,2019,2022,2060,2063,2066,2070,2079,2297,2311,2934,2937,2963,2967,2970,2986,2996,3004,3010,3014,3017],[11,12,13,14,19],"p",{},"В ",[15,16,18],"a",{"href":17},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword","посте о запуске Halfword"," одна тема осталась на отдельный пост: как экраны для Android проверяли без эмулятора. Вот он.",[11,21,22,23,29],{},"Коротко напомню: Halfword написан один раз на Swift, а ",[15,24,28],{"href":25,"rel":26},"https:\u002F\u002Fskip.dev",[27],"nofollow","Skip"," Lite переводит его в Kotlin для Android. Каждый тест прогоняется дважды: как Swift и как Kotlin, в который он превратился. Так проверяются правила, наборы слов и сохранённые игры, но не то, как выглядят экраны. Для этого приложение нужно запустить на Android: на телефоне или в эмуляторе.",[11,31,32],{},"Я не выбрал ни то, ни другое: экраны Halfword для Android рисует юнит-тест на JVM, прямо на моём Mac. Рассказываю, почему, как и что эти картинки поймали.",[34,35,37],"h2",{"id":36},"эмулятор","Эмулятор",[11,39,40],{},"Сначала выговорюсь. Вот что я говорил про эмулятор Android, дословно:",[42,43,44],"blockquote",{},[11,45,46],{},"Android simulator is shame.",[42,48,49],{},[11,50,51],{},"android simulator is abominable",[42,53,54],{},[11,55,56],{},"Android deserves all hate it gets simply due to their sdks",[11,58,59],{},"Свой характер эмулятор показал в первый же день: эмулятор Android 16 был так загружен, что не смог установить инструментальные тесты, и ответил «Can't find service: package». Так что ни один тест до сих пор не запускался на SQLite самого Android. Kotlin-тесты всё равно гоняет Robolectric на JVM, так что это могло подождать.",[11,61,62,63,67],{},"На следующий день ",[64,65,66],"strong",{},"мой Mac словил kernel panic",". Это M2 с 16 ГБ. Паника случилась по таймауту watchdog, а компрессор памяти упёрся в «100% of segments limit». В тот момент в четырёх worktree одновременно шли четыре сборки, у каждой с одной стороны Swift и Xcode, с другой — Gradle для Kotlin, а сверху ещё работал эмулятор Android. Мой диагноз задним числом:",[42,69,70],{},[11,71,72],{},"java was butchering both cpu and ram usage",[11,74,75],{},"Так что Android я поставил на паузу. iOS-версия вышла, а Android ждал. Потом я включил его обратно («начинаем Android-майлстоун, простой порт») с одним пожеланием: «если есть способ отрендерить UI без симулятора, было бы здорово».",[11,77,78],{},"Способ есть.",[34,80,82],{"id":81},"тесты-и-так-уже-идут-на-android-ну-почти","Тесты и так уже идут на Android. Ну, почти",[11,84,85,86,89,90,93],{},"Собственным Android-тестам Skip устройство не нужно. По умолчанию они идут под ",[64,87,88],{},"Robolectric",": он запускает настоящие классы фреймворка Android на десктопной JVM и подменяет собой железо. Когда Android вернулся, все ",[64,91,92],{},"721 тест прошли в обеих колонках",", Swift и Kotlin, вплоть до модели приложения, которая играет целый вечер партий. Понадобились две правки в тестах и ни одной в приложении.",[11,95,96,97,100],{},"Значит, транспилированное приложение уже работает под Robolectric. В режиме нативной графики Robolectric рисует той же Skia, что и Android, прямо на хосте, а ",[64,98,99],{},"Roborazzi"," сохраняет экран в PNG. Вместе это один JUnit-тест в модуле Android-приложения, который:",[102,103,104,117,120],"ul",{},[105,106,107,108,112,113,116],"li",{},"запускает настоящую ",[109,110,111],"code",{},"MainActivity"," приложения, тот же ",[109,114,115],{},"Main.kt",", что и на телефоне, с Compose и SkipUI под капотом;",[105,118,119],{},"на ненастоящем Pixel 7 (412 × 915 dp при 420 dpi, то есть плотность 2,625, тёмная тема);",[105,121,122],{},"открывает экран, даёт приложению немного поработать и сохраняет картинку.",[11,124,125],{},"Ни эмулятора, ни adb, ни APK для установки. Один запуск Gradle.",[34,127,129],{"id":128},"как-это-работает","Как это работает",[11,131,132,133,136,137,140,141,144,145,148],{},"В приложении уже было ",[64,134,135],{},"114 фикстур",": именованных состояний вроде голосования посреди подсчёта, попытки Mr. White или цепочки выбываний, которые отладочная сборка открывает сразу при запуске. На iOS скрипт запускает симулятор с ",[109,138,139],{},"HALFWORD_FIXTURE=round-voting"," и делает скриншот. Для Android тот же скрипт кладёт файл запуска в папку ",[109,142,143],{},"files\u002F"," приложения через ",[109,146,147],{},"adb shell run-as",", это для эмулятора.",[11,150,151,152,155],{},"Рендер делает то же самое, только без adb. Сокращённая версия теста ",[109,153,154],{},"FixtureShots",":",[157,158,163],"pre",{"className":159,"code":160,"language":161,"meta":162,"style":162},"language-kotlin shiki shiki-themes monokai","@RunWith(AndroidJUnit4::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(qualifiers = \"w412dp-h915dp-normal-long-notround-port-night-420dpi-keyshidden-nonav\")\nclass FixtureShots {\n    private fun draw(fixture: String, lang: String, file: File): String? {\n        \u002F\u002F Pick the screen the way the emulator script does: a launch file in files\u002F.\n        File(folder, LaunchOptions.fileName).writeText(\n            \"${LaunchOptions.fixtureKey}=$fixture\\n${LaunchOptions.languageKey}=$lang\\n\")\n        HalfwordRootView.forgetLaunch()   \u002F\u002F every picture shares one process\n\n        val looper = shadowOf(Looper.getMainLooper())\n        val activity = Robolectric.buildActivity(MainActivity::class.java).setup()\n        try {\n            var waited = 0L\n            while (!ready.exists() && waited \u003C FIRST_FRAME_LIMIT_MS) {  \u002F\u002F until the fixture is up,\n                step(looper); waited += FRAME_MS                         \u002F\u002F 20 s of app time at most\n            }\n            …\n            var settled = 0L\n            while (settled \u003C settleMs) { step(looper); settled += FRAME_MS }  \u002F\u002F then 1.5 s of app time\n            captureScreenRoboImage(file)                                       \u002F\u002F every window, alerts too\n            …\n        } finally {\n            activity.pause().stop().destroy()\n        }\n    }\n}\n","kotlin","",[109,164,165,185,194,213,224,261,268,283,326,341,348,371,401,409,423,458,476,482,488,500,527,539,544,555,577,583,589],{"__ignoreMap":162},[166,167,170,174,178,182],"span",{"class":168,"line":169},"line",1,[166,171,173],{"class":172},"soZtE","@RunWith",[166,175,177],{"class":176},"sCdxs","(AndroidJUnit4::",[166,179,181],{"class":180},"sHkqI","class",[166,183,184],{"class":176},")\n",[166,186,188,191],{"class":168,"line":187},2,[166,189,190],{"class":172},"@GraphicsMode",[166,192,193],{"class":176},"(GraphicsMode.Mode.NATIVE)\n",[166,195,197,200,203,207,211],{"class":168,"line":196},3,[166,198,199],{"class":172},"@Config",[166,201,202],{"class":176},"(qualifiers ",[166,204,206],{"class":205},"s8I7P","=",[166,208,210],{"class":209},"s_Ekj"," \"w412dp-h915dp-normal-long-notround-port-night-420dpi-keyshidden-nonav\"",[166,212,184],{"class":176},[166,214,216,218,221],{"class":168,"line":215},4,[166,217,181],{"class":205},[166,219,220],{"class":172}," FixtureShots",[166,222,223],{"class":176}," {\n",[166,225,227,230,233,236,239,242,245,247,250,253,256,258],{"class":168,"line":226},5,[166,228,229],{"class":205},"    private",[166,231,232],{"class":205}," fun",[166,234,235],{"class":180}," draw",[166,237,238],{"class":176},"(fixture: ",[166,240,241],{"class":172},"String",[166,243,244],{"class":176},", lang: ",[166,246,241],{"class":172},[166,248,249],{"class":176},", file: ",[166,251,252],{"class":172},"File",[166,254,255],{"class":176},"): ",[166,257,241],{"class":172},[166,259,260],{"class":176},"? {\n",[166,262,264],{"class":168,"line":263},6,[166,265,267],{"class":266},"snpHw","        \u002F\u002F Pick the screen the way the emulator script does: a launch file in files\u002F.\n",[166,269,271,274,277,280],{"class":168,"line":270},7,[166,272,273],{"class":180},"        File",[166,275,276],{"class":176},"(folder, LaunchOptions.fileName).",[166,278,279],{"class":180},"writeText",[166,281,282],{"class":176},"(\n",[166,284,286,289,292,295,298,300,303,307,309,312,314,316,319,321,324],{"class":168,"line":285},8,[166,287,288],{"class":209},"            \"",[166,290,291],{"class":205},"${",[166,293,294],{"class":176},"LaunchOptions.fixtureKey",[166,296,297],{"class":205},"}",[166,299,206],{"class":209},[166,301,302],{"class":176},"$fixture",[166,304,306],{"class":305},"s7s5_","\\n",[166,308,291],{"class":205},[166,310,311],{"class":176},"LaunchOptions.languageKey",[166,313,297],{"class":205},[166,315,206],{"class":209},[166,317,318],{"class":176},"$lang",[166,320,306],{"class":305},[166,322,323],{"class":209},"\"",[166,325,184],{"class":176},[166,327,329,332,335,338],{"class":168,"line":328},9,[166,330,331],{"class":176},"        HalfwordRootView.",[166,333,334],{"class":180},"forgetLaunch",[166,336,337],{"class":176},"()   ",[166,339,340],{"class":266},"\u002F\u002F every picture shares one process\n",[166,342,344],{"class":168,"line":343},10,[166,345,347],{"emptyLinePlaceholder":346},true,"\n",[166,349,351,354,357,359,362,365,368],{"class":168,"line":350},11,[166,352,353],{"class":205},"        val",[166,355,356],{"class":176}," looper ",[166,358,206],{"class":205},[166,360,361],{"class":180}," shadowOf",[166,363,364],{"class":176},"(Looper.",[166,366,367],{"class":180},"getMainLooper",[166,369,370],{"class":176},"())\n",[166,372,374,376,379,381,384,387,390,392,395,398],{"class":168,"line":373},12,[166,375,353],{"class":205},[166,377,378],{"class":176}," activity ",[166,380,206],{"class":205},[166,382,383],{"class":176}," Robolectric.",[166,385,386],{"class":180},"buildActivity",[166,388,389],{"class":176},"(MainActivity::",[166,391,181],{"class":180},[166,393,394],{"class":176},".java).",[166,396,397],{"class":180},"setup",[166,399,400],{"class":176},"()\n",[166,402,404,407],{"class":168,"line":403},13,[166,405,406],{"class":205},"        try",[166,408,223],{"class":176},[166,410,412,415,418,420],{"class":168,"line":411},14,[166,413,414],{"class":205},"            var",[166,416,417],{"class":176}," waited ",[166,419,206],{"class":205},[166,421,422],{"class":305}," 0L\n",[166,424,426,429,432,435,438,441,444,447,449,452,455],{"class":168,"line":425},15,[166,427,428],{"class":205},"            while",[166,430,431],{"class":176}," (",[166,433,434],{"class":205},"!",[166,436,437],{"class":176},"ready.",[166,439,440],{"class":180},"exists",[166,442,443],{"class":176},"() ",[166,445,446],{"class":205},"&&",[166,448,417],{"class":176},[166,450,451],{"class":205},"\u003C",[166,453,454],{"class":176}," FIRST_FRAME_LIMIT_MS) {  ",[166,456,457],{"class":266},"\u002F\u002F until the fixture is up,\n",[166,459,461,464,467,470,473],{"class":168,"line":460},16,[166,462,463],{"class":180},"                step",[166,465,466],{"class":176},"(looper); waited ",[166,468,469],{"class":205},"+=",[166,471,472],{"class":176}," FRAME_MS                         ",[166,474,475],{"class":266},"\u002F\u002F 20 s of app time at most\n",[166,477,479],{"class":168,"line":478},17,[166,480,481],{"class":176},"            }\n",[166,483,485],{"class":168,"line":484},18,[166,486,487],{"class":176},"            …\n",[166,489,491,493,496,498],{"class":168,"line":490},19,[166,492,414],{"class":205},[166,494,495],{"class":176}," settled ",[166,497,206],{"class":205},[166,499,422],{"class":305},[166,501,503,505,508,510,513,516,519,521,524],{"class":168,"line":502},20,[166,504,428],{"class":205},[166,506,507],{"class":176}," (settled ",[166,509,451],{"class":205},[166,511,512],{"class":176}," settleMs) { ",[166,514,515],{"class":180},"step",[166,517,518],{"class":176},"(looper); settled ",[166,520,469],{"class":205},[166,522,523],{"class":176}," FRAME_MS }  ",[166,525,526],{"class":266},"\u002F\u002F then 1.5 s of app time\n",[166,528,530,533,536],{"class":168,"line":529},21,[166,531,532],{"class":180},"            captureScreenRoboImage",[166,534,535],{"class":176},"(file)                                       ",[166,537,538],{"class":266},"\u002F\u002F every window, alerts too\n",[166,540,542],{"class":168,"line":541},22,[166,543,487],{"class":176},[166,545,547,550,553],{"class":168,"line":546},23,[166,548,549],{"class":176},"        } ",[166,551,552],{"class":205},"finally",[166,554,223],{"class":176},[166,556,558,561,564,567,570,572,575],{"class":168,"line":557},24,[166,559,560],{"class":176},"            activity.",[166,562,563],{"class":180},"pause",[166,565,566],{"class":176},"().",[166,568,569],{"class":180},"stop",[166,571,566],{"class":176},[166,573,574],{"class":180},"destroy",[166,576,400],{"class":176},[166,578,580],{"class":168,"line":579},25,[166,581,582],{"class":176},"        }\n",[166,584,586],{"class":168,"line":585},26,[166,587,588],{"class":176},"    }\n",[166,590,592],{"class":168,"line":591},27,[166,593,594],{"class":176},"}\n",[11,596,597,598,601],{},"Помогают два маленьких кусочка кода в самом приложении. Когда фикстура открылась, приложение пишет файл ready, которого скрипт скриншотов и так ждал. А ",[109,599,600],{},"HalfwordRootView.forgetLaunch()"," забывает запуск, который запомнил процесс: все 456 картинок живут в одной JVM. Обе лазейки работают только в отладочной сборке: другой тест на Robolectric запускает настоящую activity как релизную сборку, подложив файл запуска, и проверяет, что тот проигнорирован.",[11,603,604,607],{},[64,605,606],{},"Часами управляю я."," Главный лупер Robolectric двигается, только когда его двигает тест. Так что тест двигает его по 16 мс, по одному кадру, пока фикстура не появится на экране, а потом ещё на 1,5 секунды времени приложения: столько же скрипт скриншотов даёт экрану устояться. Спрайты, таймер хода («0:29») и раскрытие на экране выбывания оказываются ровно там, где были бы через 1,5 с, и никто эти полторы секунды не ждёт.",[11,609,610],{},"Так, по крайней мере, было задумано. Две вещи пришлось делать руками: проход раскладки, который телефон делает при отрисовке каждого кадра, и часы, которые при каждом запуске дают одну и ту же картинку.",[612,613,615],"h3",{"id":614},"телефон-рисует-каждый-кадр-robolectric-нет","Телефон рисует каждый кадр. Robolectric — нет",[11,617,618],{},"На первых картинках были верхняя панель и кнопки, а между ними ничего. Все прокручиваемые списки были пустыми: строки главного экрана, голосование, итоги.",[11,620,621,622,625,626,629,630,633],{},"Узел, который только попросил перерисовку, Compose измеряет и размещает в начале отрисовки окна (",[109,623,624],{},"dispatchDraw","). Телефон рисует каждый изменившийся кадр. Robolectric между картинками не рисует вообще, поэтому содержимое каждого ",[109,627,628],{},"GeometryReader"," компоновалось, но так и не размещалось, и каждый ",[109,631,632],{},"ScreenScroll"," (прокручиваемая рамка экрана в приложении) выходил пустым. Решение — сделать первый шаг телефона руками, причём в каждом окне, включая алерты:",[157,635,637],{"className":159,"code":636,"language":161,"meta":162,"style":162},"private fun layOut() {\n    for (root in windowRoots()) visit(root)\n}\n\nprivate fun visit(view: View) {\n    if (view is RootForTest) view.measureAndLayoutForTest()\n    if (view is ViewGroup) for (i in 0 until view.childCount) visit(view.getChildAt(i))\n}\n\n\u002F\u002F\u002F Every window's root view: WindowManagerGlobal's list, by reflection (it's hidden API).\nprivate fun windowRoots(): List\u003CView> {\n    val global = Class.forName(\"android.view.WindowManagerGlobal\")\n    val instance = global.getMethod(\"getInstance\").invoke(null)\n    val field = global.getDeclaredField(\"mViews\").apply { isAccessible = true }\n    return (field.get(instance) as List\u003C*>).filterIsInstance\u003CView>()\n}\n",[109,638,639,652,675,679,683,701,720,756,760,764,769,790,814,847,883,923],{"__ignoreMap":162},[166,640,641,644,646,649],{"class":168,"line":169},[166,642,643],{"class":205},"private",[166,645,232],{"class":205},[166,647,648],{"class":180}," layOut",[166,650,651],{"class":176},"() {\n",[166,653,654,657,660,663,666,669,672],{"class":168,"line":187},[166,655,656],{"class":205},"    for",[166,658,659],{"class":176}," (root ",[166,661,662],{"class":205},"in",[166,664,665],{"class":180}," windowRoots",[166,667,668],{"class":176},"()) ",[166,670,671],{"class":180},"visit",[166,673,674],{"class":176},"(root)\n",[166,676,677],{"class":168,"line":196},[166,678,594],{"class":176},[166,680,681],{"class":168,"line":215},[166,682,347],{"emptyLinePlaceholder":346},[166,684,685,687,689,692,695,698],{"class":168,"line":226},[166,686,643],{"class":205},[166,688,232],{"class":205},[166,690,691],{"class":180}," visit",[166,693,694],{"class":176},"(view: ",[166,696,697],{"class":172},"View",[166,699,700],{"class":176},") {\n",[166,702,703,706,709,712,715,718],{"class":168,"line":263},[166,704,705],{"class":205},"    if",[166,707,708],{"class":176}," (view ",[166,710,711],{"class":205},"is",[166,713,714],{"class":176}," RootForTest) view.",[166,716,717],{"class":180},"measureAndLayoutForTest",[166,719,400],{"class":176},[166,721,722,724,726,728,731,734,737,739,742,745,747,750,753],{"class":168,"line":270},[166,723,705],{"class":205},[166,725,708],{"class":176},[166,727,711],{"class":205},[166,729,730],{"class":176}," ViewGroup) ",[166,732,733],{"class":205},"for",[166,735,736],{"class":176}," (i ",[166,738,662],{"class":205},[166,740,741],{"class":305}," 0",[166,743,744],{"class":176}," until view.childCount) ",[166,746,671],{"class":180},[166,748,749],{"class":176},"(view.",[166,751,752],{"class":180},"getChildAt",[166,754,755],{"class":176},"(i))\n",[166,757,758],{"class":168,"line":285},[166,759,594],{"class":176},[166,761,762],{"class":168,"line":328},[166,763,347],{"emptyLinePlaceholder":346},[166,765,766],{"class":168,"line":343},[166,767,768],{"class":266},"\u002F\u002F\u002F Every window's root view: WindowManagerGlobal's list, by reflection (it's hidden API).\n",[166,770,771,773,775,777,780,783,785,787],{"class":168,"line":350},[166,772,643],{"class":205},[166,774,232],{"class":205},[166,776,665],{"class":180},[166,778,779],{"class":176},"(): ",[166,781,782],{"class":172},"List",[166,784,451],{"class":176},[166,786,697],{"class":172},[166,788,789],{"class":176},"> {\n",[166,791,792,795,798,800,803,806,809,812],{"class":168,"line":373},[166,793,794],{"class":205},"    val",[166,796,797],{"class":176}," global ",[166,799,206],{"class":205},[166,801,802],{"class":176}," Class.",[166,804,805],{"class":180},"forName",[166,807,808],{"class":176},"(",[166,810,811],{"class":209},"\"android.view.WindowManagerGlobal\"",[166,813,184],{"class":176},[166,815,816,818,821,823,826,829,831,834,837,840,842,845],{"class":168,"line":403},[166,817,794],{"class":205},[166,819,820],{"class":176}," instance ",[166,822,206],{"class":205},[166,824,825],{"class":176}," global.",[166,827,828],{"class":180},"getMethod",[166,830,808],{"class":176},[166,832,833],{"class":209},"\"getInstance\"",[166,835,836],{"class":176},").",[166,838,839],{"class":180},"invoke",[166,841,808],{"class":176},[166,843,844],{"class":305},"null",[166,846,184],{"class":176},[166,848,849,851,854,857,859,862,864,867,869,872,875,877,880],{"class":168,"line":411},[166,850,794],{"class":205},[166,852,853],{"class":205}," field",[166,855,856],{"class":205}," =",[166,858,825],{"class":176},[166,860,861],{"class":180},"getDeclaredField",[166,863,808],{"class":176},[166,865,866],{"class":209},"\"mViews\"",[166,868,836],{"class":176},[166,870,871],{"class":180},"apply",[166,873,874],{"class":176}," { isAccessible ",[166,876,206],{"class":205},[166,878,879],{"class":305}," true",[166,881,882],{"class":176}," }\n",[166,884,885,888,890,893,896,899,902,905,908,911,913,916,918,920],{"class":168,"line":425},[166,886,887],{"class":205},"    return",[166,889,431],{"class":176},[166,891,892],{"class":205},"field",[166,894,895],{"class":176},".",[166,897,898],{"class":180},"get",[166,900,901],{"class":176},"(instance) ",[166,903,904],{"class":205},"as",[166,906,907],{"class":176}," List",[166,909,910],{"class":205},"\u003C*>",[166,912,836],{"class":176},[166,914,915],{"class":180},"filterIsInstance",[166,917,451],{"class":176},[166,919,697],{"class":172},[166,921,922],{"class":176},">()\n",[166,924,925],{"class":168,"line":460},[166,926,594],{"class":176},[612,928,930],{"id":929},"одна-и-та-же-картинка-при-каждом-запуске","Одна и та же картинка при каждом запуске",[11,932,933,934,937,938,941],{},"Потом картинки перестали совпадать сами с собой. От запуска к запуску в одной-двух из 114 английских картинок спрайт съезжал на кадр, а когда все ядра были заняты, то в трёх–семи. ",[109,935,936],{},".task"," у вью стартует в фоновом потоке и перепрыгивает в главный, а всё, что он туда отправляет, получает метку времени по часам на тот момент, когда поток туда добрался. ",[109,939,940],{},"idleFor"," у Robolectric прогонял сразу целый отрезок задач, так что попадёт ли тик спрайта до кадра или после, зависело от того, как быстро бегут потоки.",[11,943,944,945,948],{},"Теперь главный лупер выполняет ",[64,946,947],{},"по одной задаче за раз",", часы двигаются только между задачами, а между двумя задачами тест ждёт, пока планировщик kotlinx.coroutines не затихнет: в очереди пусто, ни одно разрешение на CPU не занято, блокирующих задач нет. Это внутренний API, и читается он через рефлексию. Одних состояний рабочих потоков не хватало: разбуженный воркер выглядит припаркованным, пока ОС не даст ему процессор.",[157,950,952],{"className":159,"code":951,"language":161,"meta":162,"style":162},"\u002F\u002F\u002F One frame (16 ms) of app time, one main-thread task at a time.\nprivate fun step(looper: ShadowLooper) {\n    val end = SystemClock.uptimeMillis() + FRAME_MS\n    while (true) {\n        settle(looper)   \u002F\u002F lay out every window, wait until the coroutine pool is quiet\n        val next = looper.nextScheduledTaskTime.toMillis()\n        if (next == 0L || next > end) break\n        looper.runOneTask()\n    }\n    settle(looper)\n    val rest = end - SystemClock.uptimeMillis()\n    if (rest > 0L) ShadowSystemClock.advanceBy(Duration.ofMillis(rest))\n}\n",[109,953,954,959,976,999,1011,1022,1039,1067,1077,1081,1089,1109,1135],{"__ignoreMap":162},[166,955,956],{"class":168,"line":169},[166,957,958],{"class":266},"\u002F\u002F\u002F One frame (16 ms) of app time, one main-thread task at a time.\n",[166,960,961,963,965,968,971,974],{"class":168,"line":187},[166,962,643],{"class":205},[166,964,232],{"class":205},[166,966,967],{"class":180}," step",[166,969,970],{"class":176},"(looper: ",[166,972,973],{"class":172},"ShadowLooper",[166,975,700],{"class":176},[166,977,978,980,983,985,988,991,993,996],{"class":168,"line":196},[166,979,794],{"class":205},[166,981,982],{"class":176}," end ",[166,984,206],{"class":205},[166,986,987],{"class":176}," SystemClock.",[166,989,990],{"class":180},"uptimeMillis",[166,992,443],{"class":176},[166,994,995],{"class":205},"+",[166,997,998],{"class":176}," FRAME_MS\n",[166,1000,1001,1004,1006,1009],{"class":168,"line":215},[166,1002,1003],{"class":205},"    while",[166,1005,431],{"class":176},[166,1007,1008],{"class":305},"true",[166,1010,700],{"class":176},[166,1012,1013,1016,1019],{"class":168,"line":226},[166,1014,1015],{"class":180},"        settle",[166,1017,1018],{"class":176},"(looper)   ",[166,1020,1021],{"class":266},"\u002F\u002F lay out every window, wait until the coroutine pool is quiet\n",[166,1023,1024,1026,1029,1031,1034,1037],{"class":168,"line":263},[166,1025,353],{"class":205},[166,1027,1028],{"class":176}," next ",[166,1030,206],{"class":205},[166,1032,1033],{"class":176}," looper.nextScheduledTaskTime.",[166,1035,1036],{"class":180},"toMillis",[166,1038,400],{"class":176},[166,1040,1041,1044,1047,1050,1053,1056,1058,1061,1064],{"class":168,"line":270},[166,1042,1043],{"class":205},"        if",[166,1045,1046],{"class":176}," (next ",[166,1048,1049],{"class":205},"==",[166,1051,1052],{"class":305}," 0L",[166,1054,1055],{"class":205}," ||",[166,1057,1028],{"class":176},[166,1059,1060],{"class":205},">",[166,1062,1063],{"class":176}," end) ",[166,1065,1066],{"class":205},"break\n",[166,1068,1069,1072,1075],{"class":168,"line":285},[166,1070,1071],{"class":176},"        looper.",[166,1073,1074],{"class":180},"runOneTask",[166,1076,400],{"class":176},[166,1078,1079],{"class":168,"line":328},[166,1080,588],{"class":176},[166,1082,1083,1086],{"class":168,"line":343},[166,1084,1085],{"class":180},"    settle",[166,1087,1088],{"class":176},"(looper)\n",[166,1090,1091,1093,1096,1098,1100,1103,1105,1107],{"class":168,"line":350},[166,1092,794],{"class":205},[166,1094,1095],{"class":176}," rest ",[166,1097,206],{"class":205},[166,1099,982],{"class":176},[166,1101,1102],{"class":205},"-",[166,1104,987],{"class":176},[166,1106,990],{"class":180},[166,1108,400],{"class":176},[166,1110,1111,1113,1116,1118,1120,1123,1126,1129,1132],{"class":168,"line":373},[166,1112,705],{"class":205},[166,1114,1115],{"class":176}," (rest ",[166,1117,1060],{"class":205},[166,1119,1052],{"class":305},[166,1121,1122],{"class":176},") ShadowSystemClock.",[166,1124,1125],{"class":180},"advanceBy",[166,1127,1128],{"class":176},"(Duration.",[166,1130,1131],{"class":180},"ofMillis",[166,1133,1134],{"class":176},"(rest))\n",[166,1136,1137],{"class":168,"line":403},[166,1138,594],{"class":176},[11,1140,1141],{},"И ещё одно: первая activity за прогон рисуется и выбрасывается. Первый рендер в процессе загружает классы и запускает потоки корутин, и делает это достаточно медленно, чтобы спрайт опоздал на кадр.",[11,1143,1144,1145,1148],{},"После этого ",[64,1146,1147],{},"три прогона всех 114 английских картинок совпали байт в байт",". А ещё стало быстрее, раз фиксированных пауз не осталось: все 456 картинок теперь рисуются за 2 мин 19 с вместо 3 мин 11 с.",[11,1150,1151,1152,1155,1156,1159,1160,1163,1164,1167],{},"Картинка, за которую рендер не может поручиться, валит прогон, а рядом ложится ",[109,1153,1154],{},"\u003Cname>.failed"," с причиной: её файл ready называет другую фикстуру (протухший запуск) или какое-то ожидание сдалось («unsettled»). А ещё скрипт перед запуском Gradle сбрасывает четыре ключа запуска (",[109,1157,1158],{},"HALFWORD_FIXTURE",", ",[109,1161,1162],{},"HALFWORD_LANG",", …), и тест отказывается рисовать, если какой-то из них всё ещё задан. Приложение ставит их выше файла запуска, так что экспортированный ",[109,1165,1166],{},"HALFWORD_LANG=de"," иначе нарисовал бы каждую картинку на немецком под английским именем. Каждую из этих защит проверили, нарочно её сломав.",[612,1169,1171],{"id":1170},"часть-про-gradle","Часть про Gradle",[11,1173,1174],{},"Юнит-тестам модуля приложения понадобилось то, что сгенерированные Skip сборки модулей дают своим тестам, плюс несколько флагов:",[157,1176,1178],{"className":159,"code":1177,"language":161,"meta":162,"style":162},"android {\n    testOptions { unitTests { isIncludeAndroidResources = true } }  \u002F\u002F Micro 5 lives in res\u002Ffont\n}\n\ndependencies {   \u002F\u002F condensed from the app module's build.gradle.kts, Skip's catalog aliases spelled out\n    testImplementation(\"org.robolectric:robolectric:4.16.1\")\n    testImplementation(\"androidx.test:core:1.7.0\")       \u002F\u002F ApplicationProvider\n    testImplementation(\"androidx.test.ext:junit:1.3.0\")  \u002F\u002F AndroidJUnit4\n    testImplementation(\"io.github.takahirom.roborazzi:roborazzi:1.76.0\")\n    testImplementation(\"net.java.dev.jna:jna:5.19.1\")    \u002F\u002F skip-sql reaches SQLite through JNA: the desktop jar\n    testImplementation(\"org.ow2.asm:asm:9.10\")           \u002F\u002F Robolectric's own ASM can't read JDK 26 classes\n}\n\ntasks.withType\u003CTest>().configureEach {\n    \u002F\u002F What to draw and where: the script's -Phalfword.shots.* become the test's system properties.\n    listOf(\"fixtures\", \"langs\", \"out\", \"devices\", \"theme\", \"settle\", \"fontScale\").forEach { key ->\n        val name = \"halfword.shots.$key\"\n        project.findProperty(name)?.let { systemProperty(name, it.toString()) }\n    }\n    systemProperty(\"robolectric.graphicsMode\", \"NATIVE\")\n    systemProperty(\"roborazzi.test.record\", \"true\")  \u002F\u002F no Roborazzi plugin, nothing to compare\n    maxHeapSize = \"2g\"\n}\n",[109,1179,1180,1187,1211,1215,1219,1230,1242,1257,1272,1283,1298,1313,1317,1321,1342,1347,1399,1417,1445,1449,1466,1485,1495],{"__ignoreMap":162},[166,1181,1182,1185],{"class":168,"line":169},[166,1183,1184],{"class":180},"android",[166,1186,223],{"class":176},[166,1188,1189,1192,1195,1198,1201,1203,1205,1208],{"class":168,"line":187},[166,1190,1191],{"class":180},"    testOptions",[166,1193,1194],{"class":176}," { ",[166,1196,1197],{"class":180},"unitTests",[166,1199,1200],{"class":176}," { isIncludeAndroidResources ",[166,1202,206],{"class":205},[166,1204,879],{"class":305},[166,1206,1207],{"class":176}," } }  ",[166,1209,1210],{"class":266},"\u002F\u002F Micro 5 lives in res\u002Ffont\n",[166,1212,1213],{"class":168,"line":196},[166,1214,594],{"class":176},[166,1216,1217],{"class":168,"line":215},[166,1218,347],{"emptyLinePlaceholder":346},[166,1220,1221,1224,1227],{"class":168,"line":226},[166,1222,1223],{"class":180},"dependencies",[166,1225,1226],{"class":176}," {   ",[166,1228,1229],{"class":266},"\u002F\u002F condensed from the app module's build.gradle.kts, Skip's catalog aliases spelled out\n",[166,1231,1232,1235,1237,1240],{"class":168,"line":263},[166,1233,1234],{"class":180},"    testImplementation",[166,1236,808],{"class":176},[166,1238,1239],{"class":209},"\"org.robolectric:robolectric:4.16.1\"",[166,1241,184],{"class":176},[166,1243,1244,1246,1248,1251,1254],{"class":168,"line":270},[166,1245,1234],{"class":180},[166,1247,808],{"class":176},[166,1249,1250],{"class":209},"\"androidx.test:core:1.7.0\"",[166,1252,1253],{"class":176},")       ",[166,1255,1256],{"class":266},"\u002F\u002F ApplicationProvider\n",[166,1258,1259,1261,1263,1266,1269],{"class":168,"line":285},[166,1260,1234],{"class":180},[166,1262,808],{"class":176},[166,1264,1265],{"class":209},"\"androidx.test.ext:junit:1.3.0\"",[166,1267,1268],{"class":176},")  ",[166,1270,1271],{"class":266},"\u002F\u002F AndroidJUnit4\n",[166,1273,1274,1276,1278,1281],{"class":168,"line":328},[166,1275,1234],{"class":180},[166,1277,808],{"class":176},[166,1279,1280],{"class":209},"\"io.github.takahirom.roborazzi:roborazzi:1.76.0\"",[166,1282,184],{"class":176},[166,1284,1285,1287,1289,1292,1295],{"class":168,"line":343},[166,1286,1234],{"class":180},[166,1288,808],{"class":176},[166,1290,1291],{"class":209},"\"net.java.dev.jna:jna:5.19.1\"",[166,1293,1294],{"class":176},")    ",[166,1296,1297],{"class":266},"\u002F\u002F skip-sql reaches SQLite through JNA: the desktop jar\n",[166,1299,1300,1302,1304,1307,1310],{"class":168,"line":350},[166,1301,1234],{"class":180},[166,1303,808],{"class":176},[166,1305,1306],{"class":209},"\"org.ow2.asm:asm:9.10\"",[166,1308,1309],{"class":176},")           ",[166,1311,1312],{"class":266},"\u002F\u002F Robolectric's own ASM can't read JDK 26 classes\n",[166,1314,1315],{"class":168,"line":373},[166,1316,594],{"class":176},[166,1318,1319],{"class":168,"line":403},[166,1320,347],{"emptyLinePlaceholder":346},[166,1322,1323,1326,1329,1331,1334,1337,1340],{"class":168,"line":411},[166,1324,1325],{"class":176},"tasks.",[166,1327,1328],{"class":180},"withType",[166,1330,451],{"class":176},[166,1332,1333],{"class":172},"Test",[166,1335,1336],{"class":176},">().",[166,1338,1339],{"class":180},"configureEach",[166,1341,223],{"class":176},[166,1343,1344],{"class":168,"line":425},[166,1345,1346],{"class":266},"    \u002F\u002F What to draw and where: the script's -Phalfword.shots.* become the test's system properties.\n",[166,1348,1349,1352,1354,1357,1359,1362,1364,1367,1369,1372,1374,1377,1379,1382,1384,1387,1389,1392,1395],{"class":168,"line":460},[166,1350,1351],{"class":180},"    listOf",[166,1353,808],{"class":176},[166,1355,1356],{"class":209},"\"fixtures\"",[166,1358,1159],{"class":176},[166,1360,1361],{"class":209},"\"langs\"",[166,1363,1159],{"class":176},[166,1365,1366],{"class":209},"\"out\"",[166,1368,1159],{"class":176},[166,1370,1371],{"class":209},"\"devices\"",[166,1373,1159],{"class":176},[166,1375,1376],{"class":209},"\"theme\"",[166,1378,1159],{"class":176},[166,1380,1381],{"class":209},"\"settle\"",[166,1383,1159],{"class":176},[166,1385,1386],{"class":209},"\"fontScale\"",[166,1388,836],{"class":176},[166,1390,1391],{"class":180},"forEach",[166,1393,1394],{"class":176}," { key ",[166,1396,1398],{"class":1397},"sOx1s","->\n",[166,1400,1401,1403,1406,1408,1411,1414],{"class":168,"line":478},[166,1402,353],{"class":205},[166,1404,1405],{"class":176}," name ",[166,1407,206],{"class":205},[166,1409,1410],{"class":209}," \"halfword.shots.",[166,1412,1413],{"class":176},"$key",[166,1415,1416],{"class":209},"\"\n",[166,1418,1419,1422,1425,1428,1431,1433,1436,1439,1442],{"class":168,"line":484},[166,1420,1421],{"class":176},"        project.",[166,1423,1424],{"class":180},"findProperty",[166,1426,1427],{"class":176},"(name)?.",[166,1429,1430],{"class":180},"let",[166,1432,1194],{"class":176},[166,1434,1435],{"class":180},"systemProperty",[166,1437,1438],{"class":176},"(name, it.",[166,1440,1441],{"class":180},"toString",[166,1443,1444],{"class":176},"()) }\n",[166,1446,1447],{"class":168,"line":490},[166,1448,588],{"class":176},[166,1450,1451,1454,1456,1459,1461,1464],{"class":168,"line":502},[166,1452,1453],{"class":180},"    systemProperty",[166,1455,808],{"class":176},[166,1457,1458],{"class":209},"\"robolectric.graphicsMode\"",[166,1460,1159],{"class":176},[166,1462,1463],{"class":209},"\"NATIVE\"",[166,1465,184],{"class":176},[166,1467,1468,1470,1472,1475,1477,1480,1482],{"class":168,"line":529},[166,1469,1453],{"class":180},[166,1471,808],{"class":176},[166,1473,1474],{"class":209},"\"roborazzi.test.record\"",[166,1476,1159],{"class":176},[166,1478,1479],{"class":209},"\"true\"",[166,1481,1268],{"class":176},[166,1483,1484],{"class":266},"\u002F\u002F no Roborazzi plugin, nothing to compare\n",[166,1486,1487,1490,1492],{"class":168,"line":541},[166,1488,1489],{"class":176},"    maxHeapSize ",[166,1491,206],{"class":205},[166,1493,1494],{"class":209}," \"2g\"\n",[166,1496,1497],{"class":168,"line":546},[166,1498,594],{"class":176},[102,1500,1501,1507,1513,1521,1529,1542,1557,1565],{},[105,1502,1503,1506],{},[64,1504,1505],{},"Десктопный jar JNA."," skip-sql ходит в SQLite через JNA, и с одним только Android AAR он падал с «Could not initialize class com.sun.jna.Native».",[105,1508,1509,1512],{},[64,1510,1511],{},"ASM 9.10."," Встроенный в Robolectric ASM не умеет читать классы JDK 26: «Unsupported class file major version 70».",[105,1514,1515,1520],{},[64,1516,1517],{},[109,1518,1519],{},"isIncludeAndroidResources",", иначе никакого Micro 5: пиксельный шрифт — это ресурс приложения, и без него SkipUI молча откатывается на стандартный sans serif.",[105,1522,1523,1528],{},[64,1524,1525],{},[109,1526,1527],{},"roborazzi.test.record",": Roborazzi без своего Gradle-плагина, только запись. Сравнивать не с чем, картинки смотрят глазами.",[105,1530,1531,1537,1538,1541],{},[64,1532,1533,1534,895],{},"Цикл ",[109,1535,1536],{},"halfword.shots.*"," Свойство ",[109,1539,1540],{},"-P"," остаётся у Gradle, а тестовая JVM видит только то, что сборка передаст ей как системное свойство. Без цикла список фикстур из скрипта до теста не доходит, и тест сам себя пропускает.",[105,1543,1544,1549,1550,1552,1553,1556],{},[64,1545,1546,895],{},[109,1547,1548],{},"-Pkotlin.incremental=false"," Инкрементальная компиляция included build от Skip падала с 30 ошибками вроде «Cannot access 'class CascadeState': it is internal in file», причём воспроизводимо, после правки одной строчки в doc-комментарии. Причина неизвестна. И это обязательно флаг ",[109,1551,1540],{},": ",[109,1554,1555],{},"gradle.properties"," до included build не доходит.",[105,1558,1559,1564],{},[64,1560,1561],{},[109,1562,1563],{},"-Pkotlin.compiler.execution.strategy=in-process",": после сборки не остаётся демона Kotlin, а демон Gradle скрипт потом останавливает сам. На Mac с 16 ГБ лишняя JVM — это враг.",[105,1566,1567,1572,1573,1576],{},[64,1568,1569],{},[109,1570,1571],{},"-Pandroid.onlyEnableUnitTestForTheTestedBuildType=false",", но это не для рендера, а для теста релизной сборки, о котором выше: AGP 9.2 делает юнит-тесты только для тестируемого типа сборки, так что без этого флага ",[109,1574,1575],{},":app:testReleaseUnitTest"," просто не существует.",[11,1578,1579,1580,1583],{},"Флаги рендера завёрнуты в shell-скрипт ",[109,1581,1582],{},"android-shots.sh",", который перед запуском Gradle ждёт (до 10 минут), пока не освободится хотя бы 25% памяти. Моё правило со времён той паники: не больше двух тяжёлых сборок одновременно, а Gradle только в одиночку.",[157,1585,1589],{"className":1586,"code":1587,"language":1588,"meta":162,"style":162},"language-bash shiki shiki-themes monokai","scripts\u002Fandroid-shots.sh --lang en,de,es,ru                     # all 114 fixtures: 456 PNGs\nscripts\u002Fandroid-shots.sh --device small --theme light setup-rules\nscripts\u002Fandroid-shots.sh --font-scale 2.0 round-pk-describing\n","bash",[109,1590,1591,1605,1624],{"__ignoreMap":162},[166,1592,1593,1596,1599,1602],{"class":168,"line":169},[166,1594,1595],{"class":180},"scripts\u002Fandroid-shots.sh",[166,1597,1598],{"class":305}," --lang",[166,1600,1601],{"class":209}," en,de,es,ru",[166,1603,1604],{"class":266},"                     # all 114 fixtures: 456 PNGs\n",[166,1606,1607,1609,1612,1615,1618,1621],{"class":168,"line":187},[166,1608,1595],{"class":180},[166,1610,1611],{"class":305}," --device",[166,1613,1614],{"class":209}," small",[166,1616,1617],{"class":305}," --theme",[166,1619,1620],{"class":209}," light",[166,1622,1623],{"class":209}," setup-rules\n",[166,1625,1626,1628,1631,1634],{"class":168,"line":196},[166,1627,1595],{"class":180},[166,1629,1630],{"class":305}," --font-scale",[166,1632,1633],{"class":305}," 2.0",[166,1635,1636],{"class":209}," round-pk-describing\n",[34,1638,1640],{"id":1639},"что-оно-поймало","Что оно поймало",[11,1642,1643,1644,1647],{},"Первый полный прогон нарисовал все 456 картинок без единого сбоя: каждый экран игры на английском, немецком, испанском и русском, с чёткой пиксельной графикой из целых пикселей и Micro 5 прямо из ",[109,1645,1646],{},"res\u002Ffont",". А потом картинки начали стучать на код.",[612,1649,1651],{"id":1650},"настоящий-баг-который-картинки-показать-не-могли","Настоящий баг, который картинки показать не могли",[11,1653,1654,1655,1658,1659,1662,1663,1666,1667,1670,1671,1674],{},"В Swift методы SwiftUI-вью работают на главном акторе, так что цикл спрайта ",[109,1656,1657],{},"private func run() async"," крутится в главном потоке. skipstone, транспилятор Skip, оборачивает каждую ",[109,1660,1661],{},"async","-функцию, не привязанную к актору, в ",[109,1664,1665],{},"Async.run"," из SkipLib, а это ",[109,1668,1669],{},"withContext(Dispatchers.Default)",", то есть фоновый поток. На Android там крутились шесть циклов: спрайты, оба раскрытия на экранах выбывания, таймер хода, удержание карточки и дрожь Половинки. Они писали в ",[109,1672,1673],{},"@State"," не из главного потока, а тактильный импульс таймера мог устроить гонку с таким же импульсом из главного потока.",[157,1676,1680],{"className":1677,"code":1678,"language":1679,"meta":162,"style":162},"language-swift shiki shiki-themes monokai","\u002F\u002F skipstone honours the attribute: the body becomes MainActor.run on Android.\n@MainActor private func run() async {\n    …\n}\n","swift",[109,1681,1682,1687,1707,1712],{"__ignoreMap":162},[166,1683,1684],{"class":168,"line":169},[166,1685,1686],{"class":266},"\u002F\u002F skipstone honours the attribute: the body becomes MainActor.run on Android.\n",[166,1688,1689,1692,1695,1698,1701,1703,1705],{"class":168,"line":187},[166,1690,1691],{"class":205},"@MainActor",[166,1693,1694],{"class":205}," private",[166,1696,1697],{"class":1397}," func",[166,1699,1700],{"class":180}," run",[166,1702,443],{"class":176},[166,1704,1661],{"class":205},[166,1706,223],{"class":176},[166,1708,1709],{"class":168,"line":196},[166,1710,1711],{"class":205},"    …\n",[166,1713,1714],{"class":168,"line":215},[166,1715,594],{"class":176},[11,1717,1718,1719,1722,1723,1726,1727,1730,1731,1733],{},"Теперь честно: ",[64,1720,1721],{},"гонку не покажет ни одна картинка",", а рендер к тому же сериализует именно эту конкурентность. Зато картинка может показать время. Первая версия рендера ставила ",[109,1724,1725],{},"kotlinx.coroutines.main.delay=true",", то есть сажала каждый корутинный ",[109,1728,1729],{},"delay"," на часы Robolectric, и это начисто замазывало проблему, пока ревью рендера не нашло причину. Без этого свойства ",[109,1732,1729],{}," вне главного потока ждёт по настоящим часам, а картинку снимают по часам Robolectric: стоило снова убрать атрибут, и оба экрана выбывания застревали на одинокой янтарной чёрточке Половинки вместо результата.",[11,1735,1736,1737,155],{},"Поэтому теперь скрипт после отрисовки грепает транспилированный Kotlin модуля приложения и выходит с кодом 1, если там остался хоть один ",[109,1738,1665],{},[157,1740,1742],{"className":1586,"code":1741,"language":1588,"meta":162,"style":162},"if [ ! -d \"$KOTLIN\" ]; then\n  echo \"android-shots: no transpiled Kotlin in $KOTLIN: the main-thread check did not run\" >&2\nelif found=\"$(grep -rn 'Async\\.run' \"$KOTLIN\")\"; then\n  echo \"android-shots: these async funcs run off the main thread on Android (mark them @MainActor):\" >&2\n  …\nfi\n",[109,1743,1744,1771,1788,1820,1829,1834],{"__ignoreMap":162},[166,1745,1746,1749,1752,1754,1757,1760,1763,1765,1768],{"class":168,"line":169},[166,1747,1748],{"class":205},"if",[166,1750,1751],{"class":176}," [ ",[166,1753,434],{"class":205},[166,1755,1756],{"class":205}," -d",[166,1758,1759],{"class":209}," \"",[166,1761,1762],{"class":176},"$KOTLIN",[166,1764,323],{"class":209},[166,1766,1767],{"class":176}," ]; ",[166,1769,1770],{"class":205},"then\n",[166,1772,1773,1777,1780,1782,1785],{"class":168,"line":187},[166,1774,1776],{"class":1775},"sYf5A","  echo",[166,1778,1779],{"class":209}," \"android-shots: no transpiled Kotlin in ",[166,1781,1762],{"class":176},[166,1783,1784],{"class":209},": the main-thread check did not run\"",[166,1786,1787],{"class":205}," >&2\n",[166,1789,1790,1793,1796,1798,1801,1804,1807,1810,1812,1815,1818],{"class":168,"line":196},[166,1791,1792],{"class":205},"elif",[166,1794,1795],{"class":176}," found",[166,1797,206],{"class":205},[166,1799,1800],{"class":209},"\"$(",[166,1802,1803],{"class":180},"grep",[166,1805,1806],{"class":305}," -rn",[166,1808,1809],{"class":209}," 'Async\\.run' \"",[166,1811,1762],{"class":176},[166,1813,1814],{"class":209},"\")\"",[166,1816,1817],{"class":176},"; ",[166,1819,1770],{"class":205},[166,1821,1822,1824,1827],{"class":168,"line":215},[166,1823,1776],{"class":1775},[166,1825,1826],{"class":209}," \"android-shots: these async funcs run off the main thread on Android (mark them @MainActor):\"",[166,1828,1787],{"class":205},[166,1830,1831],{"class":168,"line":226},[166,1832,1833],{"class":180},"  …\n",[166,1835,1836],{"class":168,"line":263},[166,1837,1838],{"class":205},"fi\n",[612,1840,1842],{"id":1841},"алерты-на-языке-телефона","Алерты на языке телефона",[11,1844,1845],{},"Язык интерфейса в Halfword — отдельная настройка, не зависящая от языка телефона. На Android её игнорировал каждый алерт.",[1847,1848],"gallery",{":images":1849},"[{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Falert-before.jpg\",\"alt\":\"До: игра на немецком, алерт на английском, на сером фоне Material\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Falert-after.jpg\",\"alt\":\"После: алерт говорит по-немецки, на собственном тёмном фоне приложения\"}]",[11,1851,1852,1853,1856,1857,1860,1861,1864,1865,1867],{},"Диалог Compose — это отдельное окно, и его вью берёт ",[109,1854,1855],{},"LocalConfiguration"," заново у телефона, так что локаль, которую задаёт корень приложения, туда не доходит, а SkipUI ищет строки алерта именно там. Текст, отформатированный до того, как попасть в алерт, выходит правильным, поэтому каждое слово во всех 12 алертах теперь — готовая строка на языке интерфейса, ",[109,1858,1859],{},"Text(verbatim: model.string(…))",". С меню то же самое, они ведь тоже всплывающие окна (это вывод, а не наблюдение: ни одна фикстура меню не открывает). А проверка строк теперь не пропускает ",[109,1862,1863],{},"Text"," из каталога внутри алерта или меню, так что ключ каталога, вписанный в алерт или меню, баг не вернёт (хелпер, который возвращает ",[109,1866,1863],{},", всё ещё мог бы, и за этим следят руками).",[612,1869,1871],{"id":1870},"без-выбывания","«Без выбывания»",[11,1873,1874],{},"На русском экране «Правила» в строке «При ничьей» у варианта «Без выбывания» обрезало оба конца.",[1847,1876],{":images":1877},"[{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fsegment-ru-before.jpg\",\"alt\":\"Android, до: каждому сегменту одинаковая доля, и у «Без выбывания» обрезаны оба конца\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fsegment-ru-after.jpg\",\"alt\":\"Android, после: длинная подпись сохраняет свою ширину\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fsegment-ru-ios.jpg\",\"alt\":\"iPhone, для сравнения\"}]",[11,1879,1880,1881,1884,1885,1888,1889,1892,1893,1896,1897,1900],{},"SwiftUI даёт сегменту как минимум ширину его подписи. ",[109,1882,1883],{},"HStack"," в SkipUI даёт каждому дочернему элементу одинаковую долю, а его ",[109,1886,1887],{},"ViewThatFits"," проверяет только сумму ширин подписей, так что на бумаге строка помещалась, а самую широкую подпись обрезало. Английское «No elimination» тоже теряло 6 пунктов, просто не так заметно. Теперь ",[109,1890,1891],{},"SegmentedChoice"," измеряет каждую подпись, и сегмент, чья подпись шире его доли, сохраняет ширину подписи. Для этого нужен ",[109,1894,1895],{},".frame(width:)",", потому что SkipUI ограничивает долей даже обычный текст, и новый id в ",[109,1898,1899],{},"ForEach",", потому что контейнеры SkipUI помнят, что их содержимое когда-то заполняло строку.",[612,1902,1904],{"id":1903},"край-который-так-и-не-появился","Край, который так и не появился",[11,1906,1907],{},"Когда список продолжается под кнопками, край отмечает тонкая линия, чтобы кнопки не выглядели концом списка. На Android она так ни разу и не появилась.",[1847,1909],{":images":1910},"[{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fscroll-edge-before.jpg\",\"alt\":\"До: итоги продолжаются под кнопками, и ничто об этом не говорит\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fscroll-edge-after.jpg\",\"alt\":\"После: край отмечен тонкой линией\"}]",[11,1912,1913,1914,1917,1918,1921],{},"SkipUI строит ",[109,1915,1916],{},"onGeometryChange"," на ",[109,1919,1920],{},"boundsInRoot()"," из Compose, а тот обрезает границы вью по каждому предку. Внутри scroll view содержимое будто бы никогда не уходит ниже края. Теперь маркер высотой в 1 пункт стоит на 12 пунктов выше конца содержимого и сообщает, где он, или что его обрезало целиком, а край рисуется, пока маркер находится на уровне нижней границы или ниже.",[612,1923,1925],{"id":1924},"и-ещё-горстка","И ещё горстка",[102,1927,1928,1934,1940,1950],{},[105,1929,1930,1933],{},[64,1931,1932],{},"Цвета обоев."," На Android 12 и новее SkipUI строит цветовую схему Material из обоев телефона. На JVM это означало стандартную палитру Android 16: тёмно-синий ползунок на янтарной дорожке переключателей и алерты на сером фоне Material (алерт «до» выше). Теперь Android-оболочка задаёт схему из палитры приложения, и каждая пара «текст на цвете» проверена на контраст.",[105,1935,1936,1939],{},[64,1937,1938],{},"Крошечные стрелки."," SkipUI рисует SF Symbol как иконку Material в рамке размером с кегль шрифта, и стрелки в голосовании выходили примерно 4 × 8 dp. Теперь это нарисованные штрихи, на обеих платформах.",[105,1941,1942,1945,1946,1949],{},[64,1943,1944],{},"Цепочка выбываний."," Когда Влюблённые выбывали вместе, две их карточки висели сами по себе, а строка, объясняющая почему, оказывалась примерно на 200 dp ниже. ",[109,1947,1948],{},"fixedSize"," в SkipUI измеряет ряд, но оставляет ему свободу расти, и карточки его заполняли. Теперь каждая карточка измеряет себя сама, а ряд берёт высоту самой высокой.",[105,1951,1952,1955,1956,895],{},[64,1953,1954],{},"Верхняя строка."," «Раунд 2 · Переигровка 1 из 2 · Ход 1 из 2» ломалась посреди части на немецком, испанском и русском («Ход 1 \u002F из 2»). А первое исправление стало ломать часть уже между буквами при самом крупном шрифте Android, и это поймал флаг рендера ",[109,1957,1958],{},"--font-scale",[1847,1960],{":images":1961},"[{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fchevrons-before.jpg\",\"alt\":\"До: SF Symbols в виде иконок Material, примерно 4 × 8 dp\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fchevrons-after.jpg\",\"alt\":\"После: нарисованные штрихи, одинаковые на обеих платформах\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fcascade-before.jpg\",\"alt\":\"До: карточки Влюблённых висят отдельно, а их строка примерно на 200 dp ниже\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fcascade-after.jpg\",\"alt\":\"После: карточки и строка вместе, как на iPhone\"}]",[11,1963,1964],{},"После алертов четыре ревьюера внимательно прошлись по всем 456 картинкам и нашли ещё девять дефектов. Восемь исправлены, и причин у них нашлось шесть. Насколько можно судить по исходникам SkipUI, каждый из них — это SkipUI и Compose, которые делают ровно то же, что делали бы на телефоне, а причуды Robolectric касались только самой обвязки. После исправлений изменились 193 из 456 картинок, каждая там, где её и должно было изменить исправление, или на кадр спрайта.",[11,1966,1967],{},"Кое-что ждёт меня. Новые стрелки меняют и iOS, так что на них мне нужно посмотреть самому, на iPhone. А поле, куда Mr. White вписывает свою догадку (и поле имени на экране «Игроки»), на Android сдвинуто на 16 dp внутрь, потому что текстовое поле SkipUI сохраняет собственный отступ Material внутри отступа приложения. Для исправления нужно либо платформенное условие в общем коде, а на это нужно моё разрешение, либо настройка на всю оболочку, которая заодно отнимет отступ у поля в алерте переименования. Так что это решать мне.",[34,1969,1971],{"id":1970},"цифры","Цифры",[102,1973,1974,1984,1993,2003,2009,2012],{},[105,1975,1976,1979,1980,1983],{},[64,1977,1978],{},"456 картинок"," (114 фикстур × 4 языка) за ",[64,1981,1982],{},"2 мин 19 с",", из них около 130 с уходит на саму отрисовку: примерно 0,3 с на картинку. Только английский — около 50 с.",[105,1985,1986,1989,1990,1992],{},[64,1987,1988],{},"Байт в байт, прогон за прогоном, на спокойном Mac",": три прогона всех 114 английских картинок совпали. Когда восемь нагрузчиков CPU занимали все восемь ядер, в 1 из 114 спрайт всё равно съехал на кадр (замерено до исправления с ",[109,1991,1691],{},", под нагрузкой с тех пор не перепроверялось).",[105,1994,1995,1998,1999,2002],{},[64,1996,1997],{},"1081 × 2401 пикселей"," на картинку: Pixel 7 в ",[109,2000,2001],{},"w412dp-h915dp-…-night-420dpi",". Или маленький телефон (360 × 640 dp при xhdpi, 720 × 1280 пикселей), светлая тема, любой масштаб шрифта.",[105,2004,2005,2008],{},[64,2006,2007],{},"Одна сборка Gradle",", тестовая JVM примерно на 1,9 ГБ, демон потом останавливается.",[105,2010,2011],{},"Стек: Robolectric 4.16.1, Roborazzi 1.76, AGP 9.2, Kotlin 2.3, Compose BOM 2026.05.01, JDK 26.",[105,2013,2014],{},"Эмуляторов, запущенных ради всего этого: ноль.",[34,2016,2018],{"id":2017},"чего-оно-не-покажет","Чего оно не покажет",[11,2020,2021],{},"Это рендеры Robolectric на Mac, а не телефон, и вот что остаётся за кадром:",[102,2023,2024,2030,2036,2042,2048,2054],{},[105,2025,2026,2029],{},[64,2027,2028],{},"Текст"," рисуется шрифтами из Android-образа Robolectric (Roboto, кириллица тоже есть), но растеризует его FreeType на Mac, так что хинтинг может отличаться от телефонного на пиксель. Шрифт Samsung так и не увидишь.",[105,2031,2032,2035],{},[64,2033,2034],{},"Нет системных панелей."," Ни строки состояния, ни панели навигации, ни выреза, так что верхняя панель стоит на y = 0, а на телефоне она оказывается под строкой состояния.",[105,2037,2038,2041],{},[64,2039,2040],{},"Тени и размытие"," могут выйти плоскими или вовсе пропасть. У самого приложения их нет, но у алерта Material есть elevation.",[105,2043,2044,2047],{},[64,2045,2046],{},"Никаких касаний."," Ни ripple, ни нажатого состояния, ни клавиатуры, ни TalkBack, и ничего не вибрирует. Рисуется только то, что открывает фикстура: меню, шторки и перетаскивания требуют живого человека.",[105,2049,2050,2053],{},[64,2051,2052],{},"Никакого настоящего устройства."," Pixel 7 здесь — это набор квалификаторов, а не Pixel 7. Экран «О Halfword» гордо сообщает «robolectric · Android 16 (API 36)».",[105,2055,2056,2059],{},[64,2057,2058],{},"Чужие внутренности."," Детерминизм держится на внутренностях kotlinx.coroutines, прочитанных через рефлексию. Если обновление их сдвинет, рендер откатится на состояния потоков и честно об этом скажет.",[11,2061,2062],{},"Так что с Android всё хорошо, только в руках его ещё никто не держал. Отладочный и релизный APK собираются, каждый тест проходит и как Swift, и как Kotlin, и каждый экран, который открывает фикстура, рендерится. Но ни один APK ещё никуда не установлен. Это следующий шаг, и для него нужен телефон.",[11,2064,2065],{},"С тех пор релизная сборка прошла на эмуляторе целые партии, а Halfword вышел в Google Play.",[34,2067,2069],{"id":2068},"утащи-себе","Утащи себе",[11,2071,2072,2073,2076,2077,155],{},"Если твоя машина тоже вздрагивает от эмулятора, большая часть рецепта уже есть в коде выше. Единственное, что я только описал словами, — ожидание между задачами главного потока, а довести его до ума было дольше всего, так что вот оно, в сокращении. ",[109,2074,2075],{},"settle"," — это то, что вызывает ",[109,2078,515],{},[157,2080,2082],{"className":159,"code":2081,"language":161,"meta":162,"style":162},"\u002F\u002F\u002F Runs what is due on the main looper, one task at a time, laying out every window after each\n\u002F\u002F\u002F and waiting for the coroutine pool to finish what the task started, until nothing is due.\nprivate fun settle(looper: ShadowLooper) {\n    var rounds = 0\n    while (rounds \u003C 10_000) {\n        rounds += 1\n        var waits = 0\n        while (coroutinesBusy()) {\n            if (waits == 20_000) { giveUp(\"the coroutine pool still busy after 2 s\"); break }\n            LockSupport.parkNanos(100_000L)\n            waits += 1\n        }\n        layOut()                          \u002F\u002F a layout can compose views, whose tasks start\n        if (coroutinesBusy()) continue\n        if (looper.isIdle) return\n        looper.runOneTask()\n    }\n    giveUp(\"the main looper not idle after 10,000 rounds\")   \u002F\u002F the picture fails as \"unsettled\"\n}\n",[109,2083,2084,2089,2094,2109,2122,2136,2146,2158,2171,2203,2218,2227,2231,2242,2255,2265,2273,2277,2293],{"__ignoreMap":162},[166,2085,2086],{"class":168,"line":169},[166,2087,2088],{"class":266},"\u002F\u002F\u002F Runs what is due on the main looper, one task at a time, laying out every window after each\n",[166,2090,2091],{"class":168,"line":187},[166,2092,2093],{"class":266},"\u002F\u002F\u002F and waiting for the coroutine pool to finish what the task started, until nothing is due.\n",[166,2095,2096,2098,2100,2103,2105,2107],{"class":168,"line":196},[166,2097,643],{"class":205},[166,2099,232],{"class":205},[166,2101,2102],{"class":180}," settle",[166,2104,970],{"class":176},[166,2106,973],{"class":172},[166,2108,700],{"class":176},[166,2110,2111,2114,2117,2119],{"class":168,"line":215},[166,2112,2113],{"class":205},"    var",[166,2115,2116],{"class":176}," rounds ",[166,2118,206],{"class":205},[166,2120,2121],{"class":305}," 0\n",[166,2123,2124,2126,2129,2131,2134],{"class":168,"line":226},[166,2125,1003],{"class":205},[166,2127,2128],{"class":176}," (rounds ",[166,2130,451],{"class":205},[166,2132,2133],{"class":305}," 10_000",[166,2135,700],{"class":176},[166,2137,2138,2141,2143],{"class":168,"line":263},[166,2139,2140],{"class":176},"        rounds ",[166,2142,469],{"class":205},[166,2144,2145],{"class":305}," 1\n",[166,2147,2148,2151,2154,2156],{"class":168,"line":270},[166,2149,2150],{"class":205},"        var",[166,2152,2153],{"class":176}," waits ",[166,2155,206],{"class":205},[166,2157,2121],{"class":305},[166,2159,2160,2163,2165,2168],{"class":168,"line":285},[166,2161,2162],{"class":205},"        while",[166,2164,431],{"class":176},[166,2166,2167],{"class":180},"coroutinesBusy",[166,2169,2170],{"class":176},"()) {\n",[166,2172,2173,2176,2179,2181,2184,2187,2190,2192,2195,2198,2201],{"class":168,"line":328},[166,2174,2175],{"class":205},"            if",[166,2177,2178],{"class":176}," (waits ",[166,2180,1049],{"class":205},[166,2182,2183],{"class":305}," 20_000",[166,2185,2186],{"class":176},") { ",[166,2188,2189],{"class":180},"giveUp",[166,2191,808],{"class":176},[166,2193,2194],{"class":209},"\"the coroutine pool still busy after 2 s\"",[166,2196,2197],{"class":176},"); ",[166,2199,2200],{"class":205},"break",[166,2202,882],{"class":176},[166,2204,2205,2208,2211,2213,2216],{"class":168,"line":343},[166,2206,2207],{"class":176},"            LockSupport.",[166,2209,2210],{"class":180},"parkNanos",[166,2212,808],{"class":176},[166,2214,2215],{"class":305},"100_000L",[166,2217,184],{"class":176},[166,2219,2220,2223,2225],{"class":168,"line":350},[166,2221,2222],{"class":176},"            waits ",[166,2224,469],{"class":205},[166,2226,2145],{"class":305},[166,2228,2229],{"class":168,"line":373},[166,2230,582],{"class":176},[166,2232,2233,2236,2239],{"class":168,"line":403},[166,2234,2235],{"class":180},"        layOut",[166,2237,2238],{"class":176},"()                          ",[166,2240,2241],{"class":266},"\u002F\u002F a layout can compose views, whose tasks start\n",[166,2243,2244,2246,2248,2250,2252],{"class":168,"line":411},[166,2245,1043],{"class":205},[166,2247,431],{"class":176},[166,2249,2167],{"class":180},[166,2251,668],{"class":176},[166,2253,2254],{"class":205},"continue\n",[166,2256,2257,2259,2262],{"class":168,"line":425},[166,2258,1043],{"class":205},[166,2260,2261],{"class":176}," (looper.isIdle) ",[166,2263,2264],{"class":205},"return\n",[166,2266,2267,2269,2271],{"class":168,"line":460},[166,2268,1071],{"class":176},[166,2270,1074],{"class":180},[166,2272,400],{"class":176},[166,2274,2275],{"class":168,"line":478},[166,2276,588],{"class":176},[166,2278,2279,2282,2284,2287,2290],{"class":168,"line":484},[166,2280,2281],{"class":180},"    giveUp",[166,2283,808],{"class":176},[166,2285,2286],{"class":209},"\"the main looper not idle after 10,000 rounds\"",[166,2288,2289],{"class":176},")   ",[166,2291,2292],{"class":266},"\u002F\u002F the picture fails as \"unsettled\"\n",[166,2294,2295],{"class":168,"line":490},[166,2296,594],{"class":176},[11,2298,2299,2302,2303,2306,2307,2310],{},[109,2300,2301],{},"coroutinesBusy()"," спрашивает у планировщика за ",[109,2304,2305],{},"Dispatchers.Default"," (и за ",[109,2308,2309],{},"IO",", это тот же пул), занято ли разрешение на CPU, идёт ли блокирующая задача и стоит ли что-то в очереди. Задача считается с момента, когда её отправили на выполнение, ещё до того, как проснётся хоть один воркер, и это ровно то, что упускают состояния потоков. Это внутренний API kotlinx.coroutines 1.11, поэтому он читается через рефлексию, один раз на процесс:",[157,2312,2314],{"className":159,"code":2313,"language":161,"meta":162,"style":162},"private object Pool {\n    val read: (() -> Boolean)? = try {\n        val dispatcher: Any = kotlinx.coroutines.Dispatchers.Default\n        var type: Class\u003C*>? = dispatcher.javaClass\n        var field: java.lang.reflect.Field? = null\n        while (type != null && field == null) {   \u002F\u002F `coroutineScheduler`, somewhere up the class chain\n            field = type.declaredFields.firstOrNull { it.name == \"coroutineScheduler\" }\n            type = type.superclass\n        }\n        field!!.isAccessible = true\n        val scheduler: Any = field.get(dispatcher)!!\n        val kind = scheduler.javaClass\n        val state = kind.getDeclaredField(\"controlState\\$volatile\").also { it.isAccessible = true }\n        val blockingMask = kind.getDeclaredField(\"BLOCKING_MASK\").also { it.isAccessible = true }.getLong(null)\n        val blockingShift = kind.getDeclaredField(\"BLOCKING_SHIFT\").also { it.isAccessible = true }.getInt(null)\n        val core = kind.getField(\"corePoolSize\").getInt(scheduler)\n        val available = kind.getMethod(\"availableCpuPermits\", java.lang.Long.TYPE)\n        val queues = listOf(kind.getField(\"globalCpuQueue\").get(scheduler)!!,\n                            kind.getField(\"globalBlockingQueue\").get(scheduler)!!)\n        val size = queues[0].javaClass.superclass.getMethod(\"getSize\")\n        val reader: () -> Boolean = {\n            val now = state.getLong(scheduler)\n            val running = core - (available.invoke(scheduler, now) as Int)\n            val blocking = (now and blockingMask) shr blockingShift\n            running > 0 || blocking > 0L || queues.any { (size.invoke(it) as Int) > 0 }\n        }\n        reader()   \u002F\u002F fail here, not halfway through a render, if an update moved something\n        reader\n    } catch (_: Throwable) {\n        null       \u002F\u002F then fall back on the worker threads' states, and say so in the log\n    }\n}\n",[109,2315,2316,2328,2348,2363,2381,2400,2429,2452,2462,2466,2479,2502,2514,2553,2593,2632,2658,2679,2714,2736,2763,2779,2796,2822,2834,2878,2882,2892,2898,2915,2924,2929],{"__ignoreMap":162},[166,2317,2318,2320,2323,2326],{"class":168,"line":169},[166,2319,643],{"class":205},[166,2321,2322],{"class":205}," object",[166,2324,2325],{"class":172}," Pool",[166,2327,223],{"class":176},[166,2329,2330,2332,2335,2338,2341,2343,2346],{"class":168,"line":187},[166,2331,794],{"class":205},[166,2333,2334],{"class":176}," read: (() ",[166,2336,2337],{"class":1397},"->",[166,2339,2340],{"class":176}," Boolean)? ",[166,2342,206],{"class":205},[166,2344,2345],{"class":205}," try",[166,2347,223],{"class":176},[166,2349,2350,2352,2355,2358,2360],{"class":168,"line":196},[166,2351,353],{"class":205},[166,2353,2354],{"class":176}," dispatcher: ",[166,2356,2357],{"class":172},"Any",[166,2359,856],{"class":205},[166,2361,2362],{"class":176}," kotlinx.coroutines.Dispatchers.Default\n",[166,2364,2365,2367,2370,2373,2376,2378],{"class":168,"line":215},[166,2366,2150],{"class":205},[166,2368,2369],{"class":176}," type: ",[166,2371,2372],{"class":172},"Class",[166,2374,2375],{"class":176},"\u003C*>? ",[166,2377,206],{"class":205},[166,2379,2380],{"class":176}," dispatcher.javaClass\n",[166,2382,2383,2385,2387,2389,2392,2395,2397],{"class":168,"line":226},[166,2384,2150],{"class":205},[166,2386,853],{"class":205},[166,2388,1552],{"class":176},[166,2390,2391],{"class":172},"java",[166,2393,2394],{"class":176},".lang.reflect.Field? ",[166,2396,206],{"class":205},[166,2398,2399],{"class":305}," null\n",[166,2401,2402,2404,2407,2410,2413,2416,2418,2421,2423,2426],{"class":168,"line":263},[166,2403,2162],{"class":205},[166,2405,2406],{"class":176}," (type ",[166,2408,2409],{"class":205},"!=",[166,2411,2412],{"class":305}," null",[166,2414,2415],{"class":205}," &&",[166,2417,853],{"class":205},[166,2419,2420],{"class":205}," ==",[166,2422,2412],{"class":305},[166,2424,2425],{"class":176},") {   ",[166,2427,2428],{"class":266},"\u002F\u002F `coroutineScheduler`, somewhere up the class chain\n",[166,2430,2431,2434,2436,2439,2442,2445,2447,2450],{"class":168,"line":270},[166,2432,2433],{"class":205},"            field",[166,2435,856],{"class":205},[166,2437,2438],{"class":176}," type.declaredFields.",[166,2440,2441],{"class":180},"firstOrNull",[166,2443,2444],{"class":176}," { it.name ",[166,2446,1049],{"class":205},[166,2448,2449],{"class":209}," \"coroutineScheduler\"",[166,2451,882],{"class":176},[166,2453,2454,2457,2459],{"class":168,"line":285},[166,2455,2456],{"class":176},"            type ",[166,2458,206],{"class":205},[166,2460,2461],{"class":176}," type.superclass\n",[166,2463,2464],{"class":168,"line":328},[166,2465,582],{"class":176},[166,2467,2468,2471,2474,2476],{"class":168,"line":343},[166,2469,2470],{"class":205},"        field!!",[166,2472,2473],{"class":176},".isAccessible ",[166,2475,206],{"class":205},[166,2477,2478],{"class":305}," true\n",[166,2480,2481,2483,2486,2488,2490,2492,2494,2496,2499],{"class":168,"line":350},[166,2482,353],{"class":205},[166,2484,2485],{"class":176}," scheduler: ",[166,2487,2357],{"class":172},[166,2489,856],{"class":205},[166,2491,853],{"class":205},[166,2493,895],{"class":176},[166,2495,898],{"class":180},[166,2497,2498],{"class":176},"(dispatcher)",[166,2500,2501],{"class":205},"!!\n",[166,2503,2504,2506,2509,2511],{"class":168,"line":373},[166,2505,353],{"class":205},[166,2507,2508],{"class":176}," kind ",[166,2510,206],{"class":205},[166,2512,2513],{"class":176}," scheduler.javaClass\n",[166,2515,2516,2518,2521,2523,2526,2528,2530,2533,2536,2539,2541,2544,2547,2549,2551],{"class":168,"line":403},[166,2517,353],{"class":205},[166,2519,2520],{"class":176}," state ",[166,2522,206],{"class":205},[166,2524,2525],{"class":176}," kind.",[166,2527,861],{"class":180},[166,2529,808],{"class":176},[166,2531,2532],{"class":209},"\"controlState",[166,2534,2535],{"class":305},"\\$",[166,2537,2538],{"class":209},"volatile\"",[166,2540,836],{"class":176},[166,2542,2543],{"class":180},"also",[166,2545,2546],{"class":176}," { it.isAccessible ",[166,2548,206],{"class":205},[166,2550,879],{"class":305},[166,2552,882],{"class":176},[166,2554,2555,2557,2560,2562,2564,2566,2568,2571,2573,2575,2577,2579,2581,2584,2587,2589,2591],{"class":168,"line":411},[166,2556,353],{"class":205},[166,2558,2559],{"class":176}," blockingMask ",[166,2561,206],{"class":205},[166,2563,2525],{"class":176},[166,2565,861],{"class":180},[166,2567,808],{"class":176},[166,2569,2570],{"class":209},"\"BLOCKING_MASK\"",[166,2572,836],{"class":176},[166,2574,2543],{"class":180},[166,2576,2546],{"class":176},[166,2578,206],{"class":205},[166,2580,879],{"class":305},[166,2582,2583],{"class":176}," }.",[166,2585,2586],{"class":180},"getLong",[166,2588,808],{"class":176},[166,2590,844],{"class":305},[166,2592,184],{"class":176},[166,2594,2595,2597,2600,2602,2604,2606,2608,2611,2613,2615,2617,2619,2621,2623,2626,2628,2630],{"class":168,"line":425},[166,2596,353],{"class":205},[166,2598,2599],{"class":176}," blockingShift ",[166,2601,206],{"class":205},[166,2603,2525],{"class":176},[166,2605,861],{"class":180},[166,2607,808],{"class":176},[166,2609,2610],{"class":209},"\"BLOCKING_SHIFT\"",[166,2612,836],{"class":176},[166,2614,2543],{"class":180},[166,2616,2546],{"class":176},[166,2618,206],{"class":205},[166,2620,879],{"class":305},[166,2622,2583],{"class":176},[166,2624,2625],{"class":180},"getInt",[166,2627,808],{"class":176},[166,2629,844],{"class":305},[166,2631,184],{"class":176},[166,2633,2634,2636,2639,2641,2643,2646,2648,2651,2653,2655],{"class":168,"line":460},[166,2635,353],{"class":205},[166,2637,2638],{"class":176}," core ",[166,2640,206],{"class":205},[166,2642,2525],{"class":176},[166,2644,2645],{"class":180},"getField",[166,2647,808],{"class":176},[166,2649,2650],{"class":209},"\"corePoolSize\"",[166,2652,836],{"class":176},[166,2654,2625],{"class":180},[166,2656,2657],{"class":176},"(scheduler)\n",[166,2659,2660,2662,2665,2667,2669,2671,2673,2676],{"class":168,"line":478},[166,2661,353],{"class":205},[166,2663,2664],{"class":176}," available ",[166,2666,206],{"class":205},[166,2668,2525],{"class":176},[166,2670,828],{"class":180},[166,2672,808],{"class":176},[166,2674,2675],{"class":209},"\"availableCpuPermits\"",[166,2677,2678],{"class":176},", java.lang.Long.TYPE)\n",[166,2680,2681,2683,2686,2688,2691,2694,2696,2698,2701,2703,2705,2708,2711],{"class":168,"line":484},[166,2682,353],{"class":205},[166,2684,2685],{"class":176}," queues ",[166,2687,206],{"class":205},[166,2689,2690],{"class":180}," listOf",[166,2692,2693],{"class":176},"(kind.",[166,2695,2645],{"class":180},[166,2697,808],{"class":176},[166,2699,2700],{"class":209},"\"globalCpuQueue\"",[166,2702,836],{"class":176},[166,2704,898],{"class":180},[166,2706,2707],{"class":176},"(scheduler)",[166,2709,2710],{"class":205},"!!",[166,2712,2713],{"class":176},",\n",[166,2715,2716,2719,2721,2723,2726,2728,2730,2732,2734],{"class":168,"line":490},[166,2717,2718],{"class":176},"                            kind.",[166,2720,2645],{"class":180},[166,2722,808],{"class":176},[166,2724,2725],{"class":209},"\"globalBlockingQueue\"",[166,2727,836],{"class":176},[166,2729,898],{"class":180},[166,2731,2707],{"class":176},[166,2733,2710],{"class":205},[166,2735,184],{"class":176},[166,2737,2738,2740,2743,2745,2748,2751,2754,2756,2758,2761],{"class":168,"line":502},[166,2739,353],{"class":205},[166,2741,2742],{"class":176}," size ",[166,2744,206],{"class":205},[166,2746,2747],{"class":176}," queues[",[166,2749,2750],{"class":305},"0",[166,2752,2753],{"class":176},"].javaClass.superclass.",[166,2755,828],{"class":180},[166,2757,808],{"class":176},[166,2759,2760],{"class":209},"\"getSize\"",[166,2762,184],{"class":176},[166,2764,2765,2767,2770,2772,2775,2777],{"class":168,"line":529},[166,2766,353],{"class":205},[166,2768,2769],{"class":176}," reader: () ",[166,2771,2337],{"class":1397},[166,2773,2774],{"class":176}," Boolean ",[166,2776,206],{"class":205},[166,2778,223],{"class":176},[166,2780,2781,2784,2787,2789,2792,2794],{"class":168,"line":541},[166,2782,2783],{"class":205},"            val",[166,2785,2786],{"class":176}," now ",[166,2788,206],{"class":205},[166,2790,2791],{"class":176}," state.",[166,2793,2586],{"class":180},[166,2795,2657],{"class":176},[166,2797,2798,2800,2803,2805,2807,2809,2812,2814,2817,2819],{"class":168,"line":546},[166,2799,2783],{"class":205},[166,2801,2802],{"class":176}," running ",[166,2804,206],{"class":205},[166,2806,2638],{"class":176},[166,2808,1102],{"class":205},[166,2810,2811],{"class":176}," (available.",[166,2813,839],{"class":180},[166,2815,2816],{"class":176},"(scheduler, now) ",[166,2818,904],{"class":205},[166,2820,2821],{"class":176}," Int)\n",[166,2823,2824,2826,2829,2831],{"class":168,"line":557},[166,2825,2783],{"class":205},[166,2827,2828],{"class":176}," blocking ",[166,2830,206],{"class":205},[166,2832,2833],{"class":176}," (now and blockingMask) shr blockingShift\n",[166,2835,2836,2839,2841,2843,2845,2847,2849,2851,2853,2856,2859,2862,2864,2867,2869,2872,2874,2876],{"class":168,"line":579},[166,2837,2838],{"class":176},"            running ",[166,2840,1060],{"class":205},[166,2842,741],{"class":305},[166,2844,1055],{"class":205},[166,2846,2828],{"class":176},[166,2848,1060],{"class":205},[166,2850,1052],{"class":305},[166,2852,1055],{"class":205},[166,2854,2855],{"class":176}," queues.",[166,2857,2858],{"class":180},"any",[166,2860,2861],{"class":176}," { (size.",[166,2863,839],{"class":180},[166,2865,2866],{"class":176},"(it) ",[166,2868,904],{"class":205},[166,2870,2871],{"class":176}," Int) ",[166,2873,1060],{"class":205},[166,2875,741],{"class":305},[166,2877,882],{"class":176},[166,2879,2880],{"class":168,"line":585},[166,2881,582],{"class":176},[166,2883,2884,2887,2889],{"class":168,"line":591},[166,2885,2886],{"class":180},"        reader",[166,2888,337],{"class":176},[166,2890,2891],{"class":266},"\u002F\u002F fail here, not halfway through a render, if an update moved something\n",[166,2893,2895],{"class":168,"line":2894},28,[166,2896,2897],{"class":176},"        reader\n",[166,2899,2901,2904,2907,2910,2913],{"class":168,"line":2900},29,[166,2902,2903],{"class":176},"    } ",[166,2905,2906],{"class":205},"catch",[166,2908,2909],{"class":176}," (_: ",[166,2911,2912],{"class":172},"Throwable",[166,2914,700],{"class":176},[166,2916,2918,2921],{"class":168,"line":2917},30,[166,2919,2920],{"class":305},"        null",[166,2922,2923],{"class":266},"       \u002F\u002F then fall back on the worker threads' states, and say so in the log\n",[166,2925,2927],{"class":168,"line":2926},31,[166,2928,588],{"class":176},[166,2930,2932],{"class":168,"line":2931},32,[166,2933,594],{"class":176},[11,2935,2936],{},"Если коротко:",[2938,2939,2940,2943,2954,2957,2960],"ol",{},[105,2941,2942],{},"Дай отладочной сборке способ открыть любой экран из файла и сообщить, когда он готов.",[105,2944,2945,2946,2949,2950,2953],{},"Напиши один тест на Robolectric с ",[109,2947,2948],{},"@GraphicsMode(NATIVE)",", который запускает твою настоящую activity, по разу на экран, и сохраняет картинку через ",[109,2951,2952],{},"captureScreenRoboImage"," из Roborazzi.",[105,2955,2956],{},"Двигай часы сам, по одной задаче главного потока за раз, по ходу дела раскладывай каждое окно и жди пул корутин между задачами. Сначала выброси один прогревочный рендер: первая activity в процессе достаточно медленная, чтобы спрайт опоздал на кадр.",[105,2958,2959],{},"Считай проваленной любую картинку, за которую не можешь поручиться.",[105,2961,2962],{},"Смотри на картинки. Почти каждый баг выше нашли, посмотрев на картинку, а не по диффу.",[34,2964,2966],{"id":2965},"и-скриншоты-для-сторов","И скриншоты для сторов",[11,2968,2969],{},"Фикстуры отрабатывают своё дважды: из них же делаются скриншоты для обоих сторов, причём делают их скрипты, а не я с телефоном в руках.",[11,2971,2972,2975,2976,2978,2979,2981,2982,2985],{},[64,2973,2974],{},"App Store."," Скрипт собирает Debug-версию приложения (релизная сборка фикстуры игнорирует), загружает один закреплённый симулятор и для каждой из десяти фикстур запускает приложение с ",[109,2977,1158],{}," и ",[109,2980,1162],{},", ждёт файла ready, выставляет в строке состояния 9:41 и делает снимок. Потом скрипт на Python обрамляет каждый: Половинка стоит на краю телефона в масштабе 10× и произносит подпись в облачке, как на главном экране. Подпись набрана Micro 5, а там, где у Micro 5 нет букв (русский, украинский), жирным системным шрифтом. Headless Chrome рисует рамки ровно в 1320 × 2868: этот размер App Store Connect сам уменьшает для всех iPhone поменьше. А ",[109,2983,2984],{},"deliver"," из fastlane загружает их по одной, потому что App Store Connect давится параллельными загрузками в одну версию.",[11,2987,2988,2991,2992,2995],{},[64,2989,2990],{},"Google Play."," Те же фикстуры, только без симулятора: рендер из этого поста рисует восемь из них на шести языках, а тот же скрипт обрамляет их в 1080 × 1920. Этот размер выбирал не я. Play не принимает скриншот, у которого одна сторона больше другой вдвое с лишним, а 1081 × 2401 у Pixel 7 как раз длиннее, так что рамка 9:16, Половинка в масштабе 8×, а Micro 5 по 12 пикселей на пиксель шрифта. У рендера на JVM нет строки состояния, поэтому рамка оставляет экрану по 24 dp тёмного фона сверху и снизу, там, где у телефона свои панели. Затем ",[109,2993,2994],{},"supply"," из fastlane загружает набор, причём из лейнов, которые знают только внутреннее и закрытое тестирование Play: продакшен остаётся кнопкой в Play Console.",[11,2997,2998],{},[2999,3000],"img",{"alt":3001,"src":3002,"width":3003},"Второй скриншот для Google Play: Половинка выглядывает из-за края телефона под подписью «Everyone gets a secret word», над карточкой Чена в том виде, в каком её нарисовала JVM","\u002Fimages\u002Fhalfword_android\u002Fplay-reveal-civilian.jpg",270,[11,3005,3006,3009],{},[64,3007,3008],{},"Устаревший набор в стор не уедет."," Однажды набор скриншотов для App Store сняли с Debug-сборки, оставшейся с тех времён, когда правила ещё не поменялись: старые заголовки и одна картинка с надписью «Fixture failed». Поэтому теперь каждый набор помечен хешем исходников, из которых он нарисован, лейны загрузки отказываются брать набор, чей хеш не совпадает с текущим checkout, а прогон перед стартом удаляет старый набор, так что после неудачного прогона загружать нечего.",[34,3011,3013],{"id":3012},"вот-и-всё","Вот и всё",[11,3015,3016],{},"Рендеру эмулятор по-прежнему не нужен. Мой Mac рисует экраны Halfword для Android за то время, пока варится кофе, на четырёх языках, и если ему не мешать, пиксели каждый раз одни и те же. И «Без выбывания» помещается.",[3018,3019,3020],"style",{},"html pre.shiki code .soZtE, html code.shiki .soZtE{--shiki-default:#A6E22E;--shiki-default-text-decoration:underline}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html pre.shiki code .snpHw, html code.shiki .snpHw{--shiki-default:#88846F}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .sYf5A, html code.shiki .sYf5A{--shiki-default:#66D9EF}",{"title":162,"searchDepth":187,"depth":187,"links":3022},[3023,3024,3025,3030,3037,3038,3039,3040,3041],{"id":36,"depth":187,"text":37},{"id":81,"depth":187,"text":82},{"id":128,"depth":187,"text":129,"children":3026},[3027,3028,3029],{"id":614,"depth":196,"text":615},{"id":929,"depth":196,"text":930},{"id":1170,"depth":196,"text":1171},{"id":1639,"depth":187,"text":1640,"children":3031},[3032,3033,3034,3035,3036],{"id":1650,"depth":196,"text":1651},{"id":1841,"depth":196,"text":1842},{"id":1870,"depth":196,"text":1871},{"id":1903,"depth":196,"text":1904},{"id":1924,"depth":196,"text":1925},{"id":1970,"depth":187,"text":1971},{"id":2017,"depth":187,"text":2018},{"id":2068,"depth":187,"text":2069},{"id":2965,"depth":187,"text":2966},{"id":3012,"depth":187,"text":3013},"2026-10-02","Эмулятор Android и куча Gradle-сборок положили мой Mac, поэтому экраны Halfword для Android рисуются на JVM: Robolectric запускает настоящее приложение, Roborazzi сохраняет картинки, 456 штук на четырёх языках за 2 минуты 19 секунд. Рассказываю, как это работает, что оно поймало и чего показать не может.",false,"md","\u002Fimages\u002Fhalfword_android\u002Fcover.jpg",{},null,"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering",{"title":5,"description":3043},"halfword","ru\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering",[109,1184,161,3054],"testing","kXX-qym5cMwKkiBJk2WSFsxp4AcEjphV9iXxH8Y2B7A",[3057,3061],{"locale":3058,"path":3059,"title":3060},"en","\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering","I tested my Android screens without the emulator. All 114 of them, in four languages.",{"locale":3062,"path":3063,"title":3064},"de","\u002Fde\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering","Ich habe meine Android-Screens ohne Emulator getestet. Alle 114, in vier Sprachen.",{"slug":3051,"title":3066,"path":3067,"chapters":3068},"Как я делал Halfword","\u002Fru\u002Fseries\u002Fhalfword",[3069,3074],{"path":17,"title":3070,"description":3071,"date":3072,"image":3073,"draft":3044,"series":3051,"part":3048,"n":169},"Я сделал игру для компании. У всех одно и то же слово. Почти.","Halfword — это игра в слова Undercover на одном телефоне, который передают по кругу: пиксель-арт, восемь особых ролей и очки, которые копятся весь вечер. Она уже вышла на iPhone и Android. Рассказываю, чего стоило её сделать: очки, подобранные симуляцией, одна кодовая база на Swift для двух платформ, движок, который может воспроизвести любую игру заново, пиксель-арт без файлов картинок и экраны, нарисованные раньше, чем написан код.","2026-10-01","\u002Fimages\u002Fhalfword\u002Fcover.png",{"path":3049,"title":5,"description":3043,"date":3042,"image":3046,"draft":3044,"series":3051,"part":3048,"n":187},1791333557252]