Делать композицию дешёвой и редкой: давать lazy-спискам key и contentType, кешировать тяжёлую работу через remember, читать быстро меняющееся состояние как можно позже и как можно ниже по дереву, а оценивать результат только на release-сборке.
Lazy-списки: items(list, key = { it.id }, contentType = { it.type }): строки сохраняют своё состояние, а слоты переиспользуются между строками одной формы.
**Тяжёлая работа в remember**: сортировка, фильтрация, форматтеры, объекты Brush и Path уходят в remember(keys) { } или во ViewModel, а не в тело composable.
**derivedStateOf: когда состояние меняется гораздо чаще, чем показываемое значение: например, индекс скролла управляет кнопкой «наверх».
Отложенное чтение**: для значений, которые меняются каждый кадр, передавайте () -> T или используйте модификаторы с лямбдой offset { }, graphicsLayer { }, drawBehind { }.
Стабильные входные данные: неизменяемые UI-модели, ImmutableList и никаких новых списков и объектов, собранных прямо в композиции и переданных дочернему composable.
Никакой обратной записи (backwards write): не пишите в состояние, которое уже прочитано в этой же композиции.
Замеры только в release: R8 включён, baseline profile установлен; debug-сборка намного медленнее и врёт про jank.
AndroidSenior
Производительность и стабильность Compose
Стабильность, пропуск рекомпозиции, фазы, ключи Lazy, Modifier.Node, метрики компилятора
Закрыто вопросов
0 / 19
- 0Повторить
- 5Часто спрашивают
Вопросы
Debug-сборка запускает рантайм Compose без R8 и с выключенными оптимизациями ART, поэтому она может быть в разы медленнее того, что получают пользователи: jank, замеренный там, в release может вообще не существовать.
Нет R8: в release R8 ужимает и оптимизирует библиотеки Compose вместе с вашим кодом: инлайнинг, удаление мёртвых веток, слияние классов. Compose представляет собой большую библиотеку, которая едет внутри вашего APK, поэтому выигрывает от этого больше, чем система View.
**debuggable выключает оптимизации ART: рантайм держит код отлаживаемым, а не быстрым, и baseline profile такой сборке не помогает.
Лишнее только для debug: source information для Layout Inspector и хуки Live Edit добавляют работы каждой композиции.
Что профилировать**: build type, собранный из release (initWith), подписанный debug-ключом и не debuggable, плюс <profileable android:shell="true" /> в манифесте, чтобы Studio и Perfetto могли подключиться. Плагин androidx.baselineprofile создаёт вариант benchmarkRelease ровно такого вида.
Счётчики рекомпозиций: исключение: им нужна debuggable-сборка, и счётчики, в отличие от таймингов, там остаются честными.
renders++ выполняет обратную запись (backwards write): она меняет состояние, которое уже прочитано выше в этой же композиции. Запись инвалидирует скоуп, на следующем кадре он рекомпозируется и снова увеличивает счётчик, и так Feed рекомпозируется каждый кадр бесконечно.
Почему зацикливается: Compose перезапускает скоуп, когда меняется прочитанное в нём состояние. Запись после чтения планирует новую рекомпозицию того же скоупа, а значение каждый раз другое, поэтому всё никогда не успокаивается.
Как это выглядит: счётчик рекомпозиций в Layout Inspector, который не останавливается, занятый главный поток в трейсе, расход батареи на экране, который выглядит неподвижным.
Родственная петля: «синхронизация» параметра в состояние прямо из тела (if (x != local) local = x) оказывается той же обратной записью; запись размера из onSizeChanged в состояние, которое этот же composable использует для своего layout, образует петлю обратной связи той же формы: в лучшем случае на кадр позже, в худшем возникают колебания.
Правило: композиция должна быть чистой функцией от входных данных. Записям место в обработчиках событий, в эффектах или во ViewModel.
Исправление: вычисляйте значение, а не синхронизируйте его, а рекомпозиции считайте обычным держателем, который обновляется в SideEffect: это не snapshot-состояние, и оно ничего не запускает.
bannerOffset читается в теле ChatScreen (контент Box выполняется inline), поэтому каждый кадр анимации инвалидирует композицию всего экрана. Перегрузка с лямбдой offset { } переносит чтение в фазу layout: на каждом кадре баннер только заново размещается, без рекомпозиции.
Почему весь экран: чтение состояния подписывает ту фазу, в которой оно произошло. Modifier.offset(y = bannerOffset) вычисляет значение во время композиции, внутри restart-скоупа ChatScreen, поэтому каждое новое Dp перезапускает этот скоуп, заново собирает цепочку модификаторов и рекомпозирует NetworkBanner. MessageList уцелел только потому, что тот же экземпляр messages позволяет его пропустить.
Как работает исправление: offset { } принимает Density.() -> IntOffset, которая выполняется при размещении. Через делегат by обращение к bannerOffset внутри лямбды вызывает State.value во время layout, так что изменение доходит до layout и отрисовки, а композиция о нём не узнаёт. roundToPx() доступен, потому что у лямбды ресивер Density.
Ещё дешевле: graphicsLayer { } откладывает чтение до фазы отрисовки, так что для чистого сдвига пропускается и повторное размещение.
Как поймать: lint-проверка foundation UseOfNonLambdaOffsetOverload подсвечивает offset(x, y), в который приходит значение из State; в Layout Inspector счётчик рекомпозиций ChatScreen растёт во время анимации, а не стоит на месте.
remember без ключей выполняет свой блок один раз, а derivedStateOf пересчитывается только при изменении snapshot-состояния, прочитанного внутри. contacts и query являются обычными параметрами, захваченными при первой композиции, поэтому список навсегда остаётся отфильтрован по первому запросу.
Почему данные устаревают: лямбда замыкает значения первого вызова. Следующие композиции приносят новый query, но remember возвращает старый State, чьё вычисление по-прежнему видит старые значения и не имеет причин перезапускаться: оно вообще не зафиксировало ни одного чтения состояния.
Как работает исправление: входные данные становятся ключами. remember(contacts, query) { } заново фильтрует список ровно тогда, когда меняется любой из них, а иначе возвращает закешированный. Это именно то кеширование, ради которого писался комментарий.
**Когда derivedStateOf уместен**: когда он читает объекты State и его результат меняется реже, чем они: listState.firstVisibleItemIndex > 0 управляет кнопкой «наверх». Здесь результат меняется с каждым нажатием клавиши, так что он добавил бы только лишний snapshot-объект и отслеживание зависимостей.
Для большого списка: перенесите фильтрацию во ViewModel (combine(contacts, query) с flowOn(Dispatchers.Default)), чтобы фильтрация при вводе никогда не выполнялась на главном потоке.
Как поймать: UI-тест, который вводит текст в поле поиска и проверяет число строк; на ревью тревожным сигналом служит remember { } без ключей, в блоке которого упоминается параметр.
key отвечает за идентичность, а contentType отвечает за переиспользование: без него любая строка подходит под любой освободившийся слот, так что слот из-под текстовой строки достанется строке с фото, и рантайм выбросит всё поддерево вместо того, чтобы обновить его.
Что дать списку
items(posts, key = { it.id }, contentType = { it.kind }) в каждом вызове. Ключ удерживает состояние и анимации за нужной строкой, тип содержимого группирует строки, композицию которых реально можно переиспользовать.
Предпочитайте перегрузки items / itemsIndexed вместо repeat внутри одного item { }. Один item занимает один слот: весь блок компонуется целиком, и никакой виртуализации не происходит.
Не кладите LazyColumn внутрь Column(Modifier.verticalScroll()): он получает неограниченную высоту и падает; используйте один ленивый список, а окружающий контент превратите в вызовы item { }.
Держите тело строки дешёвым. Никакого BoxWithConstraints (это SubcomposeLayout, он компонует содержимое прямо во время измерения), никакого декодирования картинок на главном потоке, никакого SimpleDateFormat на строку; форматируйте на уровне модели.
Modifier.animateItem(), заменивший animateItemPlacement() в Foundation 1.7, требует ключа и стоит одной анимации на каждый видимый элемент. Добавляйте там, где это видно, а не везде.
Правило: один ленивый список на ось, key и contentType в каждом вызове items и ничего в теле строки, что вам не хотелось бы выполнять шестьдесят раз в секунду.
Счётчик рекомпозиций показывает, сколько раз restart scope реально перезапустился, и 300 у статичной шапки означают, что её инвалидировали 300 раз; соседняя колонка Skips говорит, удалось ли рантайму пропустить тело.
Как читать две колонки
Recompositions: тело scope выполнилось заново. Skips означает, что родитель перезапустился и вызвал его с равными параметрами, поэтому рантайм пропустил тело. Много skips и мало рекомпозиций считается здоровой картиной; тревожно, когда рекомпозиции растут у composable, входы которого не менялись.
Счётчики привязаны только к restartable-scope. У Column, Row и прочих inline-composable его нет, поэтому их работа отображается на ближайшем restartable-предке.
Сбросьте счётчики, выполните ровно одно взаимодействие и смотрите на дельту. Итог с момента запуска не говорит ни о чём.
Нужна debuggable-сборка с source information, поэтому счётчикам верьте, а тайминги из такой сборки игнорируйте.
Как дойти от числа до причины
Поднимитесь по дереву. Если счётчик родителя совпадает со счётчиком шапки, инвалидация приходит сверху, и именно родитель не смог пропуститься.
Если счётчик принадлежит самой шапке, значит, она напрямую читает snapshot-состояние: collectAsStateWithLifecycle, позицию скролла, значение анимации прямо в теле.
Посмотрите отчёт компилятора по этой функции: restartable без skippable или параметр с пометкой unstable, который каждый кадр приходит новым экземпляром, объясняют большинство случаев.
Чините так: опустите чтение в тот лист дерева, которому оно нужно, отложите его в лямбду или вынесите создание объекта из композиции, а затем сбросьте счётчики и измерьте снова.
Счётчики служат компасом, а не приговором: большое число у тривиального composable не стоит ничего измеримого, а дёрганый кадр при нуле рекомпозиций означает, что проблема в layout или draw.
Включите отчёты и метрики компилятора в блоке composeCompiler, соберите тот вариант, который выпускаете, и откройте <module>-composables.txt: там каждый composable напечатан с заголовком restartable/skippable, а каждый параметр помечен как stable или unstable.
Как включить
Compose-компилятор с Kotlin 2.0 поставляется внутри Kotlin как org.jetbrains.kotlin.plugin.compose, поэтому настраивается через Gradle-расширение composeCompiler { } (reportsDestination и metricsDestination), а не через старый kotlinCompilerExtensionVersion.
Файлы пишутся на этапе компиляции, отдельно для каждого модуля и каждого варианта. Спрячьте включение за Gradle-свойство, чтобы обычные сборки и CI за это не платили, и читайте именно тот вариант, который уходит в релиз.
Что говорят четыре файла
<module>-composables.txt предназначен для чтения человеком: restartable skippable fun PostRow(stable post: Post, unstable tags: List<Tag>). unstable-параметр становится первым подозреваемым; со strong skipping он больше не блокирует пропуск, но сравнивается через ===.
<module>-classes.txt: по каждому типу stable class / unstable class с указанием виноватого поля. Именно здесь выясняется, что одно var или один List испортили всю модель.
<module>-module.json: сводные счётчики. Следите за knownUnstableArguments между релизами; рост означает регрессию.
<module>-composables.csv: те же строки для скриптового сравнения.
Чего отчёт сказать не может
Это статический анализ. Он говорит, что функция может быть пропущена, но не как часто это происходило: skippable-composable всё равно перезапустится, если идентичность параметра меняется каждый кадр, поэтому дополняйте отчёт счётчиками рекомпозиций.
Всё из модуля, собранного без плагина, помечается unstable. Это факт про настройку сборки, а не ошибка моделирования.
Inline-composable вроде Column и Row в отчёте не появляются: у них нет собственного restart scope.
Тип стабилен, когда Compose может доверять equals в вопросе «изменился ли он», а любое изменение публичного свойства сообщается через snapshot-состояние; @Immutable обещает, что не меняется ничего, а @Stable обещает, что изменения идут через snapshot-состояние.
Что считается стабильным без аннотаций
Примитивы, String, enum, функциональные типы и типы из kotlinx.collections.immutable.
Класс, у которого все публичные свойства объявлены как val стабильных типов. Один публичный var обычного типа делает нестабильным весь класс.
Generic-классы стабильны условно: Page<T> стабилен, только если стабилен T.
Тип из модуля, собранного без Compose-компилятора (чистой Kotlin- или Java-библиотеки), не получает вывода стабильности и считается нестабильным.
Почему List<Tag> нестабилен
List является интерфейсом только для чтения, а не неизменяемым типом: за ним может стоять ArrayList, который кто-то меняет на месте. Используйте ImmutableList и конвертируйте через toImmutableList() там, где собирается UI-модель.
Аннотации
@Immutable: все публичные свойства зафиксированы после создания. Обычный выбор для UI-моделей.
@Stable: свойства могут меняться, но только через snapshot-состояние вроде mutableStateOf, а equals для одних и тех же двух экземпляров всегда отвечает одинаково.
Ни одно из обещаний ничем не проверяется. Пометьте класс, который молча мутирует, и экран перестанет обновляться: это баг хуже лишней рекомпозиции.
С strong skipping нестабильный параметр больше не мешает пропуску: он сравнивается по идентичности (===), а не через equals, поэтому заново собранный, но равный объект всё равно рекомпозирует ребёнка.
Strong skipping делает пропускаемым любой restartable-composable: нестабильные параметры сравниваются по идентичности ссылки вместо полного отказа от пропуска, а лямбды мемоизируются автоматически.
Что изменилось
Раньше один нестабильный параметр делал composable непропускаемым, и тот перезапускался вместе с родителем. Теперь в отчёте он всё равно restartable skippable.
Стабильные параметры по-прежнему сравниваются через equals, а нестабильные сравниваются через ===.
Лямбды, объявленные внутри composable, мемоизируются автоматически, поэтому обёртка remember { { ... } }, на которой настаивают старые статьи, для обычного колбэка оказывается просто шумом.
Отключить для одной функции можно через @NonSkippableComposable, а для всего модуля через featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled()) в блоке composeCompiler.
Что он не спасает
Новый экземпляр на каждую рекомпозицию. ArticleCard(tags = article.tags.map(::toTag)) каждый раз создаёт новый список, идентичность не совпадает никогда, и карточка перекомпонуется вечно. Выносите преобразование из композиции.
Чтение состояния слишком высоко в дереве. Пропуск не спасёт composable, который инвалидирует сам себя собственным чтением.
Non-restartable composable. У inline-лямбд (Column, Row, содержимое Box) и у composable, возвращающих значение, нет restart scope, поэтому пропускать просто негде.
Дорогое тело функции. Форматирование даты, сортировка списка или декодирование картинки стоят ровно столько же на тех запусках, которые всё-таки случились.
Layout и draw. Пропуск оптимизирует фазу композиции и ничего не делает с медленным измерением или перерисовкой.
Модель в голове: пропуск решает, запустится ли функция. Большая часть миллисекунд, за которыми охотятся, приходится на то, что она делает при запуске, и на две следующие фазы.
Strong skipping мемоизирует лямбду, но с ключом по тому, что она захватывает: progress каждый кадр новый, значит, и лямбда каждый кадр новая, и Controls видит изменившийся параметр. Захватывайте держатель State вместо значения и читайте значение в момент вызова лямбды.
Почему мемоизация не спасает
Компилятор превращает { onSeek(progress + 10f) } примерно в remember(onSeek, progress) { ... }: стабильные захваты сравниваются через equals, а нестабильные через ===. Новый progress даёт новую лямбду, и пропуска нет.
Если обернуть её самому в remember { } без ключей, идентичность починится, а колбэк сломается: он навсегда запомнит первый progress, и кнопка будет перематывать от давно устаревшей позиции.
Исправление
rememberUpdatedState(progress) держит один объект State на всё время жизни места вызова и при каждой композиции записывает в него свежее значение. Лямбда захватывает этот State, чья идентичность не меняется, и читает .value в момент вызова.
Читайте .value внутри лямбды, а не в теле: чтение в теле снова подписывает композицию.
Часто чище вообще не гонять движущееся значение через колбэк. onSkip = viewModel::skipForward позволяет ViewModel взять позицию, которой она и так владеет.
Правило: колбэк должен захватывать идентичности, а не значения, которые меняются каждый кадр.
Он переписывает каждый composable в обычный код со скрытыми $composer и битовой маской $changed, оборачивает тело в группы slot table и добавляет логику пропуска и перезапуска; так Compose запоминает значения по позиции.
Переписывание
**$composer: дополнительный параметр, протянутый через каждый вызов composable. Это курсор в slot table и причина, по которой composable можно вызвать только из другого composable.
$changed**: битовая маска Int, по несколько бит на параметр, сообщающая вызываемому то, что вызывающий уже знает: same, different, static или uncertain. Ребёнок не сравнивает заново значение, которое родитель уже признал неизменным.
Группы: тело оборачивается в startRestartGroup / endRestartGroup; ветки if и key() получают собственные группы. Ключи групп берутся из места в исходнике. Это и есть позиционная мемоизация (positional memoization).
Пропуск и перезапуск: пропускаемая функция начинается с «ничего не изменилось и $composer.skipping → skipToGroupEnd()». В конце она регистрирует лямбду updateScope { }, которая вызывает функцию снова с теми же аргументами; так Compose потом может рекомпозировать только её.
Slot table
Плоская структура на массивах (исторически gap buffer), которая хранит группы и их слоты в порядке выполнения: значения remember, объекты состояния, параметры, ссылки на узлы.
При рекомпозиции рантайм проходит её в том же порядке, и remember возвращает то, что лежит в текущей позиции. Поэтому remember внутри цикла после перестановки отдаёт не то значение, если не обернуть его в key().
Изменения собираются в change list и затем применяются к дереву LayoutNode через Applier.
С чем вы сталкиваетесь на практике
У inline-composable и composable, возвращающих значение, нет restart-группы, поэтому они не могут перезапускаться и пропускаться сами по себе.
@ReadOnlyComposable убирает генерацию групп для функций, которые только читают, вроде MaterialTheme.colorScheme; @NonRestartableComposable убирает restart-группу у крошечных обёрток.
Состояние в Compose определяется позицией в композиции, поэтому вызов того же composable из другого места вызова создаёт другую группу: старая уничтожается, новая строится с нуля. Перемещать группу, а не пересоздавать, умеет movableContentOf.
Почему позиция определяет идентичность
Slot table ключует каждую группу местом вызова плюс тем, что вы передали в key(). if (wide) Row { Player() } else Column { Player() } содержит два места вызова; уход из одного уничтожает его remember, отменяет LaunchedEffect и сносит его LayoutNode.
rememberSaveable спасает простые значения, но не запущенный эффект, не экземпляр ExoPlayer, не объект позиции скролла и не анимацию в полёте.
Что делает movableContentOf
remember { movableContentOf { ... } } оборачивает контент в вызываемый объект, группу которого рантайму разрешено перемещать. Если контент исчезает из одного места вызова и появляется в другом в пределах одного прохода композиции, существующие слоты и узлы переезжают целиком: состояние, эффекты и идентичность узлов сохраняются.
Если убрать в одном проходе и добавить в следующем, переносить уже нечего: группа уничтожена, вы получите новую.
movableContentOf живёт внутри одной композиции. Поворот, который пересоздаёт Activity, уничтожает её; держите плеер во ViewModel или сами обрабатывайте android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout".
Вызов одного и того же movable content сразу из двух мест даёт две независимые копии, а не один общий экземпляр.
Используйте перегрузки с параметрами (movableContentOf<T>) вместо захвата аргументов, иначе remember без ключей их заморозит.
key(): вариант для соседей
key(id) { Row(...) } фиксирует идентичность среди детей одного родителя, поэтому перестановка или вставка сохраняет состояние каждого; ровно это делает key = { it.id } в LazyColumn. Между разными родителями он ничего не переносит.
Правило: key служит для перестановок внутри одного родителя, movableContentOf предназначен для поддерева, меняющего родителя, а LookaheadScope сверху добавляют, если переезд нужно анимировать, а не выполнять мгновенно.
Модификатор на composed { } представляет собой composable, который выполняется на каждое место применения при каждой композиции, а поскольку два экземпляра composed никогда не равны, он вдобавок ломает сравнение, которым цепочка модификаторов могла бы пропустить работу.
Чего стоит composed
Фабрика материализуется во время композиции, поэтому каждое применение превращается в группу композиции со своими слотами: по одной на элемент, на место вызова, на рекомпозицию. Сто строк списка дают сто маленьких композиций.
У получившегося модификатора нет осмысленного equals, поэтому его нельзя вынести в top-level val и переиспользовать, а родитель, который мог бы скипнуться, видит изменившийся параметр modifier.
Чтение состояния в теле фабрики (не в лямбде draw или layout) инвалидирует композицию, даже когда меняться должна была только отрисовка. Анимация, написанная так, рекомпозирует всё поддерево на 60 Гц.
Что даёт node
Вы разделяете модификатор надвое: ModifierNodeElement<T> (data class с параметрами, у которого equals/hashCode бесплатны) и Modifier.Node (поведение и его изменяемое состояние).
При рекомпозиции рантайм сравнивает элементы. Если они равны, не происходит вообще ничего; если различаются, вызывается update(node), который мутирует существующий узел: без аллокаций, без группы, без композиции.
Узел реализует только нужные интерфейсы (DrawModifierNode, LayoutModifierNode, PointerInputModifierNode, SemanticsModifierNode) и инвалидирует ровно одну фазу через invalidateDraw(), invalidateMeasurement() или invalidatePlacement().
У него есть настоящий жизненный цикл (onAttach/onDetach) и собственный coroutineScope, так что анимация целиком живёт внутри узла и не трогает композицию. currentValueOf(LocalContentColor) читает CompositionLocal, не подписывая на него composable.
composed ещё существует, но фреймворк перевёл все свои модификаторы прочь от него. Модель в голове: composed кладёт модификатор в композицию, а node кладёт его в дерево layout; всё, что реагирует покадрово, должно жить в дереве, где можно инвалидировать одну лишь отрисовку.
BoxWithConstraints построен на SubcomposeLayout: он переносит композицию своих детей в проход измерения, поэтому каждая строка теперь композируется во время layout, при каждом измерении, а не один раз при композиции.
Чего стоит субкомпозиция
Контент нельзя скомпозировать, пока нет constraints, поэтому рантайм запускает вложенную композицию внутри фазы layout. Эта работа не батчится вместе с обычным проходом композиции и повторяется каждый раз, когда элемент заново измеряется с новыми constraints.
У каждого SubcomposeLayout свой SubcomposeLayoutState с учётом слотов и политикой переиспользования. Сотня таких в прокручиваемом списке означает сотню отдельных маленьких композиций, которые создаются и выбрасываются.
Intrinsics не поддерживаются вовсе: родитель, запросивший у SubcomposeLayout minIntrinsicHeight, например Row(Modifier.height(IntrinsicSize.Min)), получает IllegalStateException, потому что контента не существует, пока его не измерили.
Чтение состояния внутри блока инвалидирует и layout, и субкомпозицию, поэтому анимация, зависящая от размера, рекомпозируется каждый кадр.
Что использовать вместо
Если constraints влияют только на то, как вы измеряете, а не на то, что композируете, берите Modifier.layout { measurable, constraints -> } или собственный Layout. Субкомпозиции нет вовсе.
Если родитель должен подстроиться под самого большого ребёнка, IntrinsicSize.Min / Max стоит один дополнительный проход измерения и ноль дополнительной композиции.
Читайте брейкпоинт один раз и повыше (один BoxWithConstraints вокруг всего списка или currentWindowAdaptiveInfo()) и передавайте вниз булев флаг.
LazyLayout служит примером корректного применения субкомпозиции: композировать только видимое и переиспользовать слоты. Берите SubcomposeLayout, когда от constraints зависит сам контент; если зависят только числа, нужен Layout.
Есть три реальных варианта: объявить класс стабильным в stability configuration file, отобразить его в собственный тип или подключить Compose-компилятор к модулю, который его производит. Только отображение в собственный тип не требует ни непроверяемого обещания, ни контроля над модулем SDK.
Почему он вообще нестабилен
Компилятор выводит стабильность из полей класса, но через границу модуля может сделать это только по метаданным, которые пишет плагин Compose-компилятора. У класса из обычного Kotlin- или Java-артефакта их нет, поэтому он нестабилен, даже если все поля объявлены как val.
List, Map и Set являются интерфейсами, за которыми может стоять изменяемая реализация, поэтому одного поля List<Post> достаточно, чтобы нестабильным стал весь класс.
Три варианта
Stability configuration file: composeCompiler { stabilityConfigurationFiles.add(...) }, по одному полному имени в строке, допускаются шаблоны *. Ноль кода. Это непроверяемое обещание: укажите класс, который на самом деле мутирует на месте, и Compose пропустит нужную рекомпозицию, а вы будете отлаживать не краш, а устаревший UI.
Своя UI-модель: преобразуйте тип SDK на границе ViewModel в собственный data class с полями ImmutableList из kotlinx.collections.immutable. Стоит кода маппинга, даёт UI-слой, который не ломается при смене их схемы. Это ответ для всего, что писали не вы.
Подключить плагин Compose-компилятора к модулю: работает, если проблемный модуль ваш: плагин запишет метаданные, и стабильность выведется сама. Модуль, который должен остаться без Compose, может зависеть только от androidx.compose.runtime:runtime-annotation и пометить свои классы @Immutable.
Что в этом ответе поменял strong skipping
Нестабильные параметры больше вообще не блокируют скип: они сравниваются по идентичности экземпляра, а не по equals. Значит, нестабильный тип мешает только тогда, когда источник отдаёт новый экземпляр на каждую эмиссию. Проверьте это первым делом: репозиторий, переотдающий один и тот же закешированный объект, прекрасно скипается, оставаясь нестабильным.
Аннотируйте то, что можете доказать; оборачивайте то, что не можете.
На первом проходе всё холодное: ART интерпретирует код, который никогда не компилировался, классы загружаются на потоке кадра, а slot table, узлы layout и кеши строятся с нуля. Baseline Profile чинит часть с кодом; для остального нужно меньше разных раскладок элемента.
Что именно холодное, по порядку
Код. У свежей установки нет AOT-скомпилированного профиля, поэтому рантайм Compose, система layout и ваши composable выполняются интерпретатором, пока не подтянется JIT. Compose держит на критическом пути куда больше кода фреймворка, чем View-система, поэтому эффект здесь заметнее.
Классы. Каждая composable-лямбда компилируется в класс. Один экран подгружает сотни, а загрузка и верификация происходят на том же потоке, который готовит кадр.
Композиция. Первый проход аллоцирует каждую группу slot table и каждый LayoutNode; последующие только диффят. Внутри LazyColumn каждый отдельный тип элемента платит это заново, впервые попадая на экран.
Отрисовка. RenderThread компилирует шейдерные программы для эффектов, которых ещё не рисовал, а текстовый стек растеризует каждый глиф в атлас, по разу на шрифт и размер.
Что чинит какую часть
Baseline profile чинит пункт 1 и покрывает как ваш код, так и androidx.compose.*, потому что эти классы едут внутри вашего APK, и поэтому Compose-приложения выигрывают от профиля больше, чем View-приложения. Генерируйте плагином androidx.baselineprofile и тестом BaselineProfileRule, который проходит именно этот сценарий: запуск, открытие экрана, флинг списка.
Пунктам со 2 по 4 AOT помогает слабо. Их сокращают меньшее число разных раскладок элемента, честные значения contentType и префетч пары строк за экраном.
Проверяйте, а не предполагайте: прогоните скролл с CompilationMode.None() и с CompilationMode.Partial() и сравните FrameTimingMetric.
Быстрый признак, что перед вами проблема первого запуска, а не вашего кода: adb shell cmd package compile -f -m speed <pkg> компилирует всё заранее, и если лаги исчезли, дело было не в ваших composable.
Ищите offscreen-буферы и fill rate: alpha или blur, заставляющие рендерер выделить слой, тени и непрямоугольные обрезки, а также наслоенные непрозрачные фоны, которые красят одни и те же пиксели по несколько раз.
Обычные виновники
Alpha. Modifier.alpha(0.5f) сводится к graphicsLayer { alpha = 0.5f }, а CompositingStrategy.Auto при alpha меньше 1 переводит слой в offscreen-буфер, чтобы перекрывающиеся дети не смешивались дважды. Если дети не перекрываются, CompositingStrategy.ModulateAlpha применяет alpha к каждой операции рисования и обходится без буфера.
Тени и обрезка. Modifier.shadow(elevation, shape, clip = true) и Surface(shadowElevation = ...) рисуются из outline. У скруглённого прямоугольника есть быстрый путь; произвольный GenericShape превращается в обрезку по path плюс собственный слой, и так на каждой строке списка.
Blur. Modifier.blur() реализован через RenderEffect (API 31 и выше, ниже работает как no-op), и он перерисовывает своё поддерево в буфер каждый кадр, когда содержимое меняется.
Overdraw. Surface внутри Surface внутри Box(Modifier.background(...)) красит одни пиксели трижды. На полноэкранном списке это чистый fill rate.
Валят на GPU, но это не работа GPU: painterResource декодирует на вызывающем потоке во время композиции, а битмап, раскодированный в полном разрешении ради превью, стоит и памяти, и заполнения.
Измерять, а не гадать
Developer options, Debug GPU overdraw, раскрашивает каждый пиксель по числу заливок. Синий считается нормой, а красный означает баг, видимый без профайлера.
System trace в Perfetto показывает RenderThread рядом с frame timeline: длинные слайсы DrawFrame при простаивающем UI-потоке означают, что проблема в отрисовке, а не в рекомпозиции.
adb shell dumpsys gfxinfo <pkg> framestats даёт покадровую разбивку, а FrameTimingMetric превращает те же данные в число, которое можно отслеживать во времени.
Модель в голове: композиция решает, что рисовать, но рендерер платит за то, сколько слоёв и сколько раз поверх. Каждый offscreen-буфер представляет собой выделенную текстуру, в которую сначала рисуют, а потом рисуют ею.
Ставьте два разных по характеру гейта: полностью детерминированный дифф метрик компилятора, отрабатывающий за секунды на каждом PR, и Macrobenchmark-скролл на одном фиксированном устройстве, который что-то значит только тогда, когда вы обуздали шум.
Дешёвый детерминированный гейт
Включите composeCompiler { metricsDestination = ...; reportsDestination = ... } и закоммитьте получившийся *-module.json по каждому модулю. В нём записаны skippableComposables, restartableComposables и число стабильных и нестабильных параметров: чистый вывод компилятора, побайтово одинаковый на одинаковых исходниках.
CI пересобирает файл и сравнивает с закоммиченным. Рост knownUnstableArguments или новый unstable class в *-classes.txt валит PR с однострочной причиной. Ни устройства, ни флейков, ни минут ожидания.
Слепая зона: о поведении в рантайме он не говорит ничего. Идеально skippable composable, который инвалидируется каждый кадр, гейт пройдёт.
Гейт на скролле
Macrobenchmark с FrameTimingMetric, скриптованный флинг по фиксированному набору данных (seed), без сети, без реальных часов, без анимаций переменной длительности. Смотрите frameDurationCpuMs и frameOverrunMs на P50/P90/P99: джанк живёт в хвосте, медиана его прячет.
Зафиксируйте то, что определяет число: одна модель устройства и один образ ОС, зафиксированные частоты процессора, CompilationMode.Partial() (потому что именно так работает реальная установка), минимум 10 итераций, первая отбрасывается как прогрев.
Сравнивайте PR с его merge base в одном джобе на одном устройстве, а не с цифрой, записанной неделю назад. Тепловой режим и обновление ОС двигают абсолютное значение сильнее, чем большинство регрессий.
Валите по относительному порогу с запасом, порядка 10-15% на P90, и запускайте это в merge queue или по метке, потому что джоб с устройством стоит минут.
Проектировать надо не против пропущенной регрессии, а против флейкового гейта: один красный билд, который никто не может воспроизвести, и через месяц гейт просто выключат.