The Jerusalem Post‘Hager (Land),’ an epic story of Ethiopian immigrants, wins Best Israeli Film at Haifa festESPNWeek 5 Power Rankings: Miami moves up, Missouri and Pitt join the top 25PunchTwo dead, one missing as floods hit Spain cityDaily MaverickSOCIAL (IN)SECURITY: A Cape Town mother buys bread on credit to stretch her children’s grants, then spends payday settling the debtRTP Desporto12h30 Henrique Calisto quer saída digna para Cristiano RonaldoSky TG24Istat, la pressione fiscale aumenta al 43,5%. Cala il potere d'acquisto delle famiglieZDF heuteAktuelle Pressemitteilungen des ZDFObservador DesportoAs notícias das 13hGlobal NewsU.S. Coast Guard suspends search for missing Ontario air ambulanceStraits Times SportFrom jiu-jitsu fighter Noah Lim a lovely lesson: Grace after gritABC NewsSouth Korea blames North Korean mines for border blast injuries and demands apologyسكاي نيوز عربيةمقاعد من الركام.. رحلة غزاوي لإعالة أسرته ومساعدة الطلاب
The Daily Newsstand · Free, Always
Monday, October 5, 2026

Правда о Coil в Jetpack Compose: измеряем реальный оверхед SubcomposeAsyncImage

Translate

Эта статья рассказывает о вариантах интеграции изображений с помощью библиотеки Coil в списки и о их быстродействии на основе Compose.

Вступление

Списки с картинками есть практически в каждом приложении — любой маркетплейс с карточками товара (Lamoda, Озон, Авито, Wildberries), Instagram, Pinterest, Lemon8 — тысячи их. И часто перед разработчиком встает задача такой экран оптимизировать — бизнес хочет, чтобы все работало еще быстрее. И казалось бы, уже проведены всевозможные оптимизации: стабильные типы в UI, переписаны запросы к базе, обновлены конвертеры, оптимизированы сетевые запросы — а список все равно иногда дергается. Но есть еще одна вещь: как именно мы вставляем саму картинку в карточку.

Обычно изображение в таких списках может находиться в трёх состояниях:

  • картинка

  • загрузка (анимация, шиммер, а можно и просто цвет)

  • ошибка (картинка не скачалась)

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

Одна из них — SubcomposeAsyncImage, специализированный Composable-компонент, который выполняет сабкомпозицию на основе текущего состояния загрузки и строит нужное UI-дерево в зависимости от стейта (Loading, Success, Error).

Сами разработчики Coil использовать его в списках не рекомендуют — из-за снижения производительности от сабкомпозиции (механизма, который запускает фазу композиции UI на основе данных, полученных уже на фазе измерения). Вот прямая цитата из их документации:

“This API uses subcomposition, which is slow. Avoid using this composable in places that need high performance (e.g. LazyRow/LazyColumn).”

Но никто не говорит, насколько это плохо. Большая часть советов по производительности в Compose в интернете — “по ощущениям” или по советам нейросетей.

Поэтому я решил всё это измерить. Взял пять разных способов загрузить картинку в Compose-списке — от самого простого прямого рисования до того самого SubcomposeAsyncImage, прогнал через одинаковые условия на реальных устройствах и посчитал, во сколько именно миллисекунд обходится каждый подход. Спойлер: ответ оказался не таким однозначным.

Что нужно знать, прежде чем идти дальше

Анатомия кадра: Composition, Layout, Draw и где тут ломается конвейер

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

Конвейер Compose работает в строгой последовательности, проходя три этапа для каждого кадра UI:

  • Composition (Композиция) — что отображать? Compose выполняет @Composable-функции, считывает состояние и строит или обновляет внутреннее дерево разметки (Slot Table). Элементы интерфейса только заявляют о своём существовании.

  • Layout (Макет) — где отображать? Два неразрывных шага: измерение и размещение. Элемент измеряет дочерние узлы, определяет собственные размеры и расставляет “детей” по координатам экрана.

  • Drawing (Отрисовка) — как отображать? Компоненты рисуют пиксели на холсте: фоны, текст, векторные изображения, тени, размытие.

В обычном случае этот порядок неизменен: сначала решили что, потом померили, потом нарисовали. SubcomposeLayout — это возможность запустить еще один проход Composition прямо внутри фазы Measure. То есть “неизменяемый” порядок нарушается: пока Compose ещё занят измерением макета, он вынужден прерваться и заново решить, что вообще показывать, — и только потом продолжить мерить. Это прерывание и есть источник задержки и нагрузки.

Почему FPS врет, а миллисекунды — нет: цена Jank на 60, 90 и 120 Гц.

У экрана есть дедлайн на каждый кадр. При 60 Гц это примерно 16.6 миллисекунды на все: посчитать, измерить, отрисовать. Не успели — система либо показывает старую картинку еще раз, либо кадр дергается. Это и называется Jank — на глаз ощущается как рывок. На современных 90 Гц и 120 Гц экранах бюджет ещё меньше — ~11 и ~8 мс соответственно, так что цена одной и той же операции становится относительно выше.

Почему в основном все расчёты в миллисекундах, а не в FPS. FPS — это усреднение за секунду. Экран может честно показывать “60 FPS”, а внутри этой же секунды проскочит один-единственный кадр, который готовился не 16, а 200 миллисекунд. Пользователь этот кадр увидит — заметный, неприятный рывок посреди скролла. А счётчик FPS почти не изменится: 59 нормальных кадров перекроют один плохой в среднем значении.

Как это правильно замерять?

Можно просто погонять приложение руками — но так сложно увидеть разницу между стратегиями, нужны идентичные действия со списком. Можно использовать профилировщик прямо в Android Studio — отлично для отладки в моменте для поиска Jank’ов, но не подходит для строгого сравнения вариантов. Можно использовать production-мониторинг вроде Firebase Performance — он собирает статистику с реальных пользователей, но ловит уже свершившиеся проблемы, а не помогает выбрать между вариантами заранее.

Я использовал Macrobenchmark — официальную библиотеку Google именно под такие задачи. Она сама запускает приложение на реальном устройстве, работает с экраном через UiAutomator (по-настоящему “двигает пальцем” по экрану, а не эмулирует события программно), а потом достаёт из системного трейса точные тайминги каждого кадра. Причём делает это множество раз подряд и сама считает перцентили — не нужно вручную усреднять цифры в Excel или через нейросеть.

Что такое перцентили, и чем frameDurationCpuMs отличается от frameOverrunMs

Результат работы Macrobenchmark’а - эта таблица, где расположены показатели (процентили) P50, P90, P95 и P99 и их значения.

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

  • P50 (Медиана): Время, в которое укладываются 50% отрисовок кадра. Это типичный, средний пользовательский опыт. Если P50 равен 12 мс, значит, половина всех кадров во время скролла готовилась быстрее 12 миллисекунд (легко укладываясь в 16.6 мс для 60 Гц экранов)

  • P90: Время, в которое укладываются 90% отрисовок кадра. Только 10% отрисовок были медленнее этого значения.

  • P95: Время, в которое укладываются 95% отрисовок кадра.

  • P99 (Худший сценарий / Tail Latency): Время, в которое укладываются 99% отрисовок кадра. Лишь 1% замеров оказался хуже этого значения. Этот показатель критически важен, так как он отражает «фризы», «лаги» и сильные просадки производительности (например, когда устройство перегружено или происходит «холодный» старт с тяжелой инициализацией).

Далее - я измерял две связанные, но разные величины. frameDurationCpuMs — суммарное CPU-время на подготовку кадра, на UI Thread и на RenderThread вместе (RenderThread — отдельный системный поток, передающий готовую картинку в GPU). Это честное “сколько работы было сделано”, независимо от того, уложились мы в дедлайн или нет.

frameOverrunMs — совсем другое: разница между тем, сколько кадр реально занял, и сколько у него было в бюджете. Отрицательное число — уложились с запасом. Положительное — просрочили дедлайн, кадр дёрнулся на экране. Это прямая метрика джанка — то, что действительно увидел пользователь.

5 участников эксперимента

Прежде чем показывать цифры, нужно познакомиться с стратегиями интеграции изображений. Все пятеро решают одну задачу — показать картинку в карточке, красиво подождать, пока она грузится, и отобразить экран ошибки, если что-то пошло не так. Одна стратегия отличается от остальных: AsyncImage (Direct Canvas) — там экран ошибки — просто заливка цветом. Это сделано специально, чтобы показать, насколько такой подход быстрее, чем сложные экраны с полноценной composable-функцией для отображения ошибки. Все остальные четыре идентичны — показывают одну и ту же анимацию во время загрузки и один и тот же полноценный экран ошибки.

1. Прямой Canvas (AsyncImage)

Самый простой вариант. Картинка просто рисуется, когда готова, а пока не готова — крутится шиммер. В состоянии ошибки — на месте картинки появляется просто закрашенный прямоугольник, не экран ошибки, а квадрат, залитый цветом. Главная фишка этого варианта: быстро — да, но по-честному его сложно сравнивать с остальными по “фичам”, он не показывает полноценный экран ошибки.

AsyncImageDirect
/**
 * Strategy 1: AsyncImage (Direct Canvas / No SubcomposeLayout).
 * Renders directly via Painter without extra LayoutNodes.
 */
@Composable
internal fun AsyncImageDirectSlot(
    item: BenchmarkItemUio,
    imageLoader: ImageLoader,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current

    val imageRequest = remember(item.id) { item.toMainImageRequest(context) }

    val errorColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f)
    var showShimmer by remember(item.id) { mutableStateOf(true) }

    trace(TRACE_SECTION_STRATEGY_RENDER) {
        AsyncImage(
            model = imageRequest,
            imageLoader = imageLoader,
            contentDescription = item.title,
            contentScale = ContentScale.Crop,
            error = remember(errorColor) { ColorPainter(errorColor) },
            onLoading = { showShimmer = true },
            onSuccess = { showShimmer = false },
            onError = { showShimmer = false },
            modifier = modifier
                .fillMaxSize()
                .shimmerEffect(enabled = showShimmer),
        )
    }
}

2. Canvas + оверлей поверх

Почти то же самое, что и первый вариант — быстрый Canvas-путь для картинки. Но если что-то пошло не так, сверху накладывается настоящий экран ошибки (иконка, текст, кнопка). Хитрость в том, что этот “тяжёлый” экран ошибки существует в дереве, только когда он реально нужен — в подавляющем большинстве случаев, когда всё грузится нормально, этот вариант ведет себя ровно как первый.

AsyncImageOverlay
/**
 * Strategy 2: AsyncImage + Composable Overlay (Hybrid approach).
 * 1. Image renders fast via Canvas.
 * 2. Standard Box usage (no SubcomposeLayout).
 * 3. Composable error is shown if loading fails.
 */
@Composable
internal fun AsyncImageOverlaySlot(
    item: BenchmarkItemUio,
    imageLoader: ImageLoader,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current

    val imageRequest = remember(item.id) { item.toMainImageRequest(context) }

    var isLoading by remember(item.id) { mutableStateOf(true) }
    var isError by remember(item.id) { mutableStateOf(false) }

    trace(TRACE_SECTION_STRATEGY_RENDER) {
        Box(modifier = modifier.fillMaxSize()) {
            AsyncImage(
                model = imageRequest,
                imageLoader = imageLoader,
                contentDescription = item.title,
                contentScale = ContentScale.Crop,
                onLoading = {
                    isLoading = true
                    isError = false
                },
                onSuccess = {
                    isLoading = false
                    isError = false
                },
                onError = {
                    isLoading = false
                    isError = true
                },
                modifier = Modifier
                    .fillMaxSize()
                    .shimmerEffect(enabled = isLoading),
            )

            if (isError) {
                ErrorPlaceholder(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

3. SubcomposeAsyncImage со слотами

Официальный, “из коробки” способ библиотеки Coil. Его стратегия: “вот три варианта — что показывать при загрузке, при успехе, при ошибке”, а дальше всё решает сама библиотека. Очень удобно с точки зрения кода. Но чтобы решить, какой вариант показать, Compose должен на секунду прерваться прямо посреди измерения места под карточку. Именно это прерывание и есть дополнительная нагрузка SubcomposeLayout, из-за которого вообще написана эта статья.

Subcompose
/**
 * Strategy 3: SubcomposeAsyncImage with slot architecture.
 * Uses SubcomposeLayout for content branches (loading, error, success).
 * High flexibility, but slower Measure and Layout phases.
 */
@Composable
internal fun SubcomposeSlot(
    item: BenchmarkItemUio,
    imageLoader: ImageLoader,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current

    val imageRequest = remember(item.id) { item.toMainImageRequest(context) }

    SubcomposeAsyncImage(
        model = imageRequest,
        imageLoader = imageLoader,
        contentDescription = item.title,
        modifier = modifier.fillMaxSize(),
        loading = {
            trace(TRACE_SECTION_STRATEGY_RENDER) {
                Box(
                    modifier = Modifier
                        .fillMaxSize()
                        .shimmerEffect(),
                )
            }
        },
        success = {
            trace(TRACE_SECTION_STRATEGY_RENDER) {
                SubcomposeAsyncImageContent(
                    contentScale = ContentScale.Crop,
                    modifier = Modifier.fillMaxSize(),
                )
            }
        },
        error = {
            trace(TRACE_SECTION_STRATEGY_RENDER) {
                ErrorPlaceholder(
                    modifier = Modifier.fillMaxSize(),
                )
            }
        },
    )
}

4. SubcomposeAsyncImage с одной большой лямбдой

Технически та же самая библиотека, тот же самый SubcomposeLayout — но вместо трех готовых вариантов используется один большой кусок кода с ручным when(состояние) внутри. На первый взгляд выглядит проще — один блок вместо трех. Но эта простота в коде не бесплатна: сабкомпозиция никуда не делась, она просто спрятана внутри, а при каждой смене состояния (загрузка → успех, загрузка → ошибка) этот кусок кода не просто единожды выбирается, а пересчитывается заново поверх уже недешевой сабкомпозиции. Двойная плата: и за прерывание измерения, и за пересчет внутри него.

SubcomposeContent
/**
 * Strategy 4: SubcomposeAsyncImage with monolithic content slot.
 * Tests the impact of manual when(state) inside SubcomposeLayout.
 */
@Composable
internal fun SubcomposeContentSlot(
    item: BenchmarkItemUio,
    imageLoader: ImageLoader,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current

    val imageRequest = remember(item.id) { item.toMainImageRequest(context) }

    SubcomposeAsyncImage(
        model = imageRequest,
        imageLoader = imageLoader,
        contentDescription = item.title,
        modifier = modifier.fillMaxSize(),
    ) {
        val state by painter.state.collectAsState()

        when (state) {
            is AsyncImagePainter.State.Loading,
            is AsyncImagePainter.State.Empty,
            -> {
                trace(TRACE_SECTION_STRATEGY_RENDER) {
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .shimmerEffect(),
                    )
                }
            }

            is AsyncImagePainter.State.Error -> {
                trace(TRACE_SECTION_STRATEGY_RENDER) {
                    ErrorPlaceholder(
                        modifier = Modifier.fillMaxSize(),
                    )
                }
            }

            is AsyncImagePainter.State.Success -> {
                trace(TRACE_SECTION_STRATEGY_RENDER) {
                    SubcomposeAsyncImageContent(
                        contentScale = ContentScale.Crop,
                        modifier = Modifier.fillMaxSize(),
                    )
                }
            }
        }
    }
}

5. Ручной Painter в обычном Box

Самый “олдскульный” вариант — никакого SubcomposeLayout вообще. Создается переменная состояния и решается через обычный when, что нарисовать. Никаких прерываний фазы измерения — чистая, обычная рекомпозиция, как в любом другом месте приложения.

PainterBox
/**
 * Strategy 5: rememberAsyncImagePainter with manual Box state.
 * No SubcomposeLayout. Uses standard recomposition for state changes.
 */
@Composable
internal fun PainterBoxSlot(
    item: BenchmarkItemUio,
    imageLoader: ImageLoader,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current

    val imageRequest = remember(item.id) { item.toMainImageRequest(context) }

    val painter = rememberAsyncImagePainter(
        model = imageRequest,
        imageLoader = imageLoader,
        contentScale = ContentScale.Crop,
    )
    val state by painter.state.collectAsState()

    Box(modifier = modifier.fillMaxSize()) {
        when (state) {
            is AsyncImagePainter.State.Loading,
            is AsyncImagePainter.State.Empty,
            -> {
                trace(TRACE_SECTION_STRATEGY_RENDER) {
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .shimmerEffect(),
                    )
                }
            }

            is AsyncImagePainter.State.Error -> {
                trace(TRACE_SECTION_STRATEGY_RENDER) {
                    ErrorPlaceholder(
                        modifier = Modifier.fillMaxSize(),
                    )
                }
            }

            is AsyncImagePainter.State.Success -> {
                trace(TRACE_SECTION_STRATEGY_RENDER) {
                    Image(
                        painter = painter,
                        contentDescription = item.title,
                        contentScale = ContentScale.Crop,
                        modifier = Modifier.fillMaxSize(),
                    )
                }
            }
        }
    }
}

Если коротко: 1 и 2 — рисуют напрямую и быстро, разница только в наличии нормального экрана ошибки. 3 и 4 — используют “официальный”, но потенциально более дорогой путь через SubcomposeLayout, с разной степенью дополнительных расходов.

Методология: 500 локальных картинок, отсечение сети и защита от троттлинга

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

  • Всё, кроме картинки, — одинаковое. Одна и та же карточка (отступы, тени, теги, лайки) для всех пяти. Картинке передаётся один и тот же Modifier снаружи — ни одна стратегия не может применить свой размер или обрезку, который случайно окажется для неё удобнее.

  • 500 предзагруженных картинок. Весь датасет — локальные файлы. Coil в проекте физически не умеет ходить в сеть — соответствующий модуль просто не подключён.

  • Задержка и ошибки прописаны заранее, а не случайны. Каждая карточка знает, сколько миллисекунд “грузиться” (посчитано от реального размера файла) и должна ли упасть с ошибкой.

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

  • Релизная сборка, прошедшая обфускацию и шринкинг. benchmark-сборка — с минификацией, без отладочных костылей, с зафиксированным режимом компиляции, чтобы не ловить шум от прогрева JIT между запусками.

  • Только реальные устройства. Сама библиотека Macrobenchmark предупреждает, что цифры с эмулятора недостоверны. Бюджетный планшет (Samsung SM-T595) и более мощный флагман того же поколения (Samsung SM-G9750).

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

  • Достаточно сложная стратегия скролла. Каждый прогон гоняет список по шести сценариям: быстрый вниз, быстрый вверх, мелкое дрожание, хаос из перебиваемых свайпов, медленный контролируемый скролл, финальный скролл. Каждая стратегия запускалась по 10 раз, с паузами на охлаждение между прогонами, чтобы устройство не начало троттлить. В целом прогон одной стратегии занимает от часа до двух. Я делал 5 прогонов на каждом устройстве, меняя порядок стратегий местами — чтобы исключить эффект “холодного” устройства для стратегии, которая шла первой.

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

А что скажут нейросети? Прогнозы Claude, DeepSeek, Gemini и Qwen

Для начала я загрузил код самих стратегий и бенчмарка в различные нейронки (Claude, DeepSeek, Gemini, Qwen), и попросил сделать прогноз.

  • Claude поставил PainterBox на третье место — впереди обеих Subcompose-стратегий. Оценил разрыв по P99 в 15-40%, а по ручной метрике trace() — аж в 2-5 раз, при этом заранее честно предупредил, что именно эта метрика может оказаться нечестной по отношению к Subcompose-стратегиям. Как выяснилось — оказался прав именно в этом: сама ловушка из Вывода 3 была предсказана заранее, ничего не зная о реальных данных.

  • DeepSeek дал ту же базовую последовательность, но с куда более конкретной таблицей процентов — вплоть до “ContentSlot медленнее Direct на P99 на 45-80%”. Второй ИИ подряд, отдельно и заранее поймавший методологическую ловушку с trace(). Итоговый вывод: “победитель — AsyncImage (Direct Canvas)”.

  • Gemini дал ту же последовательность, но с самыми агрессивными числами среди всех четырёх — вплоть до “Subcompose медленнее Direct на 50-75%”. Обоснование звучало солидно и технически подкованно, но реальный разрыв оказался существенно скромнее — максимум ~20% даже на самом слабом из двух устройств.

  • Qwen дал самое детальное и технически насыщенное объяснение из всех четырёх — вплоть до точного описания механики “сначала компонует слот loading, измеряет, отбрасывает, потом компонует слот success”. Забавный момент: базовая оценка P99 для прямого Canvas (~14-16 мс) оказалась на удивление близка к реальному P99 на флагмане (15.18 мс), хотя для планшета промахнулся куда сильнее (там реальный P99 — почти 30 мс).

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

Цифры, графики и разрушение мифов

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

На бюджетном планшете (SM-T595) получилось почти как пишут на форумах. Стратегии с SubcomposeLayout (3 и 4) действительно отстают — на отметке P90 у них кадр занимал на 12-21% дольше, чем у прямого Canvas-рисования. Хуже всех — стратегия 4, та самая с одной большой лямбдой и пересчётом внутри: она платит и за прерывание измерения, и за пересчёт поверх него, и это видно по цифрам. Причем самый жёсткий перцентиль (P99) - на всех стратегиях был практически одинаковым, но об этом позже.

На флагмане (SM-G9750) немного по другому. На быстром процессоре сам механизм SubcomposeLayout стал настолько дешёвым, что перестал вносить существенную нагрузку. Худшей неожиданно оказалась стратегия 5, без сабкомпозиции, которую по идее должна была выиграть. Ответ в технических счетчиках: у нее событий перерисовки было почти вдвое больше, чем у остальных четырёх. Оказалось, используемый ей API Coil при рециклинге Composable-узла кратковременно сбрасывает стейт в AsyncImagePainter.State.Empty, провоцируя повторный холостой цикл рекомпозиции — даже если картинка уже лежит в кэше и показать её можно мгновенно. Не архитектурная плата, а просто особенность конкретной библиотеки, о которой почти никто не пишет в документации. Так же тут (P99) тоже был практически одинаковым для всех стратегий.

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

Вывод 2: если смотреть только на самый жёсткий перцентиль (P99), разницы почти не видно — проблема в чем-то одном, общим для всех пяти стратегий (скорее всего, само время декодирования картинки), и оно перекрывает архитектурные различия. А вот на P90-P95 та же самая разница видна отчётливо, потому что этот “общий шум” туда ещё не дотягивается. Мораль: если мерить производительность только по P99 — легко упустить реальный эффект, спрятанный на полшага раньше.

Я еще попробовал померить чистое время именно кода отрисовки, отдельно от общего шума кадра — обернул каждую стратегию в собственный секундомер прямо в коде. И получили результат, который сломал всю гипотезу: секундомер показывал, что стратегии с SubcomposeLayout — самые быстрые, а не самые медленные. Это противоположно тому, что говорили framework-овские замеры кадров.

В чем, собственно, дело: сама сабкомпозиция физически происходит внутри фреймворка Compose, на фазе измерения — а не в коде, который обернут секундомером. Это как засекать время “заказа еды в ресторане”, не включая время, которое повар реально готовит блюдо на кухне — мы не увидим эту часть работы, поэтому она и не попадает в секундомер. Ручной замер закрывался до того, как самая дорогая часть работы вообще начиналась.

Это объясняет, почему у Overlay (стратегия 2) здесь такое большое число (+27-32%) — он единственный, где trace() честно оборачивает весь рендер целиком (как у Direct Canvas), просто с редким дополнительным Composable-экраном ошибки поверх. У стратегий 3/4 секундомер, наоборот, видит только маленький кусочек кода внутри слота/ветки — отсюда заниженные цифры.

Вывод 3: если нужно измерить стоимость SubcomposeLayout руками — не стоит доверять голым цифрам секундомера, надо так же сверяться с метриками фреймворка.

Практический вердикт: что ставить в проект?

Среди четырёх стратегий, у которых есть настоящий Composable-экран ошибки, самой стабильной оказалась стратегия 2 (Canvas + оверлей) — она всегда вторая на каждом перцентиле, на обоих устройствах, и ни разу не оказывается худшей. При этом её отставание от простого прямого Canvas — меньше половины миллисекунды (около 2%), на любом перцентиле, на любом из протестированных девайсов. Разгадка простая: дорогой экран ошибки у неё сабкомпозируется только тогда, когда реально что-то сломалось — а таких карточек обычно меньшинство. В 99% кадров она работает буквально как первая стратегия, просто “на всякий случай” носит с собой запасной план на случай ошибки.

Урок 1: “избегайте X” — не универсальный совет, если никто не сказал, на каком устройстве, и на сколько все плохо. Бюджетный планшет и более мощный флагман дали диаметрально противоположные ответы на вопрос “какая стратегия хуже всех”. Но зато дали одинаковый ответ “какая стратегия лучше всех”. Не стоит копировать чей-то совет по производительности из интернета или из нейросети, не проверив (конечно, это касается этой статьи тоже).

Итоги

Если долистали прямо досюда, не читая остальное (это нормально, сам так иногда делаю) — вот шпаргалка:

нужен экран ошибки — лучше использовать Canvas + оверлей (стратегия 2). Не нужен — используем прямой Canvas (стратегия 1).

Приложению не нужен красивый экран ошибки? Бывает и так — например, внутренний dev-инструмент, или место, где ошибка загрузки картинки настолько не критична, что можно вставить просто цветной прямоугольник. Тогда прямой Canvas — однозначно наш выбор, он быстрее всех и по обоим устройствам, и без всяких “но”.

Приложению нужен нормальный UX при сбое? У 80% реальных продакшен-приложений ответ — да, обычно никто не хочет показывать пользователю голый серый квадрат вместо экрана ошибки. В этом случае — Canvas + оверлей. Он платит за красивый экран ошибки настолько незаметную цену (меньше полумиллисекунды на любом перцентиле), что отказываться от него ради чистой скорости прямого Canvas особого смысла нет — это оптимизация ради оптимизации.

А если все-таки очень хочется SubcomposeAsyncImage — например, у тебя сложная логика состояний, которую неудобно тащить руками через Box и when, — по результатам измерений разумнее брать вариант со слотами (loading/success/error), а не монолитную лямбду с ручным when(state) внутри. На слабом железе разница между ними ощутимая — лямбда-вариант платит и за прерывание измерения, и за пересчёт поверх него, а слоты — только за первое.

Вывод по самому методу тестирования и анализу результатов:
Не всегда стоит смотреть только на P99. Реальная разница между стратегиями иногда прячется на P90-P95, а самый жёсткий перцентиль эту разницу может замаскировать.

Это всё можно проверить самому — весь код, полный датасет, сырые данные с обоих устройств и таблицы для сравнения открыты:

  • 📦 Репозиторий: github

  • 🏷️ Релиз с данными этого исследования: coil-subcomposition-v1

  • 📄 Полная методология и таблицы: github/docs

Сам проект написан так, чтобы им можно было пользоваться повторно — не только для сравнения способов загрузки картинок. В планах прогнать похожее сравнение между Coil и Glide на том же самом стенде.

Благодарю за внимание!

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.