Когда я разрабатывал GhostGram — экспериментальный клиент Telegram на Kotlin Multiplatform, — со стикерами пришлось повозиться.
Сначала всё выглядело относительно просто: получить файл TGS, передать его проигрывателю и показать анимацию в интерфейсе. Но на Android обнаружились две проблемы. Некоторые стикеры воспроизводились заметно быстрее, чем в официальном Telegram, а при отображении нескольких анимаций график HWUI показывал серьёзные задержки.
Я пробовал оптимизировать существующую реализацию, затем сменил библиотеку и в итоге пришёл к другому подходу: предварительно отрисовывать кадры в Bitmap и воспроизводить их как последовательность изображений.
Расскажу, почему я выбрал этот подход, что пришлось дорабатывать после первой реализации и почему проблема быстрого скролла пока осталась открытой.
С чего всё начиналось
GhostGram использует Kotlin Multiplatform и Compose Multiplatform. Изначально для воспроизведения Lottie-анимаций я выбрал Compottie — библиотеку, позволяющую работать с анимациями в Compose Multiplatform.
TGS — это формат анимированных стикеров Telegram. В моей реализации обработка выглядела следующим образом:
-
TgsDecoderполучал файл стикера и распаковывал GZIP. -
Содержимое передавалось в виде JSON.
-
В Compose создавалась композиция через
rememberLottieCompositionиLottieCompositionSpec.JsonString. -
Воспроизведение управлялось через
animateLottieCompositionAsState. -
Для отображения использовался
rememberLottiePainter.
Для кроссплатформенного проекта это выглядело логичным решением: общий подход к анимациям позволял не писать отдельный проигрыватель для каждой платформы.
На Desktop результат выглядел нормально. А вот на Android начались проблемы.
Первые замеры
Первой бросалась в глаза скорость воспроизведения. Некоторые TGS-стикеры в GhostGram проигрывались примерно в два-три раза быстрее, чем в официальном Telegram.
Затем я открыл чат с несколькими стикерами и включил системный инструмент Profile HWUI Rendering в настройках разработчика Android.
Столбцы графика заметно превышали пороговые линии. Особенно неприятно выглядел сценарий, когда одновременно на экране находились три-четыре анимированных стикера. В первоначальных наблюдениях отдельные столбцы доходили примерно до 200-300 мс.
На этом этапе я видел проблему, но ещё не мог уверенно сказать, какая часть работы проигрывателя вносит наибольший вклад. Не хотелось просто менять параметры наугад и считать задачу решённой, если график случайно становился лучше в одном сценарии.
На чём я тестировал
Все измерения, описанные в статье, я проводил на одном устройстве:
-
Смартфон: TECNO SPARK Go 2024.
-
Модель: TECNO BG6.
-
ОС: Android 13.
-
Процессор: Unisoc T606, обозначение платформы UMS9230.
-
Оперативная память: 3 ГБ.
-
Сборка приложения: Release с включёнными R8-оптимизациями.
Я использовал Release-сборку, потому что Debug-вариант заметно сильнее тормозил и не подходил для сравнения поведения приложения в условиях, близких к пользовательским.
Для сравнения с официальным Telegram я использовал одну и ту же стенку стикеров на одном смартфоне. Это важно: я не сравнивал два разных чата со случайными наборами анимаций.
При этом мои выводы относятся именно к этому устройству и выбранному сценарию. Я не проверял результат на большом количестве смартфонов.
Отдельно стоит пояснить используемый инструмент. Profile HWUI Rendering показывает время отрисовки кадров и отдельные этапы графического конвейера. Это не прямой измеритель процента загрузки GPU. Поэтому дальше я буду говорить о высоте столбцов и поведении графика, а не о процентах загрузки графического процессора.
Документация Android: Profile GPU Rendering.
Попытки оптимизировать Compottie
Первым делом я попробовал улучшить существующую реализацию, не меняя библиотеку.
Одно из направлений — ограничить частоту воспроизведения частотой кадров, для которой подготовлена сама анимация, и сократить повторную работу при создании композиции. Например, я экспериментировал с useCompositionFrameRate и кэшированием LottieCompositionSpec вместо одной только исходной JSON-строки.
Это помогало в отдельных сценариях, но не давало нужного результата при одновременном воспроизведении нескольких стикеров. График всё ещё показывал заметные задержки.
Была и идея ограничить воспроизведение: сначала показывать статичное превью, проигрывать анимацию один раз, а затем останавливать её до действия пользователя. Но такой вариант меня не устраивал. Он не устранял стоимость рендеринга, а просто ограничивал работу ценой ухудшения поведения.
В Telegram анимированные стикеры не требуют дополнительных нажатий, чтобы продолжать воспроизводиться. Мне хотелось добиться нормального поведения без принудительных ограничений для пользователя.
Я также экспериментировал с поведением анимаций при прокрутке. В одном из вариантов стикеры замирали во время скролла и возобновлялись после остановки. Это не устраняло исходную проблему, зато добавляло новую: анимации выглядели зависшими.
Стало понятно, что нужно менять сам подход.
От смены библиотеки к Frame Baking
Следующим шагом стал переход Android-реализации на Airbnb Lottie. Однако основное изменение заключалось не просто в замене Compottie на другую библиотеку.
Я решил заранее подготавливать кадры анимации, а затем показывать их как обычные растровые изображения.
Так появился TgsFrameBaker.
Вместо того чтобы постоянно обрабатывать векторную композицию во время воспроизведения, я использовал Airbnb Lottie для предварительного рендеринга кадров в Bitmap.
Схема стала такой:
-
Разобрать Lottie-композицию.
-
Подготовить кадры анимации в фоновом потоке.
-
Сохранить готовые изображения в кэше.
-
Воспроизводить их через Compose, переключая текущий кадр по времени.
Этот подход называют Frame Baking — предварительным «запеканием» анимации в последовательность растровых кадров.
В Android-реализации для этого используются LottieCompositionFactory, LottieDrawable, Bitmap и Canvas.
Вместо постоянной подготовки векторной композиции при воспроизведении интерфейс получает уже созданное изображение. Это меняет распределение работы: подготовка кадров выполняется заранее, а при воспроизведении остаётся отображение готовых изображений.
Первая версия TgsFrameBaker
В первой итерации TgsFrameBaker выполнял несколько действий:
-
разбирал Lottie-композицию на фоновом потоке через
Dispatchers.Default; -
создавал
LottieDrawable; -
последовательно отрисовывал кадры в Bitmap;
-
сохранял подготовленные изображения в LRU-кэше;
-
возвращал список кадров для отображения в Compose.
Размер кадра был задан как 260 × 260 пикселей, а целевая частота воспроизведения составляла 30 FPS.

Это был первый рабочий вариант новой архитектуры. Но после тестирования стало понятно, что его нужно дорабатывать: оставались вопросы к учёту длительности анимаций, управлению памятью и поведению при отмене подготовки кадров.
Вторая итерация: память, кадры и синхронизация
После первой реализации я переработал несколько важных частей TgsFrameBaker и TgsStickerPlayer.
Ограничение кэша по памяти
Ограничение только по количеству анимаций не очень удобно: разные стикеры могут иметь разную длительность и, соответственно, требовать разного объёма памяти.
Поэтому в следующей версии я перешёл к оценке размера растровых кадров.
Кэш ограничен 24 MiB. Размер подготовленной анимации оценивается по количеству кадров, ширине, высоте и четырём байтам на пиксель.
Это оценка объёма растровых данных, а не строгий лимит всей памяти, которую приложение может потратить на анимации. Но такой подход позволяет управлять кэшем понятнее, чем простое ограничение по количеству элементов.
При вытеснении старых элементов LRU-кэш помогает сохранять недавно использованные анимации и освобождать место для новых.
Учёт длительности анимации
В первоначальной версии я ограничивал длительность композиции диапазоном от одной до четырёх секунд. Количество кадров находилось в диапазоне от 20 до 60.
При дальнейшей доработке я отказался от искусственного ограничения длительности. Теперь количество кадров рассчитывается исходя из реальной продолжительности композиции и целевого FPS, но не превышает 90.
Это компромисс между плавностью и памятью. Если композиция длинная, ограничение по числу кадров означает, что фактическая частота подготовленных изображений может оказаться ниже 30 FPS.
Я не хотел бесконечно увеличивать количество Bitmap ради сохранения одинаковой частоты на любой анимации.
Воспроизведение на основе времени
Отдельно я переработал тайминг в TgsStickerPlayer.
Вместо фиксированной задержки между кадрами плеер использует withFrameNanos и учитывает время, прошедшее между кадрами Compose.
Если воспроизведение задерживается, индекс может продвинуться сразу на несколько кадров. Это позволяет ориентироваться на прошедшее время, а не на предположение, что каждый цикл обязательно завершится ровно через 33 миллисекунды.
Готовые кадры отображаются через Canvas и drawImage.
При этом Android-реализация использует Airbnb Lottie и предварительно подготовленные Bitmap, а Desktop продолжает работать через Compottie. Я не стал насильно унифицировать способы воспроизведения там, где платформы и текущие требования проекта отличаются.
Отмена подготовки и повторные запросы
Подготовка кадров может потерять актуальность, например когда пользователь быстро пролистывает список сообщений.
Поэтому в TgsFrameBaker я добавил проверки отмены корутины во время подготовки кадров. Если задача отменяется, уже созданные Bitmap освобождаются, а CancellationException не маскируется под обычную ошибку.
Для синхронизации используется Mutex. После получения блокировки кэш проверяется повторно: пока задача ждала, другая операция уже могла подготовить нужную анимацию.
Важный нюанс: здесь используется общий mutex. Поэтому операции подготовки сериализуются, а не выполняются независимо для каждого стикера. Такой подход упрощает защиту от повторной работы, но может ограничивать параллельность.
Это один из компромиссов выбранной реализации, который стоит учитывать при дальнейшем исследовании производительности.
Что показало сравнение с Telegram
После доработки я снова открыл одну и ту же стенку стикеров в GhostGram и официальном Telegram на TECNO SPARK Go 2024.
В GhostGram обычное воспроизведение чаще всего сразу начиналось в жёлтой зоне графика HWUI. Если оставить стенку стикеров открытой и не взаимодействовать со скроллом, столбцы обычно оставались на жёлтой линии.
В официальном Telegram я наблюдал другую картину. В начале теста график находился на красной линии. После примерно трёх-четырёх полных прокруток той же стенки стикеров столбцы постепенно опускались до жёлтой зоны.


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

Это интересное наблюдение, но я не хочу делать из него вывод, что GhostGram безусловно производительнее официального клиента. Я сравнивал поведение графика HWUI в конкретных сценариях на одном устройстве, а не проводил полноценный автоматизированный бенчмарк. В частности, я не проверял серию повторяемых измерений на нескольких смартфонах.
Поэтому мой вывод ограничен наблюдаемым поведением: в обычном сценарии GhostGram обычно сразу находился в жёлтой зоне, тогда как Telegram в моём тесте переходил от красной к жёлтой после нескольких полных прокруток той же стенки стикеров.
Что происходит при взаимодействии со скроллом
У GhostGram есть и другой сценарий, в котором поведение становится менее стабильным.
Если начать взаимодействовать со скроллом, график HWUI иногда поднимается до красной линии. Это может происходить, когда пользователь быстро прокручивает список и у ещё не подготовленных стикеров появляются индикаторы загрузки.
Важно отличать это от прежнего бага. Раньше у меня случался сценарий, когда график сначала находился на жёлтой линии, а затем поднимался до красной даже без очевидного взаимодействия со скроллом. Этот баг я исправил.
Сейчас обычное воспроизведение без взаимодействия остаётся преимущественно на жёлтой линии, но при прокрутке иногда возникают пики.
Я также замечал, что дополнительное движение списка иногда как будто меняет поведение графика. Но это наблюдение пока не проверено отдельным экспериментом. Я не могу уверенно сказать, связаны ли скачки с самой прокруткой, отображением индикатора загрузки, подготовкой кадров или другим этапом работы интерфейса.
Возможное объяснение — дополнительная работа при появлении новых элементов и подготовке анимаций. Но это пока гипотеза. Без дополнительного профилирования я не могу достоверно определить, какая часть работы вызывает пики.
Поэтому я не считаю оптимизацию полностью завершённой. В обычном сценарии воспроизведения результат выглядит стабильнее, но взаимодействие со скроллом остаётся отдельным случаем для дальнейшего исследования.
Что я вынес из этой работы
Первый вывод — не всегда достаточно заменить одну библиотеку другой. В моём случае основное изменение состояло в том, что я перестал готовить векторную анимацию непосредственно во время воспроизведения и перешёл к предварительной отрисовке кадров.
Второй вывод — после появления работающего решения начинаются задачи управления памятью, временем, отменой корутин и синхронизацией. Без этого можно добиться плавности на одном стикере, но столкнуться с другими проблемами в длинном списке.
И наконец, сравнение с существующим приложением полезно, если контролировать условия эксперимента и не делать выводы шире, чем позволяют измерения.
Работа над TGS-стикерами в GhostGram ещё не закончена. Я добился того, что обычный сценарий воспроизведения на моём тестовом устройстве обычно начинается со стабильной жёлтой зоны графика HWUI. Но мне ещё предстоит разобраться со скачками при быстром скролле.
Код открыт для изучения
Исходный код GhostGram доступен на GitHub: https://github.com/vdggrtff/GhostGram
Реализация менялась в несколько этапов:
-
Первая итерация с Airbnb Lottie и Frame Baking: https://github.com/vdggrtff/GhostGram/commit/0fb8f8baa1b7138f2a9e106f60251a1f59509f20
-
Доработка кэша, таймингов и обработки отмены: https://github.com/vdggrtff/GhostGram/commit/58fce74d127328f6d9c43b8a52fc13db13a74c3e
Если вы работали с Lottie, Compose или производительностью Android, буду рад вашим замечаниям по реализации. Особенно интересно узнать, как вы организуете подготовку и воспроизведение анимаций в ленивых списках, когда пользователь быстро прокручивает элементы, которые ещё не успели загрузиться.
ссылка на оригинал статьи https://habr.com/ru/articles/1099732/