Когда эта статья пригодится

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

В этот момент очень хочется найти одну плохую строку. Вот здесь сортировка, здесь забыли remember в Jetpack Compose, а тут надо перенести работу на Dispatchers.Default. Иногда это правда помогает. Но у больших экранов причина часто шире: экран делает слишком много видимых шагов, пока пользователь ждет первый нормальный кадр.

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

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

Тимур Боргалинов

Андроид разработчик в платформенной команде Uzum Market, пропагандирую data‑oriented и business‑oriented решения

Если уже читали предыдущую статью про оптимизацию изображений в Android, новая статья станет отличным продолжением

Будем идти по простой схеме:

  1. из-за чего экран начинает дергаться

  2. почему это происходит

  3. как сделать плавнее

Плавность - это не средняя скорость

У кадра есть бюджет. Если экран работает

1) на 60 Гц, у нас примерно 16 мс на кадр.

2) на 90 Гц - около 11 мс.

3) на 120 Гц - около 8 мс.

В этот небольшой промежуток должны поместиться:

  • обработка пользовательского ввода;

  • обновление состояния;

  • повторная сборка в Compose;

  • измерение и раскладка;

  • отрисовка;

  • работа RenderThread;

  • ответы загрузчика картинок;

  • сборка мусора;

  • ожидания от системы;

  • анимации;

  • наша аналитика и трассировка.

Там же всего пара миллисекунд, но на 120 Гц весь кадр живет около восьми.

Пара миллисекунд там уже сидит не в последнем ряду.

Поэтому ускорение одного метода само по себе ничего не доказывает. Метод мог стать быстрее, но его результат теперь приходит в самый неудобный момент - прямо перед первым кадром или во время прокрутки.

Смотрите не только на среднее время. Смотрите на рывки, долгие кадры, зависшие кадры, p95/p99, количество кадров, длительность сессии и тип устройства. Особенно важно проверять слабые устройства. Быстрый телефон часто просто скрывает проблему.

Главная ошибка - смотреть на метод, а не на путь до кадра

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

Для сложного экрана полезно разложить всё по шагам:

  1. Пользователь открыл экран, обновил список или переключил режим.

  2. ViewModel начала работу.

  3. Данные пришли из сети, базы или кэша.

  4. Данные преобразовались в модели для экрана.

  5. Одно или несколько хранилищ состояния опубликовали результат.

  6. Compose прочитал состояние.

  7. Экран пересобрался, измерился и нарисовался.

  8. Картинки, предзагрузка, аналитика и модальные окна начали свою работу.

  9. Метрики записали кадры и рывки.

Вопрос на ревью: после одного успешного ответа экран получает одно новое состояние или маленький сериал на пять серий?

Если смотреть только на пункт 4, легко пропустить настоящую причину. Например, после одного ответа сети экран может получить не одно новое состояние, а пять.

Неудачный вариант

viewModelScope.launch {
    val response = repository.loadScreenData()

    state.update { it.copy(isLoading = false, items = emptyList()) }

    childStore.clear()
    childStore.publishItems(response.items) // внутри еще есть асинхронная подготовка и публикация

    snapshotFlow { childStore.count.value }
        .first { it >= response.items.size }

    val rows = response.sections.map { it.toUiRow() }
    visibleRows.clear()
    visibleRows.addAll(rows)

    state.update { it.copy(items = rows) }
}

Что здесь не так:

  • после успешной загрузки пользователь может увидеть пустой список;

  • дочернее состояние и основной список обновляются отдельно;

  • snapshotFlow используется как способ дождаться другой части логики;

  • экран получает несколько поводов перестроиться;

  • слабое устройство платит за каждый такой шаг кадрами.

Если после одного ответа сети экран переживает больше событий, чем ежедневный стендап, он почти наверняка где-то моргнет. Пользователь заметит это быстрее, чем нам хотелось бы.

Как сделать ровнее

viewModelScope.launch {
    val response = repository.loadScreenData()

    val screenData = withContext(dispatchers.default) {
        screenMapper.map(response)
    }

    childStore.replaceAndAwait(screenData.childItems)

    state.update {
        it.copy(
            isLoading = false,
            isRefreshing = false,
            primaryItems = screenData.primaryItems,
            secondaryItems = screenData.secondaryItems,
        )
    }

    scheduleWorkAfterFirstContent(screenData)
}

Здесь важна не форма API. Важен смысл: тяжелая подготовка данных уходит с главного потока, состояние другой части экрана можно дождаться явным методом, а экран получает одно полное состояние.

Показать хоть что-то сразу - не всегда быстрее

Иногда частичная загрузка полезна. Например, если пользователь уже может читать текст или нажимать на кнопку.

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

Для тяжелого экрана часто лучше так:

сеть
  -> разбор ответа
  -> преобразование данных вне главного потока
  -> одно цельное состояние экрана
  -> первый стабильный кадр
  -> второстепенная работа

А вот так стоит делать только если каждое состояние действительно нужно:

Loading
  -> Success(только заголовок)
  -> Success(пустой контент)
  -> Success(первый блок)
  -> Success(список без медиа)
  -> Success(список с зависимым состоянием)
  -> модальное окно
  -> фоновая предзагрузка

Простой вопрос для проверки: пользователь может сделать что-то полезное с этим промежуточным состоянием? Если нет, возможно, ему не нужно попадать на экран.

Экрану не нужен драматичный сериал из состояний. Ему нужен первый кадр, который не стыдно оставить на паузе

Не делайте тяжелую работу в функции отрисовки

Composable-функция - не обычный метод отрисовки. Она может вызываться часто.

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

Быстрый тест: если эту строку неприятно запускать тысячу раз подряд, ей не место в часто вызываемой функции отрисовки.

Неудачно

@Composable
fun ScreenContent(state: ScreenState) {
    val items = state.rawItems
        .filter { it.isVisible }
        .sortedByDescending { it.score }
        .map { it.toUiItem() }
        .toPersistentList()

    LazyVerticalGrid(columns = GridCells.Fixed(2)) {
        items(items) { item ->
            UiItemCell(item)
        }
    }
}

Можно добавить remember(state.rawItems), и это станет лучше. Но для большого списка сама идея все равно подозрительная: экран занимается подготовкой данных вместо того, чтобы показывать готовые модели.

Лучше

viewModelScope.launch {
    val items = withContext(dispatchers.default) {
        rawItems
            .filter { it.isVisible }
            .sortedByDescending { it.score }
            .map { it.toUiItem() }
            .toPersistentList()
    }

    state.update { it.copy(items = items) }
}

А функция экрана пусть в основном рисует:

@Composable
fun ScreenContent(
    state: ScreenState,
    onEvent: (ScreenEvent) -> Unit,
) {
    LazyVerticalGrid(columns = GridCells.Fixed(2)) {
        items(
            items = state.items,
            key = { it.composeId },
            contentType = { UiItemModel::class },
        ) { item ->
            UiItemCell(
                item = item,
                onClick = { onEvent(ScreenEvent.ItemClick(item.id)) },
            )
        }
    }
}

Стабильные модели важнее красивой аннотации

@Immutable - это обещание компилятору. Если внутри модели есть изменяемый список, var или объект, который может поменяться незаметно, аннотация не спасает. Она может даже навредить, потому что код начинает выглядеть безопаснее, чем он есть.

Хорошая база для Compose:

  • поля состояния через val;

  • отдельные UI-модели на границе экрана;

  • неизменяемые коллекции для состояния;

  • преобразование коллекций до попадания в Compose;

  • передача в дочернюю функцию только нужного куска состояния.

Плохой признак:

@Immutable
data class ScreenState(
    val items: List<UiItem>,
)

Лучше использовать тип коллекции, который явно показывает неизменяемость:

@Immutable
data class ScreenState(
    val items: PersistentList<UiItem>,
)

Ключи списка должны быть простыми и уникальными

Для LazyColumn, LazyRow и LazyVerticalGrid ключи нужны не для красоты. Они помогают Compose понимать, что элемент остался тем же самым, даже если список поменялся.

Скучный ключ - это комплимент разработчику. Ключу не надо быть умным, красивым или неожиданным. Он должен следовать одному правилу - повторяться тогда, когда элемент тот же самый.

Плохие ключи:

itemsIndexed(uiItems, key = { index, _ -> index }) { _, item -> ... }
val composeId = UUID.randomUUID().toString()

Индекс ломается при вставках, удалениях и сортировке. Случайный ключ еще хуже: при каждом пересоздании данных Compose видит новый элемент. Теряется переиспользование, может прыгать позиция прокрутки, заново создаются внутренние состояния.

Хороший ключ скучный и повторяемый:

val composeId = "item_${itemId}_${duplicateOrdinal}"

или:

val composeId = "section_${sectionPosition}_${sectionId}"

Если сервер может вернуть одинаковые идентификаторы, не надо лечить это случайным UUID. Лучше добавить стабильный порядковый номер дубликата или позицию секции.

И не забывайте про contentType:

items(
    items = screenItems,
    key = { it.composeId },
    contentType = { it.contentType },
) { item ->
    ScreenItem(item)
}

Для смешанного списка это помогает Compose переиспользовать подходящие элементы.

Корутины: переносить работу надо осознанно

Корутины легко создают ощущение, что работа ушла с главного потока, а значит все хорошо. На деле важны три вопроса:

  1. Кто выбирает поток?

  2. Можно ли отменить работу?

  3. Не запускаем ли мы слишком много задач одновременно?

Корутина не делает работу бесплатной. Она просто переносит разговор в другое место: где выполняем, сколько запускаем и кто это потом остановит.

Хороший suspend-метод должен быть безопасен для вызова с главного потока. Если внутри блокирующая работа с диском или сетью - метод сам уходит на IO. Если тяжелое преобразование - оно уходит на Default. Вызывающий код не должен гадать.

Базовые правила:

  • вычисления, сортировки, группировки, подготовка списка - Dispatchers.Default;

  • диск, база, сеть, блокирующие SDK - Dispatchers.IO;

  • публикация состояния экрана - главный поток;

  • долгие циклы должны уметь отменяться;

  • не надо запускать корутину на каждый элемент большого списка без ограничения.

Неудачный разлет задач

val mappedItems = rawItems.map { rawItem ->
    async(dispatchers.default) {
        rawItem.toUiItem()
    }
}.awaitAll()

Для маленького списка это может быть нормально. Для сотен элементов во время первого показа экрана - уже риск. Такие задачи конкурируют с Compose, загрузкой картинок и другой фоновой работой. Часто одна пачка работы на Default проще и предсказуемее.

Здесь легко обмануться: код выглядит очень современно, а устройство слышит только одно - ему выдали много работы разом.

Flow - не труба, куда можно бездумно складывать операторы

У Flow есть несколько свойств, которые напрямую влияют на плавность:

  • холодный Flow запускает работу заново для каждого сборщика;

  • flowOn влияет только на то, что стоит выше него;

  • flowOn может добавить новую корутину и буфер;

  • buffer, conflate, collectLatest меняют поведение при наплыве событий.

Добавим еще один flowOn, вдруг станет быстрее.

А вдруг станет непонятнее. В производительности это почти всегда первый шаг к долгому вечеру.

Неудачный код:

repository.itemsFlow()
    .map { it.map(RawItem::toUi) }
    .flowOn(dispatchers.default)
    .map { ScreenState.Success(it) }
    .flowOn(dispatchers.default)
    .collect { state.value = it }

Второй flowOn здесь как минимум требует объяснения. Часто он не ускоряет код, а только делает границы работы менее понятными.

Более понятный вариант:

repository.itemsFlow()
    .map { rawItems ->
        rawItems.map(RawItem::toUi)
    }
    .flowOn(dispatchers.default)
    .distinctUntilChanged()
    .collect { items ->
        state.update { it.copy(items = items) }
    }

Если один и тот же поток данных нужен нескольким потребителям, подумайте о stateIn или shareIn. Иначе дорогая работа может запускаться несколько раз.

snapshotFlow - не способ ждать другую часть экрана

snapshotFlow полезен, когда надо наблюдать Compose state. Но он плохо подходит как способ дождаться завершения другой части системы.

Хороший вопрос: мы наблюдаем UI-состояние или тайно строим на нем договоренность между слоями?

Неудачный смысл:

store.publishItems(items)
snapshotFlow { store.count.value }.first { it == items.size }
publishRows(rows)

Такой код делает Compose state частью бизнес-логики. Сегодня он работает. Завтра внутри store поменяли порядок публикации, добавили задержку или промежуточное состояние - и синхронизация снова хрупкая.

Лучше явный метод:

store.replaceItemsAndAwait(items)
publishRows(rows)

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

Картинки и предзагрузка легко съедают кадры

Картинки часто объясняют зависшие кадры лучше, чем сложная подготовка данных.

Сжатый файл может быть маленьким в сети, но после декодирования стать большим объектом в памяти.

Если картинка показывается в маленьком блоке, не стоит декодировать ее как полноэкранную. Это лишняя работа для CPU, лишняя память и лишнее давление на кэш.

Картинка не обязана быть огромной, чтобы стать дорогой. Достаточно попросить ее не в тот размер и не в тот момент.

Предзагрузка полезна только когда понятно, что именно и когда понадобится.

Хорошая предзагрузка:

  • следующий шаг пользователя достаточно вероятен;

  • параметры уже известны;

  • ключ кэша совпадает с обычной загрузкой;

  • работа ограничена для слабых устройств;

  • работу можно отменить при обновлении, смене режима или уходе со страницы;

  • она не мешает первому кадру и активной прокрутке.

Плохая предзагрузка:

  • стартует во время первого построения большого списка;

  • пытается загрузить все подряд;

  • не отменяется;

  • грузит другой размер, чем реально нужен на экране;

  • одинаково агрессивна на слабом и мощном устройстве.

Анимация может быть дешевой, а может ломать раскладку

Не все анимации одинаковы. Если меняется прозрачность или сдвиг через graphicsLayer, часто это дешевле. Если меняется размер, отступы или ограничения, список может заново измеряться.

Обычно спокойнее:

  • alpha;

  • translation;

  • scale;

  • graphicsLayer;

  • изменения на этапе рисования.

Опаснее:

  • менять размер каждого элемента списка;

  • оборачивать тяжелое дерево в AnimatedVisibility;

  • запускать бесконечную анимацию в десятках видимых элементов;

  • держать автопрокрутку, когда блок уже не виден;

  • выполнять бизнес-логику на каждом кадре анимации.

Если анимация зависит от видимости, она должна останавливаться, когда элемент ушел с экрана.

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

Метрики тоже могут мешать

JankStats, Perfetto, собственные метки, аналитика помогают искать проблемы. Но это тоже код. Если добавить слишком много меток, слушателей и событий, измерение само начнет портить экран.

Метрика должна помогать разбору проблемы. Если после добавления метрик разбирать приходится уже сами метрики, мы немного промахнулись.

Правила простые:

  • один экранный трекер на экран;

  • отдельный трекер только для нескольких тяжелых блоков;

  • не добавлять слушатель кадров на каждый элемент списка;

  • не писать в метки item id, поисковый текст, UUID или текущее время;

  • не трассировать каждый элемент и каждый кадр;

  • метка должна отвечать на вопрос, что именно мы потом будем исправлять.

Хорошие метки:

Screen.network.decode
Screen.domain.map
Screen.childStore.replace
Screen.state.publish
Screen.media.prefetch

Плохие:

Item_123456_bind
row_42_recompose_1718200000000
work

Perfetto особенно полезен, когда метрики показывают рывки, но причина неясна. В окне плохого кадра смотрите главный поток, RenderThread, фоновые потоки, декодирование картинок, ожидания системы, сборку мусора, частоту процессора и блокировки.

Как я бы разбирал просадку

Я обычно начинаю не с героического переписывания половины экрана, а со скучного вопроса: где именно пользователь потерял кадр? Скучный вопрос, кстати, часто экономит самый нескучный день.

  1. Понять симптом: долгий первый кадр, долгий кадр с данными, рывки при прокрутке, зависшие кадры, риск ANR, лишние аллокации.

  2. Разделить устройства по уровню. Плавность на флагмане не доказывает, что экран хорошо работает на слабом устройстве.

  3. Восстановить путь от события до первого стабильного кадра.

  4. Найти промежуточные состояния: Loading -> Success(empty) -> Success(partial) -> Success(final).

  5. Проверить работу на главном потоке: сортировки, форматирование, JSON, DI/service lookup, доступ к ресурсам.

  6. Проверить границы Compose: неизменяемое состояние, стабильные ключи, contentType, маленькие куски состояния.

  7. Проверить корутины и Flow: flowOn, несколько сборщиков, слишком много async, отмена работы.

  8. Проверить второстепенную работу после загрузки: предзагрузка, модальные эффекты, аналитика, показы элементов.

  9. Проверить, не мешает ли сама трассировка.

  10. Подтвердить гипотезу через Perfetto, JankStats, Macrobenchmark или отчеты компилятора Compose.

Мини-чеклист для проверки кода

Перед тем как принять изменение в сложном экране, спросите:

Этот список хорошо работает как разговор с самим собой перед мерджем. Если на половине пунктов хочется ответить потом посмотрим, значит уже есть что посмотреть сейчас.

  • Нет ли тяжелой работы в функции отрисовки, эффекте, обработчике клика или корутине на главном потоке?

  • Не публикуем ли мы несколько видимых состояний после одного успешного ответа?

  • Не используем ли snapshotFlow как способ дождаться другой части логики?

  • Нет ли clear() видимого списка перед заменой данными?

  • Стабильны ли ключи списка? Нет ли UUID, времени или голого индекса?

  • Есть ли contentType у смешанного списка?

  • Не форматируются ли подписи, даты или сложный текст внутри элемента списка?

  • Не пересобираются ли карты для аналитики на каждый пиксель прокрутки?

  • Не стартует ли предзагрузка во время первого построения большого списка?

  • Можно ли отменить работу при обновлении, смене режима или уходе со страницы?

  • Не создаем ли корутину на каждый элемент без ограничения?

  • Не добавили ли трассировку или аналитику на каждый элемент?

  • Проверяли ли на слабом устройстве или хотя бы в Perfetto/Macrobenchmark?

Если коротко

  • Оптимизируйте путь до кадра, а не отдельный метод.

  • Для первого показа большого списка часто лучше одно цельное состояние, чем несколько почти бесполезных промежуточных.

  • Тяжелое преобразование данных должно происходить до Compose.

  • Состояние экрана должно быть неизменяемым снимком.

  • Ключи списка должны быть стабильными и повторяемыми.

  • snapshotFlow не должен быть способом синхронизировать слои.

  • Предзагрузка и аналитика должны начинаться после важного первого кадра, иметь лимиты и отменяться.

  • Метки трассировки должны помогать разбору проблемы, а не создавать шум.

  • Улучшение производительности надо подтверждать измерением, а не ощущением на мощном телефоне.

Спасибо за внимание, по вопросам можете писать в телеграм @timkabor

Комментарии (0)