26 KiB
26 KiB
Оптимизация интерфейса на слабом 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. Главное
- На этом ТВ узкое место — GPU, а не CPU и не Compose. Производитель выключил частичную перерисовку (
debug.hwui.render_dirty_regions=false): каждый кадр GPU рисует весь экран 1920×1080 целиком. Каждый видимый элемент платит на каждом кадре, даже неподвижный. - GPU очень слаб по заполнению и смешиванию.
- Сплошная заливка почти бесплатна.
- Полупрозрачный слой на весь экран — 5–13 мс.
- Любая текстура на весь экран (даже непрозрачная и запечённая) — ~12 мс.
- Большой градиент — ~8 мс.
- Сотни тонких линий по 1 px — ~9 мс.
- Значит, всё сложное нужно рисовать один раз в bitmap (углы, затемнения, свечения, иконки, составные картинки), а на кадре — только простые непрозрачные bitmap и сплошные прямоугольники.
- Любая непрерывная анимация = перерисовка всего экрана 60 раз в секунду. Бесконечные анимации делать ступенчатыми (~8 кадров/с) или не делать. Фокус — только масштаб и прозрачность слоя.
- Судить о скорости только по release-сборке (R8 + baseline profile). Debug в разы медленнее и вводит в заблуждение.
- Сам ТВ «плавает»: тот же экран даёт то 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, а стеки и теги логов остаются читаемыми), подпись debug-ключом,
profileinstaller. - После установки — сразу компиляция по baseline profile:
Иначе ТВ скомпилирует профиль только при фоновом обслуживании, и первые замеры будут на JIT.
adb shell am broadcast -a androidx.profileinstaller.action.INSTALL_PROFILE <pkg>/androidx.profileinstaller.ProfileInstallReceiver adb shell cmd package compile -f -m speed-profile <pkg> - Контрольная точка: главная должна давать ~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, на размер + радиус); - пунктирные рамки, карта сезонов — всё, что состоит из множества мелких примитивов.
- скругления и затемнение под текстом у картинок (Coil
- Движущееся поверх запечённого — отдельным слоем (
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. Порядок действий, если экран тормозит
- Собрать release, установить с компиляцией по профилю, проверить контрольную точку (главная ~7 мс). Если хуже — перезагрузить ТВ.
ui_benchна проблемном действии, посмотреть фазы:- большой
gpu→ слишком много заливки (полноэкранные слои, текстуры, градиенты, тонкие примитивы); - большой
record draw/traversal→ лишняя композиция или раскладка (ленивый список, смена содержимого во время анимации); - большой
sync→ загрузка текстур (перепекание bitmap, новые картинки).
- большой
- Выключать элементы по одному (флаг через
setprop), сравнивать вперемешку, найти дорогой. - Заменить на запечённый bitmap, сплошную заливку, слой или ступенчатую анимацию.
- Если упрощение заметно меняет дизайн — согласовать с автором дизайна, показав варианты.