FrameHUD

Руководство FrameHUD

English · Русский

Всё, что 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

Приходит одно событие на всплеск 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 их очищает.

Perfetto flight recorder

Трейс объясняет тот 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/

Управление через adb

Команды лежат в отдельном артефакте, который 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().

Падение тестов из-за jank

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, чтобы остаться в окне приложения и убрать эту кнопку.

Установка вручную вместо provider'а Уберите provider и вызовите `FrameHud.install(application)` из `Application.onCreate()`. Панель появляется на следующей activity, которая выйдет на экран, поэтому вызов позже пропустит уже открытый экран. ```xml ```

Пример

./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, поэтому получает новые цифры, никого не ломая. Всё, что уходит, сначала помечается @Deprecated минимум на один минорный релиз, с указанием замены, и исчезает в следующей мажорной версии.

Не планируется