Всё, что FrameHUD умеет, когда зависимость уже добавлена.
Сборка, которая должна отчитываться, но не рисовать, берёт framehud-metrics вместо framehud. Он
собирает те же числа и шлёт те же события, но не добавляет ни окна, ни SYSTEM_ALERT_WINDOW в
объединённый манифест.
qaImplementation("com.timkrest:framehud-metrics:0.18.1")
FrameHud — тот же объект, так что код вокруг остаётся прежним. enabled включает только сбор, а
overlayMode не используется. framehud — это тот же артефакт, панель и
команды adb, так что debug-сборке больше ничего не нужно. Сам по себе этот
артефакт не объявляет ни одного экспортированного компонента.
Такой сборке не обязательно быть debuggable. Сбор, события, экспорт и базлайн работают и на
QA-флейворе с релизной подписью. За android:debuggable остаётся запасной путь через run-as,
который нужен экспорту на устройстве без внешнего хранилища.
Панель не читает ничего, чего не может приложение. Она стоит на тех же StateFlow и событиях, что
описаны ниже, так что сборка со своим выводом во вьюхах или своей телеметрией берёт всё отсюда.
Все настройки — один неизменяемый конфиг. Присваиваете копию, она применяется сразу.
FrameHud.config = FrameHud.config.copy(metricsSampleWindowFrames = 240)
| Настройка | По умолчанию | Что задаёт |
|---|---|---|
enabled |
true |
Пока false — окно не добавляется и кадры не собираются. |
overlayMode |
PREFER_SYSTEM |
APP_WINDOW держит панель внутри окна приложения и не использует разрешение. |
eventListeners |
[LogcatEventListener] |
Кто получает события о первом кадре, полезном кадре, jank, замёрзших кадрах, троттлинге, итогах экрана и взаимодействия, внутренних сбоях. |
metricsSampleWindowFrames |
120 |
За сколько кадров считаются avg и перцентили. |
metricsThrottleIntervalMs |
400 |
Как часто панель может перерисовываться. Меньше — дороже рендер. |
fallbackRefreshRateHz |
60 |
Герцовка, если дисплей её не сообщает. |
frameBudgetsMs |
{} |
Сколько миллисекунд отводится кадру на интервале вместо дедлайна дисплея. |
metricsThreadName |
framehud-metrics |
Имя потока сбора, под которым он виден в трейсах. |
perfettoTrigger |
null |
Триггер Perfetto, который дёргает инцидент, чтобы кольцевой трейс сохранил секунды вокруг него. |
keptRuns |
0 |
Сколько прогонов держать в framehud/history.json, считая текущий. Ноль не пишет файла. |
show(), hide() и toggle() — сокращения для enabled.
FrameHud.metrics, memoryStats, thermalStats, processStats, counters,
choreographerTicksPerSecond и diagnosis — обычные StateFlow, так что метрики читаются и без
панели. Показание разложено на phases
(тайминги по стадиям), window (fps, jank и p95 по окну выборки и бюджет, по которому их судили),
session (с последнего сброса) и display (герцовка и дедлайн, который выдаёт дисплей).
sessionStats() и intervals() приостанавливают вызов, пока не ответит поток метрик. Первая даёт
сессию с последнего сброса, вторая добавляет каждый экран и каждую метку вместе с бюджетом кадра,
по которому их судили.
FrameHudEvent.FirstFrame сообщает, сколько экземпляр Activity шёл до первого показанного кадра.
Отсчёт начинается до onCreate и заканчивается в конце этого кадра. Слушатель по умолчанию пишет в
logcat: MainActivity: first frame in 123.4 ms.
Android считает первые кадры ожидаемо медленными, поэтому FrameHUD сообщает их отдельно и не добавляет в оконную и сессионную статистику jank. Пересоздание Activity — новый замер, возврат в уже существующую Activity — нет.
Стартовая отметка берётся из onActivityPreCreated, поэтому событию нужен API 29. На старых версиях
первый кадр остаётся вне статистики jank, но событие FirstFrame не приходит.
Skeleton рисуется быстро, поэтому первый кадр мало говорит о том, когда экран стал полезным.
FrameHudEvent.UsableFrame сообщает момент, когда приложение само объявило экран готовым. Замер
заканчивается в конце следующего показанного кадра. Слушатель по умолчанию пишет в logcat:
MainActivity: usable in 456.7 ms.
Первому экрану ComponentActivity, созданной во время сбора, вызов не нужен. FrameHUD слушает её
FullyDrawnReporter, так что запуск покрывает тот ReportDrawnWhen или reportFullyDrawn(),
который уже стоит для Macrobenchmark.
FullyDrawnReporter придерживает любой отчёт до следующей отрисовки, через слушателя, которого
окно ниже API 26 теряет, так что на Android 7 ни ReportDrawn, ни ReportDrawnWhen, ни остальное
из этого API не сообщает ничего. Там запуску нужен свой вызов FrameHud.reportUsable() или прямой
reportFullyDrawn(), который работает на всех версиях.
Любой другой экран сообщает о себе сам: переименованный через FrameHud.screen, активити, в
которую вернулись, активити без репортера.
adapter.submitList(rows) { FrameHud.reportUsable() }
Время до полезного кадра считается от начала экрана. Новая активити считает от своего создания: на
API 29+ до onCreate, ниже — с вызова super.onCreate. Переименованный экран считает от
переименования. Повторное сообщение ничего не меняет, новый экран замеряется заново.
Полезный кадр остаётся в статистике jank. Исключение — запуск, который был полезен уже к первому кадру: этот кадр и есть first draw, который Android считает ожидаемо медленным, поэтому он остаётся вне статистики.
Приходит одно событие на всплеск jank, а не на каждый кадр, и причина в нём уже определена. Слушатель по умолчанию пишет в logcat.
FrameHud.config = FrameHud.config.copy(
eventListeners = listOf(
LogcatEventListener,
FrameHudEventListener { event -> analytics.log(event.summary) },
),
)
События приходят на metrics-потоке. Не блокируйте его и не обращайтесь из него к view.
FrameHudEvent.InternalFailure говорит, что FrameHUD поймал собственный сбой, а не уронил вместе с
собой приложение, и называет вызов, на котором это случилось. Стек каждый раз уходит в logcat, а
событие приходит один раз на вызов до reset, поэтому слушателя, который шлёт его в вашу
телеметрию, не затопит поломкой, повторяющейся на каждом кадре. Своего сборщика крашей у FrameHUD
нет, и UncaughtExceptionHandler он себе не ставит: что поймал, то отдал вам.
Всплеск jank или замёрзший кадр открывает вокруг себя короткое окно: примерно секунда кадров до срабатывания и полсекунды после, вместе со статистикой по ним и с показаниями памяти, троттлинга, процесса и счётчиков на тот момент. QA сохраняет сам случай, а не пытается воспроизвести его потом.
Главный поток, который 300 мс не обработал ни одного кадра, снимается с фонового потока тут же,
потом через 100 мс, а дальше пауза каждый раз удваивается до 800 мс, пока он не начнёт рисовать
снова. Инцидент хранит вызовы, на которых его заставали чаще всего, поэтому замёрзший кадр называет
работу, державшую поток, а не только то, сколько она его держала. Стек, снятый больше чем за две
секунды до срабатывания, ничего о нём не объясняет, и в инцидент не попадает. В JSON он лежит как
mainThreadBlock, а HTML-отчёт печатает его под графиком инцидента.
Срабатывания, которые винят одно и то же на одном экране при одних метке и контексте, — это один инцидент, поэтому отчёт говорит, что layout jank повторился семь раз, и хранит худшее из семи окон, а не семь его копий.
lifecycleScope.launch {
for (incident in FrameHud.incidents()) {
Log.w("app", "${incident.worst.trigger.summary}, раз: ${incident.occurrences}")
}
}
Инциденты попадают и в экспорт сессии, худший случай первым: HTML-отчёт рисует каждое сохранённое
окно и отмечает на графике момент срабатывания, а JSON несёт диагноз, число повторов, статистику
окна и каждый его кадр. Хранятся только худшие случаи, а reset их очищает.
Трейс объясняет тот jank, о котором FrameMetrics может только сообщить, но записывать его нужно
до того, как jank случится, а писать час подряд в надежде, что он случится, никто не станет.
Perfetto пишет в кольцевой буфер и сохраняет накопленное, только когда его об этом попросят. QA запускает такой трейс через adb, FrameHUD просит при открытии инцидента, и на диск ложатся секунды вокруг jank, а не весь прогон.
Задайте имя триггера, и каждый инцидент будет о нём просить:
FrameHud.config = FrameHud.config.copy(perfettoTrigger = "framehud_incident")
Записывайте с конфигом, который этого имени ждёт. atrace_apps кладёт в трейс собственные секции
приложения, в том числе framehud:*:
buffers {
size_kb: 65536
fill_policy: RING_BUFFER
}
data_sources {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "sched/sched_switch"
ftrace_events: "sched/sched_waking"
atrace_categories: "gfx"
atrace_categories: "view"
atrace_apps: "com.example.app"
}
}
}
trigger_config {
trigger_mode: STOP_TRACING
trigger_timeout_ms: 1800000
triggers {
name: "framehud_incident"
stop_delay_ms: 2000
}
}
cat flight.txtpb | adb shell perfetto --txt -c - \
-o /data/misc/perfetto-traces/flight.pftrace --background
stop_delay_ms — сколько прогона после инцидента трейс ещё оставит себе, trigger_timeout_ms —
сколько запись будет ждать просьбы. Трейс, которого никто так и не попросил, не пишет ничего.
Приложение может попросить в свой момент через FrameHud.retainTrace(), а QA — броадкастом
RETAIN из набора adb-команд. Повторная просьба в течение пяти секунд
ничего не меняет, так что всплеск инцидентов стоит одной
просьбы, а не одной на кадр. Отчёт сессии называет триггер и считает просьбы, и по нему видно
разницу между прогоном, которому нечего было удерживать, и прогоном, где ничего не подключили.
FrameHUD трейс не запускает, не настраивает и не читает. Он запускает /system/bin/trigger_perfetto,
что доступно любому приложению, а всё остальное — дело Perfetto. Там, где этого бинарника нет, сбой
приходит один раз как FrameHudEvent.InternalFailure, а не на каждый инцидент.
Статистика режется по экранам, а экран по умолчанию — это класс activity, из-за чего single-activity-приложение оказывается одним экраном на всю сессию. Назовите экран роутом:
navController.addOnDestinationChangedListener { _, destination, _ ->
FrameHud.screen = destination.route
}
Используйте шаблон product/{id}, а не product/12345, чтобы каждая карточка товара считалась
одним экраном. Новое имя закрывает статистику предыдущего экрана и начинает следующий. Имя действует
до следующего присваивания, поэтому приложение, которое называет экраны, должно называть каждый
показанный экран. null возвращает имена по классам activity. Имя должно отличаться от других
в трейсе; метка следует тому же правилу.
Диалог, popup и Presentation рисуются каждый в своём окне, а FrameHUD сам находит только то,
которым владеет активити в фокусе. Передайте остальные — и каждое станет отдельным экраном:
dialog.setOnShowListener { FrameHud.measureWindow(dialog.window!!, "checkout/promo") }
dialog.setOnDismissListener { FrameHud.forgetWindow(dialog.window!!) }
Его кадры идут в сессию и в этот экран, поэтому он встаёт рядом с экранами активити в screens(),
в обоих отчётах и в сравнении с базлайном. Живые показания панели, метки и события о jank
остаются за активити — панель рисуется поверх именно его окна.
Сначала пройдитесь по приложению, а потом посмотрите, какие экраны оказались худшими, вместо чтения
цифр по каждому в реальном времени. Кнопка ▤ в шапке панели переключает её между метриками и
экранами, повторное нажатие возвращает обратно.
SCREENS 118 FPS
screen frm jank p95 frz
checkout 640 18.4 31.2 2
product/{i… 1820 6.1 19.7 0
cart 90 2.2 13.4 0
lifecycleScope.launch {
val worst = FrameHud.screens().firstOrNull() ?: return@launch
Log.w("app", "самый плохой экран пока что — ${worst.id.name}")
}
Худшие первыми — это больше всего замёрзших кадров, потом больше всего jank, потом самый медленный p95. Экран, на котором меньше 60 кадров, по p95 судить нельзя, поэтому он уходит в конец, как бы плохо ни выглядел. В отчёте о сессии есть та же таблица.
Итог по экрану говорит, какой экран медленный, а метка сужает это до одного взаимодействия.
FrameHud.mark = "scroll"
try {
listState.animateScrollToItem(lastIndex)
} finally {
FrameHud.mark = null
}
Сбрасывайте метку в finally: забытая метка продолжит забирать кадры уже после взаимодействия, а
отменённый на полпути скролл до следующей строки просто не дойдёт.
Кадры, отрисованные при выставленной метке, относятся к ней. В шапке вместо таймингов появляется
▸ scroll, каждое событие за это время несёт имя метки, а её сброс даёт MarkEnded со статистикой
только по этому отрезку. Строки самой панели остаются на обычном окне и сессии, а шапка их только
подписывает.
Уход с экрана или его переименование сбрасывает метку, так что жест не перетекает на следующий экран.
FrameHud.context хранит несколько пар key=value рядом с экраном и меткой: вариант UI, действие,
тестовый сценарий. Каждое событие несёт пары, выставленные в момент, когда оно случилось, и экспорт
их сохраняет, поэтому отчёт говорит, где был jank и при каких условиях.
FrameHud.context = mapOf("variant" to "new_checkout")
Смена контекста ничего не закрывает, только подписывает то, что происходит дальше.
exportSession записывает сессию с последнего сброса как JSON и самодостаточный HTML-отчёт и
возвращает оба файла. В каждом есть статистика и разбивка по фазам для сессии и для текущего экрана,
окно кадров, худшие кадры с временем по часам, все инциденты, здоровье процесса за прогон, счётчики
приложения, триггер Perfetto, о котором оно просило, контекст, устройство, версия приложения,
состояние измерений и проблемы достоверности.
Они ложатся в framehud/ во внешнем каталоге файлов приложения, так что CI забирает их без root.
Устройство, которое внешнего хранилища не сообщает, откатывается на внутренний каталог, который
adb pull не прочитает вовсе; достать оттуда отчёт можно через
adb exec-out run-as <package> cat files/framehud/<файл>, а shareSession в обоих случаях
открывает системное окно «Поделиться». Ничего никуда не загружается. Неудачная запись бросает
исключение, а не возвращает пустой экспорт, поэтому вызов, который не должен ронять приложение,
ловит IOException. saveBaseline и history отвечают так же.
lifecycleScope.launch {
val export = FrameHud.exportSession() ?: return@launch
FrameHud.shareSession(activity, export)
}
adb pull /sdcard/Android/data/<package>/files/framehud/
Команды лежат в отдельном артефакте, который framehud уже тянет за собой:
qaImplementation("com.timkrest:framehud-qa:0.18.1")
Сборка с ним отвечает на броадкасты, так что QA-сборка начинает отчитываться, помечает сценарий и забирает результат без пересборки:
adb shell am broadcast -a com.timkrest.framehud.ENABLE <package>
adb shell am broadcast -a com.timkrest.framehud.SCREEN --es name "product/{id}" <package>
adb shell am broadcast -a com.timkrest.framehud.MARK --es name checkout <package>
adb shell am broadcast -a com.timkrest.framehud.CONTEXT --es scenario smoke <package>
adb shell am broadcast -a com.timkrest.framehud.EXPORT <package>
adb shell am broadcast -a com.timkrest.framehud.BASELINE <package>
adb shell am broadcast -a com.timkrest.framehud.RETAIN <package>
DISABLE и RESET дополняют набор. Без --es name экран или метка сбрасываются, CONTEXT без
extras очищает контекст. Имя, которое FrameHUD отвергает, получает в ответ failed: и оставляет
прежнее на месте, так что скрипт с пустой переменной об этом узнает. EXPORT отвечает путём к
отчёту в результате броадкаста, а BASELINE — путём к обновлённому базлайну, так что скрипт
забирает тот каталог, который выбрало устройство. RETAIN просит
flight recorder сохранить трейс.
QA-флейвор с релизной подписью подключает артефакт сам, и подпись — как раз то, ради чего это делается: R8 отработал, и тайминги те же, что получит устройство пользователя.
Приёмник обязан быть экспортированным, иначе adb shell am broadcast до него не достучится, и он
требует от отправителя android.permission.DUMP. У shell оно есть, приложению, которое ставит
пользователь, его не выдать. Сборка, которой нужна панель без команд, убирает приёмник:
<receiver
android:name="com.timkrest.framehud.FrameHudCommandReceiver"
tools:node="remove" />
Пока пишется системный трейс, экраны и метки видны как секции framehud:screen:<имя> и
framehud:mark:<имя>, всплеск jank — как framehud:jank_burst, а процент jank, p95, замёрзшие
кадры и heap — как счётчики framehud.*. То, что приложение ведёт через FrameHud.counter,
добавляется к ним как framehud:counter:<имя>. TraceSectionMetric из Macrobenchmark меряет
именованные секции, и всё это попадает в тот трейс, который
flight recorder сохраняет вокруг инцидента.
Имя, которое FrameHUD туда пишет, должно отличаться от любого другого: непустое, не длиннее 110
символов и без | и управляющих символов. Длинное имя трейс обрежет до такого, которое может
совпасть с чужим, а остальные обрывают запись или делят её надвое. Правило одно для экрана, метки и
счётчика, и имя, которое его нарушает, FrameHUD отвергает, вместо того чтобы дать трейсу слить два
имени в одно.
IntervalStats.lostTimeMs — сумма того, насколько опоздавшие кадры вышли за дедлайн. Jank-процент
считает любой опоздавший кадр за один плохой, как бы сильно он ни опоздал; потерянное время хранит
размер опоздания, и два экрана с одинаковыми 5% jank перестают выглядеть одинаково.
Панель показывает отдельную строку, когда он выше нуля, оба отчёта несут его по сессии и по экрану,
а JankThresholds(maxLostTimeMs = 500f) роняет по нему тест.
Кадр считается janky, когда вышел за дедлайн, который дал ему дисплей: 16.7 мс на 60 Гц, 8.3 мс на 120 Гц. Экран, который обязан держать 120 Гц на 60-герцовом тестовом устройстве, или заведомо тяжёлый экран можно судить по числу, которое выберете вы.
FrameHud.config = FrameHud.config.copy(
frameBudgetsMs = mapOf(
IntervalId.Screen("feed") to 8,
IntervalId.Mark("scroll") to 8,
),
)
Бюджет распространяется на всё, что лежит внутри интервала. IntervalId.Session — на все экраны,
экран — на метки, сделанные на нём. Побеждает запись, лежащая глубже. Бюджету следует всё, что
считает интервал: jank-процент, потерянное время, самая длинная серия janky-кадров, открытые
инциденты и база, с которой его сравнивают.
Пока такой экран в фокусе, панель следует тому же бюджету: число в шапке и линия поперёк
спарклайна. IntervalReport.frameBudgetMs говорит, каким бюджетом судили строку, и оба отчёта его
печатают. Интервал, у которого бюджет сменился на полпути, не называет никакого.
IntervalStats.phases разбивает сессию, экран или метку по фазам конвейера, это PhaseAverages:
сколько миллисекунд средний кадр провёл в layout, draw, swap buffers и остальных фазах и на какой
стадии он упёрся.
Строки фаз на панели показывают последние metricsSampleWindowFrames кадров, поэтому экран, который
тормозил две секунды, к моменту чтения снова выглядит нормально; IntervalStats.phases учитывает
каждый кадр интервала. Оба отчёта несут разбивку по сессии и по экрану, а HTML-отчёт ставит их
рядом.
PhaseAverages.gpu равен null, пока драйвер не сообщит время GPU: для этого нужен API 31+.
Раз в несколько секунд FrameHUD снимает состояние процесса за кадрами: CPU, PSS, число потоков и открытых файловых дескрипторов, каждое с пиком с последнего сброса.
cpu 42% ▲61 · pss 210 ▲228 MB
thr 38 ▲41 · fd 129 ▲140
CPU — это доля одного ядра, поэтому восемь загруженных ядер дают 800%. Смысл в устойчивом росте: экран, который течёт потоками или дескрипторами, видно здесь задолго до jank. Цифры попадают в отчёт о сессии и в каждый инцидент, а то, что устройство сообщать отказалось, не показывается вовсе, а не читается как ноль.
Замеры идут на отдельном потоке framehud-process: чтение PSS обходит все отображения процесса,
и на старом устройстве это занимает достаточно, чтобы задержать сбор кадров.
Рядом с данными о кадрах может стоять число, о котором конвейер кадра ничего не знает: очередь декодирования картинок, промахи кэша, объекты в полёте.
private val queue = FrameHud.counter("decode queue")
fun onDecodeQueued() {
queue.set(pending.size)
}
set заменяет значение, add сдвигает его, и то и другое можно звать из любого потока. FrameHUD
читает значение на своём тике, поэтому панель, трейс и отчёты показывают его выборкой. Пик выборкой
не берётся: его поднимает каждая запись. reset опускает пик к тому, что счётчик показывает в этот
момент, а само значение не трогает — оно принадлежит приложению: накопительный счётчик продолжает
считать через reset, а датчик сохраняет последнее показание.
В панели на счётчик приходится строка, но не больше четырёх: остальные сворачиваются в
+N more counters. Отчёт сессии перечисляет все, и каждый инцидент запоминает, что они показывали в
момент срабатывания. В системном трейсе счётчик выше становится
дорожкой framehud:counter:decode queue — под тем именем, которое дало приложение, рядом с
дорожками framehud.*, по которым отчитывается сам FrameHUD, но никогда поверх них.
Отслеживается шестнадцать имён, всё сверх того отбрасывается с предупреждением в logcat. Имя должно
отличаться от других в трейсе — то же правило, что у экрана и метки.
FrameHud.counters — тот же список как StateFlow, для приложения, которое читает числа без
панели.
IntervalStats.confidence перечисляет, что мешало сбору: потерянные отчёты FrameMetrics,
слушатель событий, занявший поток метрик, троттлинг, энергосбережение или заряд не выше 15%,
смена герцовки, эмулятор, слишком короткая выборка. Каждая проблема называет цифры, которые она
портит: эмулятор — только фазы render-потока и GPU, смена герцовки — jank-процент, потерянное время
и серию, короткая выборка — перцентили, на которые не хватает кадров (p99 меньше 300 кадров, p95 и
jank-процент меньше 60, p50 меньше 20). Остальные портят все цифры.
JSON-экспорт несёт проблемы для сессии и экрана, HTML-отчёт их перечисляет, а сводки
ScreenEnded/MarkEnded заканчиваются пометкой (suspect measurement).
Метрики живут в памяти, поэтому перезапуск приложения уносит их с собой. Скажите, сколько прогонов хранить, и FrameHUD запишет каждый в файл рядом с экспортами.
FrameHud.config = FrameHud.config.copy(keptRuns = 10)
Прогон — это всё, что FrameHUD намерил с запуска процесса или с последнего сброса. Он пишется каждый раз, когда приложение уходит с переднего плана, и продолжающийся прогон переписывает свою же запись, а не добавляет новую, так что хождение по экранам не забивает файл одним прогоном. Прогон, убитый прямо на экране, хранит то, что у него было в последний уход.
lifecycleScope.launch {
val previous = FrameHud.history().firstOrNull() ?: return@launch
val checkout = previous.interval(IntervalId.Screen("checkout")) ?: return@launch
Log.w("app", "в прошлый раз checkout шёл на ${checkout.stats.p95FrameMs} мс по p95")
}
history() отвечает свежим вперёд и не отдаёт текущий прогон. Каждый прогон несёт сессию, каждый
экран и каждую отметку, которые намерил, а рядом — устройство и версию приложения, на которых он
шёл. Файл лежит в framehud/history.json, и adb pull достаёт его так же, как экспорты; когда
файл не прочитать, он бросает исключение, а не отвечает, что прогонов не было.
Это не базлайн. Базлайн — одна усреднённая цифра на интервал, и он отвечает, хуже ли этот прогон обычного. История — сами прогоны, каждый со временем записи.
Фиксированный порог, подобранный под одно устройство, флачит на другом, и обычно именно поэтому jank-гейт в итоге выключают. Базлайн его заменяет: прогон сравнивает себя с тем, что намеряли прошлые прогоны на том же устройстве и той же версии Android.
Базлайн — это файл рядом с экспортами. CI забирает его после прогона и кладёт обратно перед следующим, так что базлайн есть и на устройстве, которое между прогонами чистят.
adb shell am broadcast -a com.timkrest.framehud.BASELINE <package>
adb pull /sdcard/Android/data/<package>/files/framehud/baseline.json
adb push baseline.json /sdcard/Android/data/<package>/files/framehud/baseline.json
Команда усредняет текущую сессию в framehud/baseline.json и отвечает путём к нему, и брать надо
именно этот путь. Устройство без внешнего хранилища держит файл внутри приложения, где adb pull и
adb push получают отказ, а обмен идёт через run-as:
adb exec-out run-as <package> cat files/framehud/baseline.json > baseline.json
adb shell -T "run-as <package> sh -c 'cat > files/framehud/baseline.json'" < baseline.json
Второй вызов без RESET между ними ничего не меняет, так что повтор команды не засчитает один
прогон дважды.
Когда файл на месте, оба отчёта несут дельту по сессии, по экранам и по меткам и называют фазу, которая выросла сильнее всех.
Базлайн хранит только то, что считается на кадр или в долях кадров: перцентили, процент jank, потерянное время на кадр, замёрзшие кадры в процентах и средние фазы, поэтому вдвое более длинный прогон не выглядит вдвое худшим. Базлайн, снятый на другом устройстве, не сравнивается вовсе, и отчёт говорит, откуда он взялся.
Процент jank и потерянное время привязаны к бюджету кадра, по которому их судили. На дисплее, который переключает частоту, они меняются и сравниваются только между прогонами с одним бюджетом, а три прогона подряд под новым начинают их с него заново. Перцентили и фазы — миллисекунды, они сравниваются при любой частоте.
Каждый новый прогон весит не меньше десятой части базлайна, поэтому базлайн поспевает за приложением, а не застывает на первых прогонах.
Цифра, которую задела проблема достоверности, в базлайн не попадает и не сравнивается. Троттлинг задевает все цифры прогона. Слишком короткая выборка задевает только те перцентили, которых ей не хватает, остальное из этого прогона считается. Отчёт показывает, сколько прогонов усреднено в каждой цифре.
В коде saveBaseline пишет файл, compareWithBaseline возвращает дельты, а
FrameHud.baselineOverride сравнивает со своим базлайном вместо файла. Свой базлайн собирается из
BaselineEntry.of и BaselineEnvironment.current().
androidTestImplementation("com.timkrest:framehud-instrumentation:0.18.1")
@get:Rule val noJank = DetectJankAfterTestSuccess(JankThresholds(maxJankPercent = 2f))
Правило сбрасывает сборщик перед каждым тестом и проверяет пороги после того, как тест прошёл, так
что упавший тест остаётся со своей ошибкой. Итоги сессии переживают закрытие панели, поэтому цифры
остаются и после того, как ActivityScenario закрыл activity.
Порог, чью цифру портит проблема достоверности, не может честно ни пройти, ни упасть, поэтому гейт
считает прогон inconclusive и пишет и саму цифру, и проблему. По умолчанию OnInconclusive.FAIL
валит тест, OnInconclusive.WARN пишет сообщение в лог и пропускает, а OnInconclusive.SKIP
помечает тест пропущенным — обычно это то, что нужно на общем устройстве в CI: прогон, который
машина была слишком занята измерить, ничего не блокирует, а регрессия не выдаётся за чистый прогон.
Нарушенный порог с чистой цифрой падает в любом режиме.
Порог может быть не фиксированным, а относительным. BaselineThresholds роняет тест, когда цифра
вырастает больше чем на заданную долю базлайна этого устройства:
@get:Rule val noJank = DetectJankAfterTestSuccess(JankThresholds.baselineOnly())
baselineOnly выключает фиксированные пороги и оставляет сравнение. Если передать baseline в
JankThresholds самому, останется и то и другое, и тест должен пройти ещё и по фиксированным
порогам.
Рост меньше половины миллисекунды или половины процентного пункта считается шумом и не роняет ничего; потерянное время считается на кадр, и его граница — десятая доля миллисекунды. Пока базлайна нет, проверка пишет в лог и пропускает, а базлайн с другого устройства даёт inconclusive, как и испорченная цифра.
Выбранная метрика, которую сравнить не удалось, тоже даёт inconclusive, а не пропуск по остальным. В сообщении сказано почему: этот прогон измерил её с проблемой достоверности либо два прогона судили разные бюджеты кадра.
Чтобы отключить проверку, пометьте тест или класс @SkipJankDetection либо вызовите
JankAssertions.assertNoJank("scroll") в нужный момент сами.
Гейт сбрасывает сборщик, а FrameHudResetRule вдобавок очищает то, что живёт дольше теста: экран,
метку, контекст и подменённый базлайн. Иначе набор тестов, который что-то из этого выставил, начнёт
следующий тест с прежними значениями. Правило пишет main-thread свойства на main thread, из какого
бы потока ни шёл тест.
@get:Rule val resetFrameHud = FrameHudResetRule()
Правило, открывающее метку, должно быть внутри этого: закрытие метки доходит до слушателей событий.
С выданным SYSTEM_ALERT_WINDOW панель живёт в системном окне и переживает переходы между экранами.
Без него окно принадлежит текущей activity, поэтому на каждом переходе панель поднимается заново, а
рядом появляется кнопка ⧉, открывающая экран разрешения.
Сама библиотека его не открывает. Поставьте overlayMode = APP_WINDOW, чтобы остаться в окне
приложения и убрать эту кнопку.
./gradlew :sample:installDebug
Три вкладки поверх одного прогона.
Load — список из 300 строк и шесть переключателей: блокировка main thread, overdraw, аллокации
на строку, вложенные layout’ы, поток мусора, фоновое декодирование. Каждый двигает свою метрику, а
выбранный набор едет вместе с каждым событием и каждым инцидентом как контекст замера. Переключатель
над списком судит кадры по бюджету вместо дедлайна дисплея: 16 мс на сессию и 8 мс, пока список
скроллится. Скролл размечен меткой. Строка открывается как экран с именем row/{index}, который
сообщает о готовности, когда его данные загрузились.
Readouts — всё, что FrameHUD измеряет, прочитанное из StateFlow, а не с панели: скользящее окно,
фазы, сессия, диагноз, память, троттлинг, здоровье процесса, счётчики. Два счётчика ведёт само
приложение: rows composed растёт, пока список рекомпозится, а decode queue следит за фоновой
очередью, пока включён её переключатель.
Session — то, чем заканчивается прогон QA. Здесь интервалы с бюджетом, по которому судили каждый, экраны от худшего к лучшему и инциденты с показаниями того момента, когда они сработали. Можно сохранить базлайн и сравниться с ним, включить хранение прошлых прогонов и посмотреть, как шли последние, заморозить показания, включить и выключить сбор, отправить отчёт, отдать на замер окно диалога или включить flight recorder и попросить трейс Perfetto сохранить накопленное.
До 1.0 публичный API может измениться в минорном релизе; changelog говорит, что писать вместо старого.
1.0 его фиксирует. Внутри мажорной версии бинарная совместимость держится, кроме двух случаев, которые минорный релиз всё же может себе позволить. Оба ловит компилятор, и оба лечатся пересборкой на новой версии:
copy:
FrameHudConfig получает настройку, IntervalStats — цифру. Строить их и есть смысл, поэтому они
остаются публичными и растут вместе с ним.when по нему без else перестаёт компилироваться.Показание, которое вы только читаете, не имеет ни публичного конструктора, ни copy, поэтому
получает новые цифры, никого не ломая. Всё, что уходит, сначала помечается @Deprecated минимум на
один минорный релиз, с указанием замены, и исчезает в следующей мажорной версии.
framehud-noop существует ровно для того, чтобы
release-код без него компилировался.FrameMetrics ничего не сообщает про Vulkan, OpenGL и
игровые движки — значит, не сообщает и FrameHUD.debugImplementation и releaseImplementation по
вариантам и сэкономил строку @get:Rule на модуль, но угнаться за версиями AGP дороже, чем то и
другое вместе.FrameMetrics, а это Android и ничего больше.