Compose Multiplatform (CMP) представляет собой сборку Jetpack Compose от JetBrains для не-Android-таргетов: iOS, desktop на JVM и web на Wasm. Знакомые composable-API (Column, Row, Modifier, Material 3) здесь ровно те же самые.
Связь
Jetpack Compose: Android-рантайм и UI-тулкит от Google.
CMP: multiplatform-сборка того же исходника от JetBrains, синхронная с каждым релизом Compose.
На Android CMP просто делегирует Jetpack Compose: второго рендерера нет.
На iOS и desktop он рисует через Skia на платформенной поверхности.
Зрелость, то есть та часть, в которой до сих пор ошибаются:
Android и desktop: продакшен уже годы.
iOS: Stable с 1.8 (май 2025); см. вопрос про стабильный iOS.
Web на Wasm: Beta. Пользоваться можно, но ждите изменений и большого начального payload.
Выгода: одна UI-кодовая база на несколько платформ. Цена: результат всюду выглядит как ваша дизайн-система, поэтому всё, что обязано ощущаться платформенно-нативным, всё равно требует платформенной работы.
AndroidSenior
Compose Multiplatform
Compose для iOS/desktop/web, ресурсы, навигация, мост с платформенным UI
Закрыто вопросов
0 / 22
- 0Повторить
Вопросы
Потому что специфичен для Android только один слой Compose. Стек состоит из компиляторного плагина Kotlin, рантайма, ничего не знающего про UI, и слоя ui, единственного, который JetBrains пришлось портировать.
Компиляторный плагин (org.jetbrains.kotlin.plugin.compose) переписывает @Composable-функции: протаскивает Composer, расставляет группы и решает, можно ли пропустить вызов. Это плагин Kotlin, поэтому JVM, Native и Wasm получают его даром.
**compose.runtime** владеет snapshot-состоянием, slot table, рекомпозицией, а также интерфейсом Applier. Пикселей внутри нет: рантайм управляет каким-то деревом, а Applier говорит, как это дерево строить. Именно поэтому тот же рантайм умеет управлять DOM-узлами в Compose HTML.
**compose.ui**: это LayoutNode, измерение, отрисовка, ввод, текст и мост в accessibility. На Android он рисует в AndroidComposeView, а на остальных таргетах рисует через Skia и Skiko в Metal, Direct3D или OpenGL. Это и есть портированный слой.
**compose.foundation и compose.material3**: по сути общий код поверх ui, поэтому iOS получил LazyColumn, жесты и Material 3 почти бесплатно, как только заработал слой ниже.
Что из этого следует
Расхождение между платформами почти всегда живёт в ui: ввод, отрисовка текста, insets, interop. Логика layout и ленивых списков лежит в общих частях ui и foundation, поэтому ведёт себя везде одинаково. Отлаживайте её на desktop, где цикл быстрее всего.
Composable-функции не превращаются в нативные виджеты, и никакой UIView на каждый composable не создаётся. Вся сцена представляет собой одну host-view с одним canvas, и ровно поэтому accessibility, выделение текста и interop потребовали отдельной работы по наведению мостов.
У каждого таргета есть тонкая точка входа, единственная задача которой состоит в том, чтобы захостить общий корневой composable.
Android: MainActivity с setContent { App() }. Больше ничего не отличается, потому что на Android это и есть Jetpack Compose.
iOS: функция в iosMain, возвращающая ComposeUIViewController { App() }. Swift её вызывает и либо показывает контроллер из UIKit, либо оборачивает в UIViewControllerRepresentable для SwiftUI.
Desktop: application { Window(onCloseRequest = ::exitApplication) { App() } } в JVM-наборе исходников.
Web: ComposeViewport { App() }, привязанный к DOM-элементу. Это замена более старого CanvasBasedWindow.
Важнее шаблонного кода то, что где лежит. Тема, навигационный хост и старт DI живут внутри App(), чтобы на всех платформах они были одинаковыми. Всё, чем владеет платформа (регистрация push, запускающий intent или URL, запросы разрешений, размер окна на десктопе), остаётся в точке входа и передаётся параметром или колбэком.
У CMP собственная система ресурсов, которая генерирует типизированный объект Res из файлов в ваших source-set; она не использует ни Android-класс R, ни iOS asset catalogs.
Раскладку задаёт директория composeResources/ в любом source-set (обычно commonMain; платформенные source-set тоже могут держать свои):
drawable/: изображения и векторный XML.
font/: шрифты.
values/strings.xml: строки в Android-формате XML, рядом папки-квалификаторы values-ru/, values-de/ для локалей и plurals.
files/: произвольные байты.
Доступ
В композиции: painterResource(Res.drawable.logo), stringResource(Res.string.hello, name), pluralStringResource(...), Font(Res.font.sora_bold).
Вне композиции, как suspend-функции: getString(Res.string.hello), Res.readBytes("files/config.json") и Res.getUri(...), когда путь надо отдать interop-вью.
Multiplatform resources API стабилен с Compose Multiplatform 1.7, так что общим аксессорам больше не нужен экспериментальный opt-in.
Android-only здесь три вещи: класс R вместе с функциями из androidx.compose.ui.res, LocalContext и Intent. Ресурсы переезжают в мультиплатформенный класс Res, а действие «поделиться» становится колбэком, который подставляет каждая платформа.
**R.drawable и R.string**: сгенерированного Android-класса ресурсов в общем коде нет. Положите файлы в commonMain/composeResources/ и используйте painterResource(Res.drawable.ic_share) и stringResource(Res.string.share_title) из org.jetbrains.compose.resources. Имена функций те же, пакет другой, поэтому меняется импорт.
**LocalContext**: на iOS, десктопе и web никакого Context нет. Общий composable, которому он нужен, делает платформенную работу не в том слое.
**Intent**: шторка «поделиться» на iOS реализована через UIActivityViewController, а на десктопе нужна своя реализация. Поднимите это наверх: composable принимает onShare: (String) -> Unit, и каждая точка входа передаёт свою реализацию; либо объявите @Composable expect fun rememberShareAction(): (String) -> Unit, у которого Android-actual читает LocalContext.
То, что компилятор это ловит, и есть смысл разделения на source set'ы: commonMain видит только API, которые существуют на всех таргетах.
Есть два механизма, и выбор зависит от того, насколько зафиксирован список таргетов: expect/actual на composable-функции либо composable-слот, который заполняет точка входа платформы.
expect/actual
Объявите @Composable expect fun PlatformWebView(url: String, modifier: Modifier = Modifier) в commonMain и по одному actual на каждый source set. @Composable нужна с обеих сторон, а значения по умолчанию живут только на expect; повторить их в actual значит получить ошибку компиляции.
Реализацию обязан дать каждый таргет, поэтому добавленный позже wasmJs сломает сборку, пока вы её не напишете. Это защита, если без фичи экран бессмыслен, и рутина, если она опциональна.
Слот, который обычно лучше
Экран принимает webView: @Composable (String, Modifier) -> Unit, и каждая точка входа передаёт своё. Никакой возни с source set'ами, превью и тесты подставляют заглушку, а таргет без настоящей реализации отдаёт placeholder вместо того, чтобы блокировать сборку. CompositionLocal делает то же самое, когда протаскивать лямбду через десять слоёв хуже.
Ловушка: ветвление по платформе для чего угодно, кроме возможностей. if (isIOS) ради layout-решения сломается, как только кто-то сузит окно на desktop; размер берётся из currentWindowAdaptiveInfo(). А проверка платформы в общем коде врёт в превью и тестах, которые идут на JVM.
Правило: expect/actual отвечает на вопрос «есть ли это на платформе», а лямбда отвечает на вопрос «кто это поставляет».
У каждого таргета CMP есть interop-composable, который хостит настоящую платформенную вью внутри Compose-дерева, поэтому «нам тут нужен MapKit» ещё не повод отдавать платформе целый экран.
Android: AndroidView { factory } хостит любую View; AndroidExternalSurface и AndroidEmbeddedExternalSurface нужны для камеры и видео.
iOS: UIKitView { factory } для UIView и UIKitViewController { factory } для UIViewController, через который встраивается и SwiftUI, обёрнутый в UIHostingController. Актуальные перегрузки принимают аргумент UIKitInteropProperties для поведения тач-событий и accessibility вместо прежних отдельных флагов interactive и accessibilityEnabled.
Desktop: SwingPanel { factory } для компонентов Swing и AWT.
Web: DOM-interop развит слабее всех четырёх; считайте его экспериментальным.
Обратное направление важно не меньше: ComposeUIViewController { } оборачивает composable, чтобы его показал UIKit или SwiftUI.
Держите встроенную вью маленькой и листовой. Layout, темизация, навигация и обработка back остаются работой Compose; как только нативная вью начинает владеть навигацией, у вас два конкурирующих back-stack.
factory выполняется один раз, при создании нативного view, поэтому видит только первый center. Значения, которые меняются потом, должны жить в update, который выполняется снова, когда composable рекомпозируется с новыми данными.
Два колбэка, две задачи: factory создаёт view один раз на узел; update выполняется сразу после него и затем каждый раз, когда меняются захваченные им входные данные. AndroidView работает по тому же контракту, так что баг одинаков на обеих платформах.
**Держите update идемпотентным**: update выполняется после factory и снова, когда меняется состояние, которое он читает, или захваченные значения, то есть чаще, чем сам center. Повторная установка того же центра безвредна; если это не так (анимированные перемещения), сначала сравните с текущим centerCoordinate карты.
Обратное направление: сообщать, куда пользователь сдвинул карту, нужно через MKMapViewDelegate, хранящийся в remember, потому что UIKit держит delegate слабой ссылкой.
Берите multiplatform-версию AndroidX Navigation Compose (NavHost, type-safe маршруты, deep links). Вне Android работа уходит в обработку «назад» (свайп от края на iOS, история браузера), подключение deep links под каждую платформу и отсутствие восстановления после process death.
Варианты
Navigation Compose, multiplatform-сборка (org.jetbrains.androidx.navigation:navigation-compose): тот же NavHost, type-safe маршруты и deep links, что на Android. Стабильна с CMP 1.8.
Navigation 3 (back stack как состояние): Alpha на не-Android-таргетах в CMP 1.10, поддержки истории браузера пока нет.
Decompose или Voyager, когда навигация должна жить вне Compose: Decompose компонентный и тестируется вообще без UI, а Voyager предлагает более лёгкий API вокруг объектов-экранов.
Что действительно отличается за пределами Android
На iOS нет системной кнопки «назад». CMP превращает свайп от края в обработку «назад» в Compose, и NavHost делает pop по свайпу, а каждый ваш собственный BackHandler должен работать как жест.
Deep links объявляются один раз, но подключаются тремя способами: intent-filter на Android, associated domains плюс URL-колбэк на iOS и адресная строка на web.
На web поверхностью навигации служит история браузера, и её надо держать синхронной с back-stack.
Process death и восстановление сохранённого состояния относятся к Android; iOS вместо этого приостанавливает приложение, поэтому гарантии rememberSaveable отличаются.
rememberSaveable без Saver принимает только значения, которые помещаются в Android Bundle, а Filter объявлен как обычный data class из общего кода. Реестр на Android отвергает его с IllegalArgumentException уже при первой композиции; desktop значение не сериализует и поэтому ничего не проверяет.
Почему только Android: там rememberSaveable пишет в Bundle сохранённого состояния Activity, поэтому реестр принимает лишь примитивы, String, Parcelable, Serializable и подобное и проверяет значение сразу при регистрации. На desktop нет смерти процесса, которую надо пережить, и любой объект просто хранится в памяти. Если команда разрабатывает на desktop, баг уезжает в релиз.
**Почему не @Parcelize**: Parcelable и java.io.Serializable в commonMain не существуют; сделать общий класс Parcelable можно только обходным путём через expect/actual. Saver же остаётся обычным общим кодом.
Исправление: listSaver (или mapSaver) раскладывает Filter на значения, допустимые для Bundle, и собирает его обратно при восстановлении. Передайте его как stateSaver, который применяется к значению внутри MutableState. Сохраняйте мелочи (запрос и флаги), но никогда не загруженный список.
Как поймать в следующий раз: гоняйте каждый общий экран на Android-таргете в CI и покрывайте сохраняемое состояние через StateRestorationTester в Android UI-тесте: emulateSaveAndRestore() сохраняет и восстанавливает состояние так же, как поворот экрана или смерть процесса.
Compose Multiplatform отображает информацию о безопасных зонах и клавиатуре каждой платформы на тот же самый API WindowInsets, который вы уже используете на Android.
Insets
WindowInsets.safeDrawing, systemBars и ime доступны из общего кода. На iOS они берутся из safe area хостящего view controller, на десктопе почти всегда пустые, на Android это системные панели и вырез.
Применяйте через Modifier.windowInsetsPadding(...) или отдайте это Scaffold через contentWindowInsets и нигде не зашивайте высоту статус-бара.
Modifier.imePadding() закрывает клавиатуру на обеих мобильных платформах.
Рисуйте под панелями и отступайте по insets: на свежих версиях Android edge-to-edge уже обязателен, и только такой подход переживает устройство с другим вырезом.
Адаптивная вёрстка
Ветвитесь по доступной ширине, а не по платформе: рекомендуемым источником данных служит currentWindowAdaptiveInfo(), он даёт класс размера окна, а окно на десктопе, растянутое узко, должно получить телефонный макет.
NavigationSuiteScaffold сам выбирает нижнюю панель, rail или drawer по этому классу размера.
Опишите нарисованное и склейте то, что читается как одно целое: semantics { contentDescription = ... } на Canvas, semantics(mergeDescendants = true) на карточке. Compose отдаёт VoiceOver и TalkBack только дерево семантики, поэтому чего в нём нет, того для скринридера не существует.
На Android дерево превращается в виртуальную иерархию внутри единственного AndroidComposeView, а на iOS превращается в UIAccessibilityElement внутри Compose-овой UIView; на desktop и web мост слабее.
Text и Image(contentDescription = ...) попадают в дерево сами. Canvas, drawBehind и собственные layout'ы не дают ничего: рисование не создаёт семантику.
Карточка, на которую приходится пять остановок фокуса, означает забытый merge. clickable и так склеивает своих детей, поэтому проблема бьёт по некликабельным карточкам и строкам; кликабельной дайте onClickLabel, чтобы скринридер назвал действие глаголом.
testTag служит крючком для тестов, а не подписью, вслух он не читается.
Проверяйте через TalkBack и VoiceOver, но проверки пишутся один раз: onNodeWithContentDescription(...) в commonTest выполняется на всех таргетах.
Превьюйте stateless-composable из commonMain, итерируйтесь на desktop-таргете с Compose Hot Reload, а Xcode открывайте, чтобы проверить поведение на iOS, а не чтобы верстать. Код вёрстки одинаков на всех таргетах, поэтому правьте на самом быстром.
Превью
Начиная с CMP 1.10 androidx-овский @Preview (androidx.compose.ui.tooling.preview.Preview) работает в commonMain. Аннотация от JetBrains и desktop-only версия объявлены deprecated. В проектах на CMP до 1.9 была аннотация JetBrains без параметров, так что проверьте, какой импорт у вас стоит.
Android Studio и IntelliJ IDEA с плагином Kotlin Multiplatform рендерят превью из общего кода.
Превью, которое лезет в DI, репозиторий или suspend-загрузчик, рисуется как блок с ошибкой. Превьюйте stateless-composable на фейковых данных: та же дисциплина делает их тестируемыми.
Hot reload
Compose Hot Reload применяет изменения кода к запущенному desktop-окну и сохраняет его состояние. Он работает только на JVM: для iOS и Wasm это по-прежнему полная перелинковка.
Входит в Compose Gradle plugin с версии 1.10, для desktop-таргета включён по умолчанию (Kotlin 2.1.20+).
Линковка фреймворка плюс сборка в Xcode занимают минуту; перезагрузка на десктопе занимает около секунды. Использовать iOS-симулятор как цикл правок оказывается самым дорогим по часам промахом здесь.
Что всё ещё требует устройства
Insets, клавиатура, жесты, шрифты и ощущение скролла различаются по платформам, поэтому проверьте их на симуляторе или телефоне, прежде чем считать экран готовым.
Модуль CMP подключает org.jetbrains.kotlin.multiplatform, org.jetbrains.kotlin.plugin.compose (компилятор Compose) и org.jetbrains.compose. Обновление Kotlin ломает сборку, потому что плагины компилятора и библиотеки с klib собраны под конкретную версию Kotlin.
org.jetbrains.kotlin.multiplatform объявляет таргеты и наборы исходников.
org.jetbrains.kotlin.plugin.compose: компилятор Compose. Начиная с Kotlin 2.0 он поставляется вместе с дистрибутивом Kotlin и носит его версию, что отменило старую таблицу совместимости, где каждый релиз Compose был прибит к конкретному релизу Kotlin.
org.jetbrains.compose: плагин JetBrains. Он генерирует типизированные аксессоры Res, добавляет задачи упаковки десктопа и (в старых проектах) алиасы compose.*, которые теперь deprecated в пользу записей в каталоге.
Android-модули дополнительно подключают Android Gradle Plugin и берут зависимости Compose из тех же записей каталога.
Почему ломается: всё, что является плагином компилятора или поставляет klib, скомпилировано против конкретного API компилятора Kotlin. Compose, kotlinx-serialization, SQLDelight и процессоры KSP относятся как раз к таким, а klib, собранный более новым Kotlin, старым просто не читается. У обычных JVM-библиотек такой связи нет, поэтому обновление KMP ощущается строже, чем обновление Android-проекта.
Гигиена, которая делает это управляемым: держите всё в version catalog, обновляйте Kotlin и Compose осознанно, а не мимоходом, проверяйте, что версия KSP поддерживает ваш Kotlin (до KSP 2.3 версии выглядели как <kotlin>-<ksp>), и перед стартом читайте в release notes Compose Multiplatform, какой диапазон Kotlin поддержан.
Compose для веба компилируется в WebAssembly и рисует всю страницу на одном canvas. Отсюда и сильные, и слабые стороны.
Сначала о статусе: таргет Wasm находится в Beta, в отличие от стабильных iOS и десктопа. Он работает, на нём выпускают внутренние инструменты, но API между релизами ещё двигается.
Чего стоит модель с canvas
DOM практически отсутствует, поэтому поисковые системы не видят ничего и про SEO можно забыть.
Доступность держится на том, что семантика Compose пробрасывается в браузер, а не на настоящих элементах, так что это не эквивалент хорошо сделанной HTML-страницы.
Выделение текста, поиск по странице, автозаполнение и менеджеры паролей ведут себя не как на обычном сайте: они просто не видят ваш контент.
Первая загрузка включает бинарник Wasm плюс движок отрисовки, то есть мегабайты. На холодном кэше это долго, дальше выручает кэш.
Что придётся сделать самим: кнопка «назад» и история браузера, адресная строка как поверхность навигации, диплинки. На Android всё это даёт система, а в вебе это делаете вы.
И ещё две вещи не надо путать. Compose Multiplatform на Wasm представляет собой UI-тулкит, рисующий на canvas. Compose HTML является другой, более старой библиотекой, которая собирает настоящие DOM-элементы и делит с Compose только рантайм, а не виджеты.
Итог: годится для внутренней админки, демо существующего приложения или показа знакомых экранов небольшой известной аудитории. Не годится для публичного маркетингового сайта и всего, что живёт за счёт индексации.
Gradle-плагин управляет jlink и jpackage: собирает урезанный JDK-runtime (модули по умолчанию плюс те, что вы перечислили в modules(...)), кладёт его рядом с вашими jar'ами и делает нативный инсталлятор для той ОС, на которой вы запустились.
jpackage не умеет кросс-сборку: .msi собирается на Windows, .dmg на macOS, .deb на Linux, поэтому три формата требуют матрицы в CI, а не флага.
jlink выбрасывает модули, так что библиотека, которая рефлексией лезет в java.sql или jdk.unsupported, падает с ClassNotFoundException в установленном приложении, тогда как run работает. Проверяйте через runDistributable, а не run; suggestRuntimeModules подскажет, что добавить.
Варианты ...Release... дополнительно прогоняют ProGuard по вашим jar'ам и зависимостям (меньше размер, обфускация), но не по runtime. Сериализаторам и движкам Ktor нужны keep-правила. Размер инсталлятора определяют встроенные JRE и Skiko, так что ждите десятки МБ в любом случае.
macOS требует подписи и нотаризации, иначе Gatekeeper откажется открывать приложение на машине тестировщика.
Stable означает замороженный публичный API, поддержку в продакшене и закрытые базовые вещи: VoiceOver, ввод текста, interop с UIKit и SwiftUI. Нативным UI это не делает: CMP рисует свои виджеты через Skia на поверхности Metal.
Что покрывает stable (с CMP 1.8, май 2025)
Accessibility: VoiceOver, AssistiveTouch и Full Keyboard Access на основе дерева семантики Compose.
Текст: выделение, лупа и системное контекстное меню.
Interop: UIKitView и UIKitViewController внутри Compose, ComposeUIViewController внутри UIKit или SwiftUI.
Скролл и «назад»: физика скролла как в iOS и свайп от края, связанный с обработкой «назад».
Что всё ещё отличается
Внешний вид: Material 3 по умолчанию выглядит как Material. San Francisco, Dynamic Type и контролы в стиле iOS остаются работой, которую вы делаете сами.
Навигационный хром и контролы: large titles, detents у шторок, пикеры и swipe-to-delete реимплементированы на Compose, и пользователи отличают их от UIKit.
Интеграция с ОС: виджеты, Live Activities, App Intents и share extensions в любом случае остаются нативным кодом.
Цена: дополнительный фреймворк примерно от 10 МБ и время линковки Kotlin/Native в iOS-сборке.
Когда не стоит шарить весь UI
«Неотличимо от нативного» является жёстким продуктовым требованием, как в банкинге или у премиальных потребительских брендов.
Приложение в основном состоит из платформенных интеграций, а не из экранов.
iOS-команда не будет владеть Kotlin-кодом; тогда шарьте логику через KMP и оставляйте SwiftUI.
В продакшене обычно отвечают смесью: CMP для экранов дизайн-системы, которые должны выглядеть одинаково на обеих платформах, и натив для экранов, которые определяют ощущение от приложения.
Общие Compose-UI-тесты действительно гоняются на всех таргетах, iOS-бинарь прибавляет примерно на размер Skia-фреймворка, а оставшийся разрыв в тулинге касается цикла сборки под iOS.
Тесты
runComposeUiTest гоняет те же проверки onNodeWithText и performClick из commonTest на Android, desktop и iOS. Экспериментальный opt-in для test-API всё ещё нужен.
На Android остаётся и привычный путь через JUnit4 createComposeRule.
Тесты ViewModel и логики пишутся как обычные JVM-тесты: быстро, без эмулятора и симулятора. Спускайте на этот уровень как можно больше.
Размер бинаря
iOS-фреймворк с Compose UI прибавляет примерно 10–15 МБ в сжатом виде: Skia, рантайм Kotlin, вшитые шрифты. Тяжелее рукописного Swift.
Android почти ничего не доплачивает, потому что там уже Compose.
Desktop-дистрибутивы весят десятки МБ, потому что несут JRE и движок отрисовки.
Тулинг
Поддерживаемые IDE: IntelliJ IDEA и Android Studio с плагином Kotlin Multiplatform.
Превью и Hot Reload: см. вопрос про быстрый цикл правок; проверка настоящей iOS-сборки по-прежнему означает Xcode.
Отладка проходит через кадры Kotlin и Objective-C; lldb работает, но имена символов шумные.
CI нужен macOS-раннер для всего iOS и Linux-раннер для Android и desktop. Стоят они очень по-разному, и эта разница определяет, что гоняется на каждый PR, а что ночью.
Обмен: меньше экранов, написанных дважды, в обмен на сборку, которую придётся понимать.
Единицей внедрения служит один экран, обёрнутый в ComposeUIViewController и показанный как любой другой view controller.
Обвязка
В iosMain объявляете функцию, возвращающую ComposeUIViewController { SettingsScreen(...) }. Всё, что нужно Compose, остаётся за этой единственной функцией.
В Swift оборачиваете её в UIViewControllerRepresentable для SwiftUI или пушите контроллер в UINavigationController из UIKit.
Входные данные передаются параметрами, результат возвращается колбэком. Compose-экран не должен лезть в синглтоны хост-приложения: именно граница в виде сигнатуры функции позволит потом его удалить или заменить.
Главная ошибка: задвоенная навигация. Стек экранов остаётся у хоста; Compose-экран просит закрыть себя колбэком, а решает NavigationStack. Два спорящих стека дают кнопку «назад», ведущую не туда, и это частая неудача при внедрении.
Швы, которые надо проверить на первом же экране: safe area (какие insets увидит composable, решает хостящий контроллер), клавиатура, интерактивный свайп назад и оформление. Compose-экран приносит свои цвета и типографику и сам собой к остальному приложению не подстроится.
Экономику стоит проговорить прямо. Приложение вырастает примерно на размер фреймворка Compose в тот момент, когда его использует первый экран, так что всю стоимость несёт первый, а десятый почти бесплатен. Именно это, а не код, решает, окупается ли пилот.
ViewModel живёт ровно столько, сколько живёт создавший её ViewModelStoreOwner, а на iOS этим владельцем выступает ComposeUIViewController, который строит ваш Swift-код: пересоздали контроллер, получили новый store, onCleared() у старого и чистое состояние.
androidx lifecycle теперь мультиплатформенный, поэтому код везде один: androidx.lifecycle.ViewModel, viewModelScope, viewModel { } из lifecycle-viewmodel-compose и collectAsStateWithLifecycle из lifecycle-runtime-compose работают из commonMain. Отличается только владелец.
Откуда берётся владелец
Android: Activity, которая переживает смену конфигурации, поэтому там баг и не виден.
iOS: хостящий контроллер. UIViewControllerRepresentable пересоздаёт его, как только SwiftUI решает, что у view новая identity, и весь баг заключается во втором вызове makeUIViewController.
Внутри общего NavHost: NavBackStackEntry. Вызванный там viewModel() привязан к записи, то есть время жизни определяет back stack Compose, а не платформа.
Что делать, по порядку
Держать identity в SwiftUI стабильной (без меняющегося .id(...) и без if/else, которые то вставляют мост, то убирают его), а новые входные данные передавать через updateUIViewController, а не через make.
Поднять владельца выше: родитель на Swift держит живым один Kotlin-холдер состояния (со своим CoroutineScope, который отменяется по завершении сценария) и передаёт его в функцию контроллера.
Опустить состояние ниже UI: в store или репозиторий из DI-графа, который переживает любой экран, оставив ViewModel тонкой проекцией над ним.
Поместить экран в общий NavHost, чтобы скоупом владел back stack на всех платформах.
Не переносится восстановление после убийства процесса. rememberSaveable и SavedStateHandle восстанавливают состояние между записями back stack внутри сессии; iOS приложение усыпляет, а не убивает, и после холодного старта вам никто не отдаст bundle, если вы не сохранили его сами. Спрашивайте, кто владеет store, а не как выглядит экран.
Начните с того, что исключите обычные причины Compose: чаще всего проблема оказывается вовсе не iOS-специфичной.
Прежде всего убедитесь, что вы меряете релизную сборку. Debug-бинарник Kotlin/Native радикально медленнее релизного, и заметная доля жалоб «Compose тормозит на iOS» сводится к сравнению debug-сборки с релизным Android-приложением.
Причины, которые есть на всех платформах и обычно оказываются виноваты:
Нестабильные или только что созданные параметры ломают пропуск рекомпозиции (со strong skipping, включённым по умолчанию с Kotlin 2.0.20, это встречается реже), и перерисовывается целое поддерево.
Состояние читается слишком высоко по дереву: читайте его внутри lambda-модификатора, чтобы анимация вообще ничего не рекомпозировала.
Аллокации и настоящая работа в теле composable.
Ленивый список без стабильных key или элементы, высота которых дорого измеряется.
Картинки, декодированные в полном разрешении ради маленького слота.
Причины, которые действительно про iOS
Первые кадры оплачивают инициализацию Kotlin/Native и прогрев графического конвейера, поэтому первый экран и первый скролл окажутся худшим, что вы увидите. Меряйте установившийся режим отдельно от первого запуска.
Interop-вью внутри скроллящегося списка дороги: каждое из них является настоящим UIView, который композитится с поверхностью Compose. Их должно быть мало, они должны быть маленькими и не в длинных списках.
Блюр, крупные тени и большие offscreen-слои стоят здесь дороже, чем тот же код на десктопе.
ProMotion: CADisableMinimumFrameDurationOnPhone должен быть true в Info.plist, иначе iPhone ограничивает отрисовку 60 Гц. CMP 1.7+ без него не запускается, поэтому ограничение в 60 Гц на iOS означает, что кто-то отключил enforceStrictPlistSanityCheck.
Инструменты: Time Profiler и Core Animation в Xcode Instruments показывают, куда уходят кадры, Kotlin-фреймы видны под искажёнными именами. Счётчики рекомпозиций есть только в Layout Inspector в Android Studio, и это нормально: проблема композиции воспроизводится на Android, а чинится один раз для обеих платформ.
Цикл ссылок между Kotlin и Swift не виден ни одному сборщику: GC Kotlin/Native не видит ссылок ARC, а ARC не видит ссылок Kotlin, поэтому Leaks молчит, а экран не освобождается.
Откуда это берётся в CMP-экранах
Kotlin-синглтон (object AppRouter), хранящий ComposeUIViewController или колбэк, который его захватил.
Регистрации в платформе, которые никто не снимает: block-наблюдатель NSNotificationCenter, KVO, CADisplayLink или NSTimer. Платформа держит Kotlin-блок или target сильной ссылкой, а блок захватывает состояние композиции.
Исключение составляет delegate: UIKit держит его слабой ссылкой, поэтому delegate, созданный прямо в фабрике, освобождается сразу (обратный баг), и хранить его нужно в remember.
CoroutineScope(Dispatchers.Main), созданный прямо в теле composable вместо rememberCoroutineScope(): job не отменяется и держит живой всю композицию.
Как чинить и как проверять
На каждую регистрацию в платформе нужен DisposableEffect с настоящим onDispose, в том же composable, который её сделал.
Колбэки, отданные в Swift, обнуляются при закрытии, а на стороне Swift используется захват [weak self].
Compose-контроллером владеет навигационный стек; Kotlin не хранит UIViewController никогда.
Откройте и закройте экран двадцать раз, затем загляните в Memory Graph Debugger в Xcode: живые контроллеры и есть ответ. Память, вышедшая на плато, нормальна; память, растущая линейно с числом заходов, указывает на цикл через границу.
Правило, которое снимает всё это разом: что композиция зарегистрировала в платформе, то композиция и снимает.