Разработка
Рекомпозиции, которых не было
Как данные о выделении памяти показали то, что Layout Inspector скрывал о рекомпозициях Compose.
Недавно я глубоко погрузился в профилирование, пытаясь понять, почему один экран Compose в продакшене ощущался тяжелее, чем должен. То, что начиналось как обычная проверка производительности, быстро превратилось во что-то большее: Layout Inspector и Memory Profiler показывали совершенно разные картины происходящего. Вот что я обнаружил, что исправил и что хотел бы знать раньше.
⋅ ⋅ ⋅
Если вы достаточно долго работаете с Jetpack Compose, наверняка у вас уже была такая ситуация: открываете Layout Inspector, смотрите количество рекомпозиций, не видите ничего подозрительного и идёте дальше. Я тоже так делал. Пока не задал другой вопрос: а что, если счётчик рекомпозиций точный, но неполный?
Я исследовал обычный BalanceScreen: баланс, список транзакций, несколько рекламных карточек. Экран, который загружает данные один раз, после чего должен практически успокоиться. Layout Inspector именно это и показывал: несколько рекомпозиций после появления состояния — и дальше тишина.
Но таблица выделений памяти рассказывала совсем другую историю. doCompose-aFTINEg: 14 873 выделения памяти. recomposeToGroupEnd: 12 805. ComposableLambdaImpl.invoke: тысячи вызовов глубоко внутри стека. И всё это на экране, который, согласно другим инструментам, практически ничего не делал.
Что-то продолжало проходить через механизмы Compose, чего Layout Inspector либо не видел, либо не показывал так, как мне было нужно.
Почему я вообще начал смотреть на выделение памяти
Меня интересовала реальная стоимость рекомпозиций на этом экране. Не просто сколько раз они происходят, а сколько настоящей работы выполняется при каждой из них. Количество выделений памяти показалось мне более честным сигналом, потому что рекомпозиции оставляют вполне реальные следы: создаются экземпляры лямбд, отслеживаются объекты RecomposeScopeImpl, вызывается ComposableLambdaImpl, создаются временные объекты. Они появляются в таблице allocation независимо от того, считает ли Layout Inspector событие полноценной рекомпозицией.
Для анализа я использовал запись Android Studio: Track Memory Consumption (Java/Kotlin Allocations) с фильтром App → Callstack. Я искал элементы среды выполнения Compose, вроде group(), recomposeScopeImpl, синтетические лямбда, вроде BalanceScreen$ExternalSyntheticLambda и всё остальное, что могло показать, какие composable-функции вызываются повторно и насколько часто.
Я не ожидал найти противоречие. Я рассчитывал просто подтвердить данные Layout Inspector, но получить больше деталей.
Когда цифры перестали совпадать
Я открыл Memory Profiler, начал запись и перешёл на BalanceScreen. После раскрытия стеков вызовов и сортировки по количеству allocations картина сразу изменилась.

doCompose показывал почти 15 тысяч выделений памяти, recomposeToGroupEnd — 12 805. Compose Runtime выполнял гораздо больше работы, чем можно было предположить по Layout Inspector.
Внутри call stack такие методы, как recomposeToGroupEnd, skipToGroupEnd, ComposableLambdaImpl.invoke вызывались тысячи раз внутри composable-функций, которые Layout Inspector показывал практически бездействующими.
Что происходило на самом деле?
Layout Inspector увеличивает счётчик, когда завершается полноценная рекомпозиция composable scope. Но прежде чем Compose решит пропустить композабл через skipToGroupEndили выполнить его через recomposeToGroupEnd, движку всё равно приходится проделать некоторую внутреннюю работу. Он повторно проверяет remember, чтобы выяснить, изменились ли ключи, заново создаёт захваченные лямбды, сравнивает параметры, выполняет проверки стабильности.
И всё это может создавать реальные объекты в памяти. Layout Inspector интерпретирует такую работу примерно как «ничего не произошло», поскольку UI в итоге не перерисовался. А Memory Profiler фиксирует каждый созданный объект.
Под этими цифрами скрывались две основные проблемы.
1. Невидимые повторные вычисления. Код внутри композабл выполнялся повторно и создавал временные объекты, не увеличивая формальный счётчик рекомпозиций в Inspector.
2. Скрытые проверки нестабильности. Композабл, которые действительно рекомпозировались, выглядели совершенно нормально в Layout Inspector. При этом Compose фактически перезапускал их защитным образом просто потому, что не мог доказать стабильность типов их параметров.
Я начал изучать код, чтобы понять источники этой работы.
Какие шаблоны я обнаружил
Всего их было пять. И все они находились прямо перед глазами.
Нестабильные классы данных
Это оказалось самой серьёзной проблемой. Compose решает, можно ли пропустить рекомпозицию композабл, проверяя стабильность его параметров. Под «стабильным» подразумевается тип, для которого Compose может на этапе компиляции доказать — если два экземпляра равны, они будут оставаться равными, изменения будут корректно сообщаться через snapshot-систему.
Такие классы, как AccountBalance, Transaction, PromotionsData, TransactionGroupData не были помечены @Immutable. Интуитивно кажется, что класс должен быть стабильным: у него же автоматически генерируется equals(). Но Compose интересует не только равенство. Ему нужны гарантии. Без @Immutable Compose не может быть уверен, что объект не изменят после передачи в композабл. Поэтому он действует осторожно и выполняет рекомпозицию.
С PromotionsData ситуация была ещё хуже: он содержал List<PromotionOffer>. А kotlin.collections.List — это интерфейс. Его фактической реализацией вполне может быть MutableList. На этапе компиляции Compose этого не знает, поэтому считает параметр нестабильным и может рекомпозировать композабл даже тогда, когда содержимое списка совершенно не изменилось.
Исправление:
// Before data class PromotionsData(val offers: List<PromotionOffer> = emptyList()) // After @Immutable data class PromotionsData(val offers: ImmutableList<PromotionOffer> = persistentListOf())
То есть @Immutable для data classes, ImmutableList из kotlinx.collections.immutable вместо List, persistentListOf() при построении состояния во ViewModel.
Layout Inspector не мог показать проблему ясно, потому что с его точки зрения рекомпозиция из-за реального изменения данных и рекомпозиция из-за невозможности доказать стабильность выглядят одинаково.
Объекты, которые создавались заново при каждой рекомпозиции
Внутри карточки с форматированным балансом использовался buildAnnotatedString { ... }. Он выполнялся при каждой рекомпозиции. Каждый раз создавался новый AnnotatedString, новые экземпляры SpanStyle, заново вычислялись цвета темы, снова извлекались font weights. Хотя входные данные при этом не менялись.
Похожая ситуация была и в индикаторе пейджера: AppTheme.colors.labelPrimary.copy(alpha = 0.3f) вызывался внутри цикла repeat. Каждая рекомпозиция, каждая итерация — новый объект Color. Ещё один пример — Brush.verticalGradient(...), который каждый раз создавался заново в фоновом композабл.
// Before — new AnnotatedString on every recomposition
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = AppTheme.colors.labelPrimary, ...)) {
append(amountText)
}
withStyle(SpanStyle(color = AppTheme.colors.labelDisabled, ...)) {
append(" / $thresholdText")
}
},
)
// After — cached until inputs actually change
val primaryColor = AppTheme.colors.labelPrimary
val disabledColor = AppTheme.colors.labelDisabled
val annotatedText = remember(amountText, thresholdText, primaryColor, disabledColor) {
buildAnnotatedString {
withStyle(SpanStyle(color = primaryColor, ...)) {
append(amountText)
}
withStyle(SpanStyle(color = disabledColor, ...)) {
append(" / $thresholdText")
}
}
}
Text(text = annotatedText)kotlin
Почему Layout Inspector этого не показывал? Потому что это не отдельные рекомпозиции. Это allocations внутри composable, который и так рекомпозируется. Inspector считает рекомпозиции скоупа, а не количество объектов, созданных внутри. Поэтому профалер видел тысячи лишних объектов, а Inspector не видел ничего необычного.
Нестабильные лямбда, вызывающие каскад рекомпозиций
В композабл bottom sheet был коллбэк onDismiss, который пересоздавался при каждой рекомпозиции, поскольку он напрямую захватывал изменяемые значения. Каждый раз, когда родительский компонент пересоздался, создавался новый экземпляр лямбда-функции, и каждый дочерний компонент, получивший его в качестве параметра, воспринимал его как «измененный» параметр и пересоздался.
// Before — new lambda instance every recomposition
val onDismiss = {
when (sheetType) { // captures mutable value directly
is SheetType.Success -> onAction(HideSuccess)
is SheetType.Info -> onAction(HideInfo)
// ...
}
}
// After — stable lambda reference across recompositions
val latestAction by rememberUpdatedState(onAction)
val latestSheetType by rememberUpdatedState(sheetType)
val onDismiss = remember {
{
when (latestSheetType) { // reads from stable State reference
is SheetType.Success -> latestAction(HideSuccess)
is SheetType.Info -> latestAction(HideInfo)
// ...
}
}
}
Одна нестабильная лямбда вызывает не одну лишнюю рекомпозицию. Она приводит к рекомпозиции каждого композабл, который получает её в качестве параметра, а потенциально — и их дочерних элементов. Layout Inspector показывал такие рекомпозиции как вполне нормальные, потому что технически они действительно были обоснованными: параметр изменился. Проблема лишь в том, что он изменился без необходимости.
Отсутствующие ключей в lazy-списках
Список транзакций использовал stickyHeader { ... } и itemsIndexed(...)без ключей. Без ключей Compose не может надёжно сопоставить элементы с их идентичностью между рекомпозициями. Когда список обновляется или родительский композабл перестраивается по другой причине, Compose может изменять элементы, которые не изменились или сопоставлять элементы в неправильном порядке, а затем исправлять себя.
Исправление простое key = "header_${section.date}" для stickyHeader и key = { _, transaction -> transaction.uniqueId } для itemsIndexed.
Без ключей Compose не обязательно рекомпозирует больше элементов. Но он может рекомпозировать не те элементы, создавая лишнюю работу по сравнению состояний и множество аллокаций. В профайлере это хорошо видно, а в Layout Inspector все выглядит как обычное поведение lazy list.
Ненужные обертки композабл
ScreenTopBar существовал исключительно для того, чтобы передавать параметры в TopAppBar из дизайн системы. Он принимал onInfoClick, onHeightMeasured, получал LocalDensity.current и передавал всё дальше. Каждая рекомпозиция родительского экрана создавала для обертки собственный скоуп рекомпозиции. То есть ещё один скоуп для отслеживания, ещё одна потенциально изменяемая область, и никакой дополнительной функциональности.
Решение — удалить wrapper и встроить TopAppBar непосредственно в родительский экран.
В Layout Inspector он выглядел как обычный отдельный композабл с обычными рекомпозициями. На самом деле это были чистые накладные расходы, замаскированные под архитектуру.
Результат

В представлении профилировщика видно, что количество выделенных памяти теперь составляет примерно 2000–2800, а количество компонуемых записей значительно сократилось.
Тот же экран после изменений. Количество выделенных памяти снизилось с ~14 800 до нескольких тысяч. Среда выполнения Compose выполняет лишь малую часть работы по сравнению с тем, что было раньше.
Что это изменило в моём подходе
Layout Inspector сообщает вам, что произошла рекомпозиция. Но он не говорит, почему она произошла. Он не умеет различать «рекомпозиция произошла потому, что изменились данные» и «рекомпозиция произошла потому, что Compose не смог доказать стабильность параметров». Чтобы понять это, нужен allocation profiler или хотя бы отчёты о стабильности от Compose compiler.
@Immutable и ImmutableList — это не просто оптимизации. Это аннотации корректности. Без них Compose предполагает худший сценарий и выполняет защитные рекомпозиции. Добавляя их, вы не столько «ускоряете код», сколько сообщаете компилятору правду о своих данных.
remember тоже нужен не только для state. Он помогает стабилизировать идентичность объектов. Если buildAnnotatedString(...) каждый раз возвращает одинаковый результат, он всё равно создаёт новый объект, если его не поместить в remember. А новый объект, если передаётся параметром дальше, уже может спровоцировать следующие рекомпозиции.
Если композабл создаёт 500 объектов за одну рекомпозицию и рекомпозируется 30 раз, Layout Inspector покажет 30 рекомпозиций. Profiler покажет 15 000 аллокаций. Оба инструмента говорят правду. Но только один показывает достаточно информации.
Заключение
Инструменты отладки не просто помогают находить проблемы. Они формируют наше представление о том, как вообще выглядит проблема. Месяцами моё представление о «здоровой рекомпозиции» определялось Layout Inspector. Если счётчик выглядел нормально — значит, экран работает нормально. Я даже не ставил это предположение под сомнение.
Теперь, когда я анализирую экран Compose, первым делом открываю не Layout Inspector. Я открываю Memory Profiler. Смотрю таблицу allocations. Ищу синтетические лямбда, которых там быть не должно. Проверяю подозрительно большие значения ComposableLambdaImpl. Это занимает около пяти минут и обнаруживает проблемы, которые Inspector может вообще не показать.
Если у вас есть Compose-экран в продакшене, который кажется быстрым и у которого Layout Inspector не показывает ничего тревожного, попробуйте посмотреть его allocations. Отсортируйте их по общему количеству. Раскройте стек вызовов. Вполне возможно, вы обнаружите рекомпозиции и лишнюю работу, которых там вообще не должно было быть.
-
Новости4 недели назадВидео и подкасты о мобильной разработке 2026.32
-
Новости3 недели назадGoogle анонсировал Gemini 3.7 Flash всего через три недели после предыдущего релиза
-
Разработка4 недели назад50 вопросов о System Design, ответы на которые должен знать каждый Senior iOS-разработчик: часть 5
-
Разработка4 недели назад14 лет в Android-разработке, но кажется, что мобильная разработка умирает. Реально ли перейти в серверную разработку на Kotlin?
