Files
SleppyOwl/docs/ui-performance.md
StenFredd 3ef78bd49c
All checks were successful
release / release (push) Successful in 12m40s
Add CI
2026-10-27 17:38:55 +03:00

26 KiB
Raw Permalink Blame History

Оптимизация интерфейса на слабом Android TV: что тормозило, как мерили, какие приёмы работают

Интерфейс — Jetpack Compose (без Leanback, без tv-foundation lazy-списков) на Xiaomi TV S Pro Mini LED 75: MediaTek MT9689, слабый Mali GPU, 32-bit, 2,4 ГБ RAM. Требование: плавно на 60 Гц на этом ТВ и на более слабых приставках. Работа шла 2026-10-14 по 9 этапам интерфейса. План интерфейса и правила производительности — tv-app.md.


0. Главное

  1. На этом ТВ узкое место — GPU, а не CPU и не Compose. Производитель выключил частичную перерисовку (debug.hwui.render_dirty_regions=false): каждый кадр GPU рисует весь экран 1920×1080 целиком. Каждый видимый элемент платит на каждом кадре, даже неподвижный.
  2. GPU очень слаб по заполнению и смешиванию.
    • Сплошная заливка почти бесплатна.
    • Полупрозрачный слой на весь экран — 5–13 мс.
    • Любая текстура на весь экран (даже непрозрачная и запечённая) — ~12 мс.
    • Большой градиент — ~8 мс.
    • Сотни тонких линий по 1 px — ~9 мс.
  3. Значит, всё сложное нужно рисовать один раз в bitmap (углы, затемнения, свечения, иконки, составные картинки), а на кадре — только простые непрозрачные bitmap и сплошные прямоугольники.
  4. Любая непрерывная анимация = перерисовка всего экрана 60 раз в секунду. Бесконечные анимации делать ступенчатыми (~8 кадров/с) или не делать. Фокус — только масштаб и прозрачность слоя.
  5. Судить о скорости только по release-сборке (R8 + baseline profile). Debug в разы медленнее и вводит в заблуждение.
  6. Сам ТВ «плавает»: тот же экран даёт то 8, то 17 мс GPU между прогонами, а иногда весь ТВ втрое медленнее до перезагрузки. Варианты сравнивать только вперемешку, по 2–3 прогона.

Ориентир: лаунчер Google TV на том же ТВ тратит ~5 мс GPU на кадр. Наш интерфейс после оптимизации — 4–8 мс (было до 45).


1. Как измеряли

1.1 Инструменты

  • dumpsys gfxinfo <пакет> — перцентили кадра и GPU, доля «рывков» (janky frames). Перед замером dumpsys gfxinfo <пакет> reset.
  • dumpsys gfxinfo <пакет> framestats + свой скрипт tools/tv/framestats.py, который разбивает кадр по фазам (p50/p90/max):
    • input+anim — обработка ввода и анимаций;
    • traversal — measure/layout;
    • record draw — запись команд рисования (композиция Compose тоже здесь);
    • sync — загрузка текстур (bitmap → GPU);
    • issue cmds — отправка команд;
    • swap;
    • gpu — сколько GPU рисовал кадр. Главная цифра на этом ТВ.
  • ui_bench [right|down] [N] (tools/tv/env.sh): сбросить статистику → N нажатий «вперёд» и N «назад» с паузой 0,35 с → перцентили и разбивка по фазам. Один и тот же сценарий для всех сравнений.
  • am start -W — время холодного запуска.
  • Время в логах приложения: Home shown in, Home fetched in, Home snapshot read in, Series loaded in — время до показа экрана из снимка и до свежих данных.
  • Флаг-эксперимент в release без пересборки: приложение временно читает системное свойство debug.movies.x через SystemProperties (reflection), меняем его через adb shell setprop debug.movies.x ABC, и код включает или выключает варианты (фон, слой, градиент…). Флаги — заглавные однобуквенные, без пересечений: например, «none» содержит «e» и «o», и такие флаги сработают по ошибке. Так можно сравнивать варианты вперемешку на одной установке.
  • Layout Inspector и счётчики перекомпозиций — чтобы убедиться, что при прокрутке перекомпонуется только нужное.

1.2 Правила замеров

  • Только release: R8 (минификация и оптимизация, без обфускации — проект open source, а стеки и теги логов остаются читаемыми), profileinstaller.
  • После установки — сразу компиляция по baseline profile:
    adb shell am broadcast -a androidx.profileinstaller.action.INSTALL_PROFILE <pkg>/androidx.profileinstaller.ProfileInstallReceiver
    adb shell cmd package compile -f -m speed-profile <pkg>
    
    Иначе ТВ скомпилирует профиль только при фоновом обслуживании, и первые замеры будут на JIT.
  • Контрольная точка: главная должна давать ~7 мс GPU. Если она даёт 20–30, ТВ в «плохом» состоянии — перезагрузить (adb reboot), а не оптимизировать.
  • Сравнивать вперемешку A, B, A, B, по 2–3 прогона каждого. Разница между прогонами одного варианта бывает 2×.
  • Смотреть GPU p50/p90, а не только «время кадра»: в медиану кадра входит задержка конвейера (у нас ~19 мс при GPU 10 мс).

2. Что тормозило больше всего (и сколько стоило)

Все цифры — GPU на кадр, release, тестовый ТВ.

Что Стоимость Что сделали После
Главная до оптимизации (листание ряда) 45 мс всё ниже 10 мс → затем 7 мс
Фоновая сетка точек из макета (на весь экран) ~5 мс каждый кадр убрали, фон окна — сплошной цвет 0
Полупрозрачный слой на весь экран (затемнение, радиальное свечение фона, затемнение под панелью) 5–13 мс не делаем; панели непрозрачные 0
Фон страницы фильма: картинка на весь экран + затемнение + свечение постера, даже запечённые в один непрозрачный bitmap ~12 мс (кадр 19 мс вместо 7) фон только справа сверху, 70 % ширины и высоты, края запечённым градиентом уходят в цвет фона ~8 мс
Затемнения сверху и снизу в плеере (Brush.verticalGradient, 130 + 230 dp) ~8 мс (HWUI дизерит градиенты) полосы сплошного цвета по 4 dp с границами по целым пикселям, высота 105 + 170 dp 7–8 мс с OSD (было 15–20)
Тот же градиент, запечённый в bitmap и растянутый ~6 мс — (полосы дешевле)
Карта сезонов сериала: сотни рамок из тонких прямоугольников 1 px ~9 мс (17 мс вместо 8 на 28 сезонах) полоски сезона запекаются в bitmap один раз, выбранная серия — отдельный маркер, двигается graphicsLayer.translationX ~8,5 мс, как без карты вообще
Пульсирующая точка «Ищем…» (плавная бесконечная анимация) ~17 мс, 60 кадров/с всего экрана пульс ступенчатый, ~8 кадров/с 6 мс, 47 кадров за 5 с
Анимированный фокус в плеере (поверх видео) ~8 дорогих кадров на нажатие фокус без анимации (FocusStyle(animate = false)) 1 кадр на смену фокуса, ~4 кадра на нажатие вместо ~15
Смена содержимого большой области прямо во время анимации фокуса (раздел настроек, библиотека) ~15 мс каждый кадр анимации менять содержимое через 0,3 с после фокуса (быстрое листание не грузит промежуточное) ~4 мс
LazyColumn на коротком экране (сборка ряда во время прокрутки) кадры до 50 мс короткие экраны — обычная Column
Встроенная анимация bring-into-view при вертикальной прокрутке «выстреливала», настроить нельзя своя прокрутка AnchoredScrolling: 450 мс с замедлением к концу рывков ~4 % → 0–0,5 % с baseline profile
Modifier.clip по скруглённой форме у картинок + затемнение поверх дорогие слои и шейдеры углы и затемнение запекаются в bitmap при декодировании (CardImageTransformation)
drawPath (векторные иконки) в часто перерисовываемом дорого иконки растеризуются в bitmap один раз (Icon через drawWithCache), формы — скруглённые прямоугольники
Свечение фокуса из макета (размытые тени 30–120 px) blur/RenderEffect недопустимы готовая картинка: размытый прямоугольник считается один раз на размер и кэшируется (GlowCache), только у элемента в фокусе ~0
Прямоугольник с дробными координатами рисуется сглаженным, более тяжёлым шейдером границы округлять до целых пикселей
Перепекание bitmap на каждое нажатие загрузка текстуры в GPU каждый раз (фаза sync) печь один раз, менять только слой поверх
Crossfade картинок перерисовывает каждую видимую карточку всё время перехода crossfade(false)
Смена библиотеки (собираются ряды и первые ряды сетки) один кадр 55–70 мс оставлено: приходится на момент, когда ничего не движется. Если станет заметно — собирать ряды по одному в соседних кадрах

3. Приёмы, которые стоит использовать

3.1 Рисование

  • Фон окна — сплошной цвет, совпадающий с фоном приложения (windowBackground). Никаких полноэкранных текстур, сеток, виньеток, полупрозрачных слоёв.
  • Всё составное — в bitmap один раз, на Dispatchers.Default, с LruCache:
    • скругления и затемнение под текстом у картинок (Coil Transformation). Сначала обрезать под размер карточки, потом скруглять — иначе при постере не 2:3 запечённые углы обрежутся при рисовании, и поверх рамки фокуса будут торчать прямые углы;
    • плитки из нескольких картинок (мозаика постеров + заливка + затемнение + углы, TileArt). Картинки грузить через imageLoader.execute(... allowHardware(false)), чтобы с ними можно было рисовать на Canvas;
    • иконки (растеризация вектора в drawWithCache);
    • свечение фокуса (GlowCache, на размер + радиус);
    • пунктирные рамки, карта сезонов — всё, что состоит из множества мелких примитивов.
  • Движущееся поверх запечённого — отдельным слоем (graphicsLayer { translationX }), без перерисовки содержимого.
  • Большие фоновые картинки — только на части экрана (например, правый верхний угол 70 %), края запечённым градиентом уходят в цвет фона.
  • Затемнения — полосами сплошного цвета (4 dp) с целыми границами вместо Brush.verticalGradient. Пример: Modifier.verticalShade в ui/player/PlayerOsd.kt.
  • Без Modifier.clip по форме у картинок, без blur/RenderEffect, без drawPath в часто перерисовываемом.
  • Панели и шторки — непрозрачные, без затемнения экрана под ними.

3.2 Анимации и фокус

  • Анимировать только свойства слоя (масштаб, прозрачность, сдвиг) и читать значение анимации только в фазе отрисовки/слоя (graphicsLayer { }, drawBehind { }), а не при композиции. Раскладку не анимировать. Боковое меню, например, анимируется только в отрисовке, а ширина раскладки переключается разом.
  • Анимация фокуса: 150–180 мс. Поверх видео (плеер) — без анимации.
  • Бесконечные анимации (пульс, курсор) — ступенчато: курсор мигает 2 раза в секунду, пульс ~8 кадров/с.
  • Тяжёлую смену содержимого откладывать до конца анимации фокуса (пауза 0,3 с; «→» или OK — сразу).
  • Переходы между экранами пока не анимируются (Navigation 3 без анимаций, переходы отложены на этап полировки). По правилам выше анимированный переход — это несколько кадров с двумя полноэкранными слоями, его нужно мерить отдельно.

3.3 Списки и прокрутка

  • Только ленивые списки с key и contentType для длинного; короткие экраны — обычная Column.
  • Сложный экран (медиатека: плитки, ряды, заголовки, сетка) — одна LazyVerticalGrid: ряды и заголовки — элементы span = maxLineSpan, выходящие за поля сетки (bleed), постеры — её ячейки. Составляется только видимое.
  • Постраничная загрузка: страницы по 60, следующая — когда до конца остаётся 2 ряда (snapshotFlow от layoutInfo). Без подсчёта TotalRecordCount в том же запросе — число отдельным запросом.
  • Своя вертикальная прокрутка к ряду (AnchoredScrolling, animateScrollBy, 450 мс, замедление к концу) вместо встроенного bring-into-view. Для панелей — MinimalScrolling(margin): прокрутка только насколько нужно (по умолчанию на ТВ фокус держится на 30 % высоты, и панель прокручивается при первом же фокусе).
  • При смене сортировки не очищать сетку до прихода новых данных — иначе экран «прыгает».
  • UI-модели @Immutable, без DTO SDK в composable; модели карточек строятся вне главного потока.

3.4 Картинки (Coil 3)

  • Запрашивать у сервера ровно размер на экране (постер 220×330 dp → ~440×660 px): fillWidth/fillHeight/quality в Image API Jellyfin.
  • allowRgb565(true) — постеры и кадры непрозрачные, память вдвое меньше.
  • Кэш в памяти — 15 % кучи, на диске — 200 МБ.
  • crossfade(false).
  • BlurHash как заглушка: декодировать 16 px, увеличить до ¼ карточки с запечёнными углами и затемнением, рисовать одним bitmap; декодирование вне главного потока.
  • Ключ кэша — на публичном адресе сервера, а сами запросы перехватчик OkHttp переписывает на LAN-адрес.

3.5 Данные и запуск

  • Снимок главной: показ сразу из последнего показа в этом запуске или из JSON-файла с прошлого запуска, потом обновление с сервера одним параллельным заходом.
  • Кэш ответов на время сессии; одинаковые запросы в полёте объединяются; данные просмотра сбрасываются по событию.
  • Запросы к серверу — параллельно, первым то, что видно.
  • Ловушки сервера Jellyfin:
    • /Items с несколькими типами (includeItemTypes=Movie,Series) в ~10 раз медленнее одного типа (0,35–0,44 с против 0,05). Запрашивать библиотеку её собственным типом;
    • ряды «Новинки» через /Items/Latest?parentId (20–80 мс), а не /Items с сортировкой по дате и подсчётом (≈0,2 с каждый, 5 параллельных ≈1 с);
    • без fields=Width сервер не отдаёт ширину видео (значок «4K» не построить).
  • Baseline profile (модуль :baselineprofile, macrobenchmark + UiAutomator): сценарий «запуск → главная → вдоль рядов → страница фильма → медиатека → поиск → избранное → настройки → сериал». Переснимать после крупных изменений экранов (gen_profile, ~8–27 мин на ТВ). Gradle может написать «failing tests» / «BUILD FAILED», хотя профиль снят — ошибка на шаге покрытия, профиль копируется скриптом.

3.6 Плеер

  • Видео — на своей плоскости (SurfaceView), окно интерфейса рисуется только при изменениях. OSD скрыт — 0 кадров; виден — кадр раз в секунду (часы/время) и по одному на нажатие.
  • Нажатия перемотки копятся и уходят в движок одним seek через 0,6 с.
  • Если видео идёт через TextureView (каждый кадр видео перерисовывает окно), бюджет на OSD особенно мал: 6 мс GPU с OSD, 4–5 мс без.

4. Итоговые цифры (release, тестовый ТВ)

Экран / действие GPU на кадр Рывки
Главная, листание ряда 45 → ~7–10 мс ~8 % → 0–0,5 %
Страница фильма ~8 мс (p90 ~9) —
Ряд «Похожие» ~7 мс 0
Плеер с OSD 15–20 → 7–8 мс —
Сериал, лента серий 6–8,5 мс 0–5 %
Медиатека, сетка 6–7 мс ~1 %
Поиск (клавиатура, результаты) ~8 мс 0
Настройки ~4 мс 0
Время До После
Главная на экране (от создания экрана) 1,3–2,5 с 0,09–0,14 с (из снимка)
Свежие данные главной 1,3–2,5 с 0,4–1,0 с
Чтение снимка главной (130 КБ JSON) — 150 мс без профиля → 60–80 мс с профилем
Холодный запуск (am start -W) 0,35–0,6 с 0,28–0,42 с
Страница сериала (16 / 365 серий) — 0,23 / 0,65–0,7 с (сервер отдаёт ~1 МБ JSON за 0,1 с, остальное — разбор)
Содержимое библиотеки 0,4–0,65 с 0,15–0,23 с

5. Ловушки Compose на ТВ (не скорость, но всплыли при работе)

  • «Назад», которое никто не перехватил, сначала только снимает фокус, и BackHandler срабатывает со второго нажатия. В плеере «Назад» ловится в onPreviewKeyEvent и выполняется по отпусканию клавиши, иначе отпускание прилетит следующему экрану.
  • lastKey = x; requestFocus() сразу не работает: requester ещё висит на старом элементе. Нужно дождаться перекомпозиции (2 кадра).
  • Когда пропадает слой с фокусом (панель), система ставит фокус на первую кнопку экрана — для возврата фокуса держать отдельный FocusRequester.
  • focusRestorer у обычного Row без focusGroup() не восстанавливает фокус.
  • BasicTextField на ТВ «захватывает» D-pad — нужен ручной перевод фокуса. Gboard для ТВ плавает поверх нижних ~45 % экрана и сообщает нулевые IME insets: adjustPan/adjustResize не работают, экран нужно приподнимать самому (сдвигом слоя), активность — adjustNothing.
  • В тестовых скриптах между нажатиями нужны паузы ≥0,25 с: нажатие в первом кадре после появления OSD уходит в никуда. Снимок экрана на ТВ занимает ~5 с.

6. Порядок действий, если экран тормозит

  1. Собрать release, установить с компиляцией по профилю, проверить контрольную точку (главная ~7 мс). Если хуже — перезагрузить ТВ.
  2. ui_bench на проблемном действии, посмотреть фазы:
    • большой gpu → слишком много заливки (полноэкранные слои, текстуры, градиенты, тонкие примитивы);
    • большой record draw / traversal → лишняя композиция или раскладка (ленивый список, смена содержимого во время анимации);
    • большой sync → загрузка текстур (перепекание bitmap, новые картинки).
  3. Выключать элементы по одному (флаг через setprop), сравнивать вперемешку, найти дорогой.
  4. Заменить на запечённый bitmap, сплошную заливку, слой или ступенчатую анимацию.
  5. Если упрощение заметно меняет дизайн — согласовать с автором дизайна, показав варианты.