# Оптимизация интерфейса на слабом 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`](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 /androidx.profileinstaller.ProfileInstallReceiver adb shell cmd package compile -f -m speed-profile ``` Иначе ТВ скомпилирует профиль только при фоновом обслуживании, и первые замеры будут на 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. Если упрощение заметно меняет дизайн — согласовать с автором дизайна, показав варианты.