Когда экран с изображениями начинает тормозить, первая реакция обычно предсказуемая: выключить crossfade, включить кэш, добавить placeholder и надеяться, что этого достаточно.

Иногда это правда помогает. Но редко решает проблему системно.

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

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

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

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

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

Разберём три вещи:

  • какую задачу мы на самом деле решаем, когда оптимизируем картинки

  • что стоит измерять, если экран с изображениями начинает тормозить

  • какие подходы действительно помогают, а какие только создают иллюзию оптимизации

Сначала не про картинки, а про цель

Одна из самых частых ошибок — оптимизировать скорость загрузки картинки как отдельную величину.

Но у экрана с изображениями почти всегда другая цель

Пользователь должен как можно раньше увидеть контент и спокойно начать взаимодействовать с экраном.

Это важное различие.

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

Для каталогов и лент правильнее мыслить так:

  • сначала как можно быстрее показать все видимые элементы хотя бы в preview-виде

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

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

То есть оптимизировать нужно не одну картинку, а весь визуальный поток экрана.

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


Что измерять, прежде чем что-то чинить

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

Ниже — метрики, которые действительно помогают увидеть картину целиком.

1. Метрики плавности UI

Если экран дёргается, всё остальное уже вторично.

В первую очередь полезно смотреть:

  • janky frames или hitch rate — долю кадров, в которых экран начинает заметно подтормаживать

  • frozen frames — совсем тяжёлые кадры, которые отрисовываются аномально долго, например дольше 700ms

  • p50 / p95 / p99 времени кадра — чтобы понимать не только среднее поведение, но и тяжёлые хвосты

  • разбивку по low / mid / high device tier

Среднее по всей аудитории почти бесполезно. Сильные устройства очень любят маскировать боль слабых.

Если на high-tier всё выглядит нормально, а на low-tier уже высокий jank, это не “в среднем приемлемо”. Это реальная проблема для заметной части пользователей.

Если эти метрики растут, в первую очередь стоит смотреть не только на сеть, но и на то, что происходит в hot path: decode, transform, alpha-анимации, лишняя телеметрия и тяжёлые placeholders.

2. Метрики визуальной загрузки

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

Если упростить, нас обычно интересуют четыре вещи:

  • через сколько появляется первое preview-изображение, то есть быстрая облегчённая версия картинки

  • через сколько появляется original, то есть основное изображение в нормальном качестве

  • как часто пользователь вообще видит пустое место вместо картинки

  • сколько времени проходит между появлением preview и его заменой на original

Последняя метрика особенно полезна.

Если original приходит слишком поздно, пользователь дольше смотрит на размытую или упрощённую картинку. Если original приходит резко и без аккуратной замены, интерфейс начинает визуально дёргаться.

То есть здесь важно не только “как быстро загрузилась картинка”, но и “как именно выглядел экран для пользователя в этот момент”.

3. Метрики самих image requests

Если предыдущий блок отвечает на вопрос “что увидел пользователь”, то этот помогает понять “почему экран ведёт себя именно так”.

Здесь уже полезно смотреть на более технические сигналы:

  • какого размера изображения реально загружаются

  • откуда они берутся: из памяти, из дискового кэша или из сети

  • сколько картинок одновременно пытается загрузиться на экране

  • соответствует ли размер загружаемой картинки реальному размеру элемента, в котором она показывается

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

Один из самых частых сценариев выглядит так: картинка в карточке шириной около 120dp приходит слишком тяжёлой. И проблема в этот момент уже не только в сети. Устройство тратит лишние ресурсы на decode, память и отрисовку изображения, которое для этого слота просто избыточно.

Здесь полезно задавать себе простой вопрос:

Совпадает ли размер изображения, которое я загружаю и декодирую, с тем, что реально нужно UI?

Если нет, это почти всегда точка роста.

4. Продуктовые сигналы

Они не заменяют технические метрики, но помогают понять, окупились ли изменения.

Например:

  • scroll depth

  • CTR карточек

  • bounce rate

  • time to first interaction

  • conversion на low-tier устройствах

Не каждая оптимизация изображений напрямую поднимет бизнес-метрики. Но плохая витрина на слабом устройстве или плохом интернете почти всегда бьёт и по ним тоже.


Практика 1. Делите устройства на tier

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

Минимально полезное деление — по RAM, классу CPU/GPU и размеру экрана в пикселях. Этого уже достаточно, чтобы не пытаться делать одинаково дорогой UX для всех.

пример разделения
пример разделения

После этого можно задавать policy по tier.

Например:

  • на low грузить облегчённый original

  • на low убирать анимацию preview -> original

  • на low сильнее ограничивать prefetch

  • на low не включать дорогие трансформации

  • на mid/high оставлять более “красивый” сценарий

Это не деградация UX. Это честная адаптация поведения под железо.

Иногда “проще, но стабильно” на слабом устройстве выглядит для пользователя лучше, чем “красиво, но тормозит”.

Практика 2. Используйте preview как полноценный первый слой

Если у картинки есть preview-версия, не относитесь к ней как к случайному placeholder.

Для списка карточек preview, это нормальный и очень полезный first render layer.

Рабочая схема обычно такая:

  • preview показывается быстро

  • original загружается параллельно

  • пока original не готов, пользователь видит preview

  • после загрузки original аккуратно заменяет preview

Упрощённо это выглядит так:

@Composable
fun ProductImage(
    previewUrl: String?,
    originalUrl: String,
) {
    var isOriginalReady by remember(originalUrl) { mutableStateOf(false) }

    Box {
        if (!isOriginalReady && !previewUrl.isNullOrBlank()) {
            AsyncImage(
                model = previewUrl,
                contentDescription = null,
            )
        }

        AsyncImage(
            model = originalUrl,
            contentDescription = null,
            onSuccess = { isOriginalReady = true },
        )
    }
}

В реальном проекте там обычно есть ещё state, metrics, animation policy и caching policy. Но базовая идея именно такая: preview это не декоративный бонус, а способ быстро стабилизировать экран.

Плюсы

Минусы

пользователь быстрее видит контент

нужно отдельно решать переход preview -> original

экран раньше становится визуально собранным

если делать это грубо, получится заметный swap

уменьшается количество пустых карточек на первом экране

если делать слишком красиво, можно переплатить за анимацию

Но здесь важен компромисс: preview полезен только если переход к full-size изображению не выглядит как грубый swap.

Если анимация тяжёлая или замена происходит слишком поздно, выигрыш в скорости легко превращается в визуальный шум.

Практика 3. Не стройте waterfall "preview, потом original" для каждой карточки

На бумаге это решение звучит красиво:

  1. сначала скачай preview

  2. покажи preview

  3. потом уже качай original

Но на экранах с большим количеством карточек такой сценарий легко превращается в waterfall, где пользователь видит, как контент подтягивается по очереди.

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

  • визуально пользователь сначала видит preview

  • original при этом не блокируется

  • UI просто не показывает original до тех пор, пока он не готов

То есть у вас не последовательная сеть, а последовательная визуализация.

Это важная разница. Последовательной должна быть визуализация, а не сеть.

Практика 4. Делайте prefetch на уровне экрана

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

Это уже задача уровня экрана.

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

Хорошая схема выглядит так:

  • экран собирает preview-URL для видимых элементов и небольшого буфера вперёд

  • image loader заранее прогревает их

  • к моменту реального рендера preview уже чаще лежит в cache

Например, можно разделить решение на две части.

Сначала pure-логика выбора URL:

class CatalogImageUrlsCollector {

    fun collectPreviewUrls(
        catalogElements: List<CatalogElement>,
        previewImagePrefetchLimit: Int,
    ): List<String> {
        return catalogElements
            .asSequence()
            .filter(String::isNotBlank)
            .distinct()
            .take(previewImagePrefetchLimit)
            .toList()
    }
}

А затем отдельно infrastructure-адаптер, который знает, как эти URL реально префетчить:

class CoilCatalogImagePrefetcher(
    private val imageLoader: ImageLoader,
    private val context: Context,
) : ImagePrefetcher {

    override fun prefetch(urls: List<String>, targetWidthPx: Int?) {
        if (urls.isEmpty()) return

        urls.forEach { imageUrl ->
            imageLoader.enqueue(
                ImageRequest.Builder(context)
                    .data(imageUrl)
                    .build()
            )
        }
    }
}

Плюсы

Минусы

экран заполняется быстрее

если префетч слишком агрессивный, он сам начинает конкурировать с visible work

уменьшается количество пустых карточек

приходится думать о лимитах и приоритетах

появляется единая стратегия для всех блоков

Здесь легко сделать хуже, если prefetch слишком агрессивный. Тогда он начинает конкурировать с visible work.

Поэтому у prefetch должны быть ограничения:

  • лимит на количество запросов

  • приоритет visible content выше, чем фонового прогрева

  • отмена лишнего prefetch при быстром скролле

  • отдельные бюджеты для preview и full-size изображений

Практика 5. Подгоняйте full-size изображение под слот

Это одна из самых дешёвых и самых полезных оптимизаций.

Очень частая ошибка — грузить для всех экранов одну и ту же картинку, не задавая себе вопрос: а какой размер реально нужен этому элементу интерфейса?

Если у карточки небольшой image-slot, нет смысла тянуть туда тяжёлую версию изображения только потому, что “пусть будет покачественнее”. На слабом устройстве это быстро начинает бить сразу в несколько мест:

  • в сеть

  • в декодирование

  • в память

  • в плавность скролла

Особенно хорошо это видно на типичных e-commerce экранах. Например:

  • у товарной карточки картинка часто занимает примерно 118–132dp по ширине

  • у inline-баннера слот уже заметно больше

  • у top banner изображение вообще растягивается почти на всю ширину экрана

Очевидно, что всем этим элементам не нужен один и тот же размер original-картинки.

Поэтому на практике почти всегда полезно иметь несколько уровней изображений:

  • preview для быстрого первого показа

  • облегчённый original для low-tier устройств

  • обычный original для mid/high-tier устройств

Это выглядит как маленькая оптимизация, но по факту она влияет сразу на всю цепочку отображения: сколько байт приехало по сети, сколько времени ушло на decode, сколько памяти занял bitmap и насколько тяжёлым оказался сам скролл.

Что полезно спрашивать себя

Совпадает ли размер изображения, которое я запрашиваю и декодирую, с тем, что реально нужно слоту на экране?

Если нет, это почти всегда точка роста.

Практика 6. На low-tier отключайте эффекты, которые не окупаются

Есть вещи, которые визуально кажутся дешёвыми, а по факту очень любят отъедать производительность:

  • blur

  • двойные crossfade

  • длинные fade-анимации

  • лишние трансформации

  • сложные placeholder-сценарии на каждый item

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

Поэтому полезно иметь отдельную policy для low-tier:

  • отключать fade между preview и original

  • не блюрить preview

  • не держать тяжёлые эффекты в горячем списке

  • оставлять только дешёвые визуальные переходы

Плюсы

Минусы

быстрое и предсказуемое улучшение на слабых девайсах

экран может стать чуть менее "полированным"

меньше визуальных дёрганий, если реализация анимации неидеальна

нужно отдельно поддерживать tier-specific behavior

Практика 7. Не переоценивайте кэш

Кэш очень полезен. Но сам по себе кэш не делает экран быстрым.

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

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

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

Типичный пример — большой баннер.

Если заранее прогреть его без size hint, image loader может сохранить слишком тяжёлую версию. А потом на экране тот же баннер будет запрошен уже под конкретную ширину устройства.

Пример:

// пример с coil
val targetWidthPx = context.resources.displayMetrics.widthPixels

imageLoader.enqueue(
    ImageRequest.Builder(context)
        .data(banner.photoLink)
        .size(
            Size(
                width = Dimension.Pixels(targetWidthPx),
                height = Dimension.Undefined,
            ),
        )
        .build(),
)

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

Где это особенно полезно

Сильнее всего это заметно на:

  • top banner

  • hero image

  • full-width promo blocks

  • любых крупных изображениях, которые занимают заметную часть экрана

То есть там, где лишние байты и лишний decode стоят особенно дорого.

Поэтому правильный вопрос здесь не “есть ли у нас кэш?”, а такой:

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

Практика 8. Метрики по изображениям тоже должны быть дешёвыми

Это место, где многие случайно вредят себе сами.

Когда на каждую картинку в горячем списке вешается:

  • visibility tracking

  • impression logic

  • lifecycle callbacks

  • metrics events на loading / success / failure

всё это тоже становится частью hot path.

Сами метрики нужны. Но не обязательно собирать их на каждом item.

Обычно помогает sampling.

Что это даёт

  • вы сохраняете наблюдаемость

  • не тащите полную instrumentation-нагрузку на каждую картинку

  • меньше вмешиваетесь в scroll path

Практика 9. Разделяйте policy и infrastructure

Когда image-оптимизация разрастается, код очень быстро превращается в смесь из:

  • бизнес-правил

  • деталей платформы

  • особенностей image loader

  • UI policy

  • metrics policy

Если всё это живёт в одном ViewModel или одном "умном" composable, дальше начинается боль.

Поэтому полезно разделять:

  • policy — чистую логику, которую легко тестировать

  • infrastructure — код, который знает про ImageLoader, Context, enqueue() и другие детали

  • orchestration — слой, который просто собирает всё вместе

Хороший сигнал, что вы движетесь в правильную сторону:

  • pure-часть можно покрыть обычными unit tests

  • side effects локализованы

  • ViewModel остаётся тонким orchestration-слоем

Что обычно делают неправильно

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

Давайте просто быстрее грузить original

Если экрану важнее быстро заполнить витрину, это часто не лучший приоритет.

Давайте одинаково красиво на всех устройствах

На слабом железе одинаковая "красота" может превращаться в одинаково плохой UX.

Давайте сильнее префетчить

Без лимитов и приоритетов это легко превращается в конкуренцию с видимым контентом.

Кэш всё решит

Нет, если размер, request policy и размер слота живут в разных реальностях.

Соберём подробные metrics на каждой картинке

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

Мини-чеклист для экрана с большим количеством картинок

Если нужно быстро провести аудит, я бы шёл по такому списку.

Сначала проверьте самое дорогое

  • какой jank и frozen frames на low-tier устройствах

  • сколько пустых карточек пользователь видит на первом экране

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

  • не слишком ли агрессивен prefetch

  • нет ли дорогих эффектов в hot path

  • не слишком ли тяжёлая телеметрия на каждый item

Затем проверьте архитектуру и стратегию

  • есть ли preview

  • есть ли уменьшенная full-size версия для low-tier устройств

  • есть ли screen-level prefetch

  • есть ли tier-based policy

  • разделены ли policy и infrastructure

И отдельно проверьте измерения

  • есть ли метрики preview shown / full image shown

  • есть ли sampling для image metrics

  • разбиваются ли метрики по device tier

Что можно сделать за один день

Если времени мало, я бы начал с трёх вещей:

  • измерить jank и empty slots на low-tier устройствах

  • проверить соответствие размера изображения реальному слоту

  • ввести screen-level prefetch с лимитами и приоритетом visible content

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

Вывод

Оптимизация изображений — это системная работа с несколькими вопросами:

  • что показывать первым

  • какой размер реально нужен

  • что греть заранее

  • что отключать на слабых девайсах

  • как всё это измерять без самообмана

Если коротко, то самый полезный вывод у меня такой:

Хорошо оптимизированный экран с изображениями — это не экран, где быстрее всего приходит full-size картинка. Это экран, который быстро становится визуально полным, стабильно скроллится и аккуратно догружает качество там, где пользователь уже не страдает.

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

В следующей статье разберу более глубокие темы:

  • смена качества изображения в зависимости от состояния сети

  • hardware vs software decoding

  • влияние формата изображения и как он рендерится на android

  • как проектировать AsyncImage-обёртку без архитектурного шума

  • как строить image metrics так, чтобы они сами не мешали перформансу

  • как сочетать preview, full-size, prefetch и кэш без лишнего дублирования работы

Пишите в комментариях :)

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