Skip to content

Latest commit

 

History

History
832 lines (645 loc) · 62.1 KB

guide.ru.md

File metadata and controls

832 lines (645 loc) · 62.1 KB

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

English · Русский

Всё, что FrameHUD умеет, когда зависимость уже добавлена.

Сбор без панели

Сборка, которая должна отчитываться, но не рисовать, берёт framehud-metrics вместо framehud. Он собирает те же числа и шлёт те же события, но не добавляет ни окна, ни SYSTEM_ALERT_WINDOW в объединённый манифест.

qaImplementation("com.timkrest:framehud-metrics:0.20.0")

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, считая текущий. Ноль не пишет файла.
nameScreensByFragment true Называет экран по фрагменту на нём, а не по activity.

show(), hide() и toggle() — сокращения для enabled.

config, screen, mark и context задаются на main thread, в другом потоке сеттер бросает IllegalStateException. Читать их можно из любого потока. Имя, нарушающее правило трейса, бросает IllegalArgumentException. Бросает только сборка, которая собирает метрики: framehud-noop принимает всё.

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, которая показывает фрагменты, получает имя фрагмента на экране, любая другая — имя своего класса.

Navigation больше ничего не нужно. С app:defaultNavHost="true" её хост становится primary navigation fragment у activity и переносит свой primary на каждый показанный destination, так что single-activity-приложение получает по экрану на destination. FrameHUD идёт по этой цепочке primary navigation fragment до конца и смотрит заново всякий раз, когда фрагмент переходит в resumed. Приложение, которое меняет фрагменты само, отмечает показанный через setPrimaryNavigationFragment в той транзакции, которая его резюмит; hide и show оставляют resumed все фрагменты, так что такую смену FrameHUD не увидит, и экраны приложение называет само. Приложение без androidx.fragment остаётся с именами activity и не получает новой зависимости, а nameScreensByFragment = false в конфиге оставляет по экрану на activity и там, где фрагменты есть.

У single-activity-приложения на Compose фрагментов нет, и оно остаётся одним экраном на всю сессию, пока не назовёт экраны само. Назовите их роутом:

navController.addOnDestinationChangedListener { _, destination, _ ->
    FrameHud.screen = destination.route
}

Используйте шаблон product/{id}, а не product/12345, чтобы каждая карточка товара считалась одним экраном. Новое имя закрывает статистику предыдущего экрана и начинает следующий. Имя действует до следующего присваивания, поэтому приложение, которое называет экраны, должно называть каждый показанный экран. null возвращает имена по классам фрагментов и activity. Имя должно отличаться от других в трейсе; метка следует тому же правилу. Type-safe маршрут читается как полное имя его класса вместе с пакетом, так что пакет стоит отрезать, если имя может выйти за 110 символов.

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

-keepnames class * extends androidx.fragment.app.Fragment

Диалоги и второй дисплей

Диалог, 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: забытая метка продолжит забирать кадры уже после взаимодействия, а отменённый на полпути скролл до следующей строки просто не дойдёт.

Скролл пальцем обернуть нечем. framehud-compose ставит метку на список, пока тот скроллится:

implementation("com.timkrest:framehud-compose:0.20.0")
val listState = rememberLazyListState()
MarkWhileScrolling(listState, name = "feed")

Подходит любой ScrollableState, а метку, выставленную в другом месте, пока список стоит, не трогает. Ему нужен Compose 1.7 или новее, а FrameHUD он берёт тот, что уже есть в варианте сборки, поэтому release-сборке, которая его вызывает, нужен framehud-noop.

Кадры, отрисованные при выставленной метке, относятся к ней. В шапке вместо таймингов появляется ▸ 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.20.0")

Сборка с ним отвечает на броадкасты, так что 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>
adb shell am broadcast -a com.timkrest.framehud.HISTORY <package>

DISABLE и RESET дополняют набор. Без --es name экран или метка сбрасываются, CONTEXT без extras очищает контекст. Имя, которое FrameHUD отвергает, получает в ответ failed: и оставляет прежнее на месте, так что скрипт с пустой переменной об этом узнает. EXPORT отвечает путём к отчёту в результате броадкаста, а BASELINE — путём к обновлённому базлайну, так что скрипт забирает тот каталог, который выбрало устройство. RETAIN просит flight recorder сохранить трейс. HISTORY отвечает путём к прошлым прогонам, когда уже дописал, чем они кончились, вместе со стеком главного потока у ANR.

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.3f,
        IntervalId.Mark("scroll") to 8.3f,
    ),
)

Бюджет распространяется на всё, что лежит внутри интервала. IntervalId.Session — на все экраны, экран — на метки, сделанные на нём. Побеждает запись, лежащая глубже. Бюджету следует всё, что считает интервал: jank-процент, потерянное время, самая длинная серия janky-кадров, открытые инциденты и база, с которой его сравнивают.

Пока такой экран в фокусе, панель следует тому же бюджету: число в шапке и линия поперёк спарклайна. IntervalReport.frameBudgetMs говорит, каким бюджетом судили строку, и оба отчёта его печатают: это среднее бюджетов, по которым судили интервал, 16.7 на 60 Гц. Интервал, у которого ни один бюджет с точностью до миллисекунды не судил 95% кадров, не называет никакого.

Куда ушло время

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, для приложения, которое читает числа без панели.

В Compose framehud-compose считает, сколько раз composable компонуется. Добавьте одну строку в начало того, который подозреваете:

@Composable
fun ProductRow(product: Product) {
    CountCompositions("product rows")
    // ...
}

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

Достоверность замера

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 достаёт его так же, как экспорты; когда файл не прочитать, он бросает исключение, а не отвечает, что прогонов не было.

На API 30+ прогон ещё и говорит, как закончился его процесс. exit несёт причину от системы — ANR, падение или освобождение памяти — с её описанием, а также экран и метку, которые были перед пользователем в этот момент. Эти два значения FrameHUD оставляет системе по мере смены через setProcessStateSummary, а система возвращает их, когда процесса уже нет, так что выход попадает в прогон, когда историю читает следующий процесс, и записывается обратно в файл. Такая сводка у системы одна на процесс: приложение, которое хранит там своё, теряет это, пока прогоны хранятся. У процесса, закончившегося в фоне, экрана нет, а у прогона, убитого до первого ухода с переднего плана, нет и записи, к которой выход можно привязать.

У ANR есть ещё mainThreadStack: кадры главного потока из дампа, который сняла система, сверху вниз, вместе с блокировкой, которую поток ждал, если такая была.

lifecycleScope.launch {
    val exit = FrameHud.history().firstOrNull()?.exit ?: return@launch
    if (exit.reason == ExitReason.ANR) {
        Log.w("app", "ANR на ${exit.screen} в ${exit.mainThreadStack.firstOrNull()}")
    }
}

Это не базлайн. Базлайн — одна усреднённая цифра на интервал, и он отвечает, хуже ли этот прогон обычного. История — сами прогоны, каждый со временем записи.

Сравнение с прошлыми прогонами

Фиксированный порог, подобранный под одно устройство, флачит на другом, и обычно именно поэтому 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.20.0")
@get:Rule val noJank = DetectJankAfterTestSuccess(JankThresholds(maxJankPercent = 2f))

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

Без аргументов правило допускает 5% jank и ни одного замороженного кадра. Тест, который не нарисовал ни кадра, падает с no frames were collected, поэтому в классе, где UI-тесты перемешаны с другими, остальные помечают @SkipJankDetection.

Порог, чью цифру портит проблема достоверности, не может честно ни пройти, ни упасть, поэтому гейт считает прогон inconclusive и пишет и саму цифру, и проблему. По умолчанию OnInconclusive.FAIL валит тест, OnInconclusive.WARN пишет сообщение в лог и пропускает, а OnInconclusive.SKIP помечает тест пропущенным — обычно это то, что нужно на общем устройстве в CI: прогон, который машина была слишком занята измерить, ничего не блокирует, а регрессия не выдаётся за чистый прогон. Нарушенный порог с чистой цифрой падает в любом режиме.

Порог может быть не фиксированным, а относительным. BaselineThresholds роняет тест, когда цифра вырастает больше чем на заданную долю базлайна этого устройства:

@get:Rule val noJank = DetectJankAfterTestSuccess(JankThresholds.baselineOnly())

По умолчанию он роняет тест при росте p95, доли jank или потерянного времени на кадр на 10%. baselineOnly выключает фиксированные пороги и оставляет сравнение. Если передать baseline в JankThresholds самому, останется и то и другое, и тест должен пройти ещё и по фиксированным порогам.

Рост меньше половины миллисекунды или половины процентного пункта считается шумом и не роняет ничего; потерянное время считается на кадр, и его граница — десятая доля миллисекунды. Пока базлайна нет, проверка пишет в лог и пропускает, а базлайн с другого устройства даёт inconclusive, как и испорченная цифра.

Выбранная метрика, которую сравнить не удалось, тоже даёт inconclusive, а не пропуск по остальным. В сообщении сказано почему: этот прогон измерил её с проблемой достоверности либо два прогона судили разные бюджеты кадра.

Чтобы отключить проверку, пометьте тест или класс @SkipJankDetection либо вызовите JankAssertions.assertNoJank("scroll") в нужный момент сами.

Гейт сбрасывает сборщик, а FrameHudResetRule вдобавок очищает то, что живёт дольше теста: экран, метку, контекст и подменённый базлайн. Иначе набор тестов, который что-то из этого выставил, начнёт следующий тест с прежними значениями. Правило пишет main-thread свойства на main thread, из какого бы потока ни шёл тест.

@get:Rule val resetFrameHud = FrameHudResetRule()

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

Если что-то не так

  • Панели нет. enabled выключен, или код работает во втором процессе: FrameHUD стартует из ContentProvider, а Android создаёт его только в главном процессе. Без разрешения на оверлей панель живёт в окне приложения и поднимается заново на каждой activity, см. Разрешение на оверлей
  • Цифры стоят на месте. Ничего не рисуется: статичный экран не даёт кадров
  • В строке gpu стоит n/a. Устройство или драйвер не отдают время GPU, см. GPU
  • Строки рендера серые, в шапке EMU. Эмулятор меряет GPU хоста, см. На эмуляторе
  • Экраны-фрагменты называются a или b. Их переименовал R8, см. keep-правило в Именах экранов
  • history() пустой. keptRuns по умолчанию 0, а текущего прогона в нём не бывает никогда
  • exit равен null. API ниже 30, или прогон убили до того, как приложение первый раз ушло в фон
  • Jank-гейт пишет, что кадров не собрано. Тест ничего не нарисовал, пока правило следило

Разрешение на оверлей

С выданным SYSTEM_ALERT_WINDOW панель живёт в системном окне и переживает переходы между экранами. Без него окно принадлежит текущей activity, поэтому на каждом переходе панель поднимается заново, а рядом появляется кнопка ⧉, открывающая экран разрешения.

Сама библиотека его не открывает. Поставьте overlayMode = APP_WINDOW, чтобы остаться в окне приложения и убрать эту кнопку.

Установка вручную вместо provider'а

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

<provider
    android:name="com.timkrest.framehud.FrameHudInstaller"
    android:authorities="${applicationId}.framehud-installer"
    tools:node="remove" />

Пример

./gradlew :sample:installDebug

Три вкладки поверх одного прогона.

Load — список из 300 строк и семь переключателей: блокировка main thread, заморозка его дольше 700 мс раз в несколько секунд, overdraw, аллокации на строку, вложенные layout'ы, поток мусора, фоновое декодирование. Каждый двигает свою метрику, а выбранный набор едет вместе с каждым событием и каждым инцидентом как контекст замера. Переключатель над списком судит кадры по бюджету вместо дедлайна дисплея: 16 мс на сессию и 8.3 мс, пока список скроллится. Скролл размечен меткой. Строка открывается как экран с именем row/{index}, который сообщает о готовности, когда его данные загрузились.

Readouts — всё, что FrameHUD измеряет, прочитанное из StateFlow, а не с панели: скользящее окно, фазы, сессия, диагноз, память, троттлинг, здоровье процесса, счётчики. Два счётчика ведёт само приложение: rows composed растёт, пока список рекомпозится, а decode queue следит за фоновой очередью, пока включён её переключатель.

Session — то, чем заканчивается прогон QA. Здесь интервалы с бюджетом, по которому судили каждый, экраны от худшего к лучшему и инциденты с показаниями того момента, когда они сработали. Можно сохранить базлайн и сравниться с ним, включить хранение прошлых прогонов и посмотреть, как шли последние, заморозить показания, включить и выключить сбор, отправить отчёт, отдать на замер окно диалога или включить flight recorder и попросить трейс Perfetto сохранить накопленное.

Версионирование

До 1.0 публичный API может измениться в минорном релизе; changelog говорит, что писать вместо старого.

1.0 его фиксирует. Внутри мажорной версии бинарная совместимость держится, кроме двух случаев, которые минорный релиз всё же может себе позволить. Оба ловит компилятор, и оба лечатся пересборкой на новой версии:

  • Входной тип, который вы строите сами, получает поле, и это меняет сигнатуру его конструктора и copy: FrameHudConfig получает настройку, либо растут Baseline, BaselineEnvironment или IntervalId. Строить их и есть смысл, поэтому они остаются публичными и растут вместе с ним.
  • Запечатанный тип обзаводится наследником: новым событием, новой помехой достоверности, новой причиной jank. when по нему без else перестаёт компилироваться.

Всё, что отдаёт FrameHUD: статистика, события, причины jank, проблемы достоверности и сравнения, — не имеет ни публичного конструктора, ни copy, поэтому получает новые цифры, никого не ломая. Тест, которому такое нужно, строит его фабрикой of под @OptIn(InternalFrameHudApi::class), а она ничего не обещает. Всё, что уходит, сначала помечается @Deprecated минимум на один минорный релиз, с указанием замены, и исчезает в следующей мажорной версии.

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

  • Панель в release-сборках. Это инструмент отладки, а framehud-noop существует ровно для того, чтобы release-код без него компилировался.
  • FPS в уведомлении. Приложение в фоне не рисует кадров — показывать нечего. Когда панель закрывает то, что вы проверяете, её можно тапом сжать до одной строки или читать logcat.
  • Отправка чего-либо куда-либо. Замеры остаются на устройстве.
  • Запуск процесса и production-агрегаты. FrameHUD меряет экран, который перед вами, включая его первый и полезный кадр; холодный старт — это Macrobenchmark, а установленная база — Play Vitals и Firebase Performance.
  • Собственный системный профайлер или просмотрщик trace. FrameHUD оставляет метки и может дать сигнал, но записью и разбором занимается Perfetto.
  • Рендеринг вне конвейера View и Canvas. FrameMetrics ничего не сообщает про Vulkan, OpenGL и игровые движки — значит, не сообщает и FrameHUD.
  • Gradle-плагин для jank-гейта. Он развёл бы debugImplementation и releaseImplementation по вариантам и сэкономил строку @get:Rule на модуль, но угнаться за версиями AGP дороже, чем то и другое вместе.
  • Другие платформы. Фазы кадра берутся из FrameMetrics, а это Android и ничего больше.