Irbisa · Шпаргалка

Compose Multiplatform

Compose для iOS/desktop/web, ресурсы, навигация, мост с платформенным UI

Senior-разработчик · 22 вопроса

  1. 01

    Что такое Compose Multiplatform и как он связан с Jetpack Compose?

    Compose Multiplatform (CMP) представляет собой сборку Jetpack Compose от JetBrains для не-Android-таргетов: iOS, desktop на JVM и web на Wasm.

  2. 02

    Jetpack Compose называют Android-тулкитом для UI. Как тогда тот же исходник вообще рисуется на iOS, из каких слоёв всё состоит?

    Потому что специфичен для Android только один слой Compose.

    // Proof that the runtime is not a UI toolkit: compose a plain tree, no compose.ui at all.class Node(val name: String) {  val children = mutableListOf<Node>()} class NodeApplier(root: Node) : AbstractApplier<Node>(root) {…
  3. 03

    В CMP-проекте один App() в общем коде. Что нужно каждой платформе, чтобы вывести его на экран?

    У каждого таргета есть тонкая точка входа, единственная задача которой состоит в том, чтобы захостить общий корневой composable.

    // commonMain@Composablefun App(startRoute: String = "home") {  AppTheme { AppNavHost(startRoute) }}…
  4. 04

    Где в проекте Compose Multiplatform лежат картинки, строки и шрифты и как на них ссылаться?

    У CMP собственная система ресурсов, которая генерирует типизированный объект Res из файлов в ваших source-set; она не использует ни Android-класс R, ни iOS asset catalogs.

    @Composablefun Header(name: String) {  Image(painterResource(Res.drawable.logo), contentDescription = null)  Text(stringResource(Res.string.hello, name))  Text("Branded", fontFamily = FontFamily(Font(Res.font.sora_bold)))}…
  5. 05

    Этот composable компилировался в androidMain, но не собирается в commonMain. Какие Android-only API он использует и чем заменить каждое?

    @Composablefun ShareRow(text: String) {  val context = LocalContext.current  Row {    Image(painterResource(R.drawable.ic_share), contentDescription = null)    Text(stringResource(R.string.share_title))    Button(onClick = {      val send = Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_TEXT, text).setType("text/plain")      context.startActivity(Intent.createChooser(send, null))    }) { Text("Share") }  }}

    Android-only здесь три вещи: класс R вместе с функциями из androidx.compose.ui.res, LocalContext и Intent.

  6. 06

    Общему экрану нужен настоящий WebView на Android и iOS и просто ссылка на desktop. Как выразить это без if (isIOS) в общем коде?

    Есть два механизма, и выбор зависит от того, насколько зафиксирован список таргетов: expect/actual на composable-функции либо composable-слот, который заполняет точка входа платформы.

    // commonMain: the expectation is one function, not a platform type@Composableexpect fun PlatformWebView(url: String, modifier: Modifier = Modifier) // androidMain: actual repeats no default values@Composable…
  7. 07

    Как встроить настоящую нативную вью (MapKit, AVPlayer, WebView) в экран Compose Multiplatform?

    У каждого таргета CMP есть interop-composable, который хостит настоящую платформенную вью внутри Compose-дерева, поэтому «нам тут нужен MapKit» ещё не повод отдавать платформе целый экран.

    // iosMain: a UIView inside the Compose tree@OptIn(ExperimentalForeignApi::class)@Composablefun MapWidget(modifier: Modifier = Modifier) {  UIKitView(    factory = { MKMapView() },…
  8. 08

    Пользователь выбирает другой город, center меняется, а карта не двигается. Почему и как это исправить?

    // iosMain@OptIn(ExperimentalForeignApi::class)@Composablefun CityMap(center: LatLng, modifier: Modifier = Modifier) {  UIKitView(    factory = {      MKMapView().apply {        setCenterCoordinate(CLLocationCoordinate2DMake(center.lat, center.lng), animated = false)      }    },    modifier = modifier  )}

    factory выполняется один раз, при создании нативного view, поэтому видит только первый center.

  9. 09

    Какую библиотеку навигации брать в Compose Multiplatform и что меняется в обработке «назад» и deep links вне Android?

    Берите multiplatform-версию AndroidX Navigation Compose (NavHost, type-safe маршруты, deep links).

    // org.jetbrains.androidx.navigation:navigation-compose@Serializable object Home@Serializable data class Profile(val id: Long) @Composablefun App() {…
  10. 10

    Найдите баг: этот экран с фильтром работает на desktop, а на Android падает сразу при открытии.

    // commonMaindata class Filter(val query: String = "", val onlyOpen: Boolean = false) @Composablefun OrdersScreen(orders: List<Order>) {  var filter by rememberSaveable { mutableStateOf(Filter()) }  Column {    TextField(filter.query, onValueChange = { filter = filter.copy(query = it) })    Switch(filter.onlyOpen, onCheckedChange = { filter = filter.copy(onlyOpen = it) })    OrderList(orders.filter { it.matches(filter) })  }}

    rememberSaveable без Saver принимает только значения, которые помещаются в Android Bundle, а Filter объявлен как обычный data class из общего кода.

  11. 11

    Как в общем Compose-коде учитывать вырез, домашний индикатор и клавиатуру и по чему ветвиться вместо платформы?

    Compose Multiplatform отображает информацию о безопасных зонах и клавиатуре каждой платформы на тот же самый API WindowInsets, который вы уже используете на Android.

    @Composablefun HomeScaffold(content: @Composable () -> Unit) {  val sizeClass = currentWindowAdaptiveInfo().windowSizeClass  val wide = sizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)   Scaffold(…
  12. 12

    VoiceOver читает общий экран как набор безымянных кусков и полностью пропускает рейтинг, нарисованный через Canvas. Что чинить?

    Опишите нарисованное и склейте то, что читается как одно целое: `semantics { contentDescription = ...

    // wrong: drawn, therefore invisible to VoiceOver and TalkBack@Composablefun Rating(stars: Int) {  Canvas(Modifier.size(120.dp, 24.dp)) { drawStars(stars) }}…
  13. 13

    Как настроить быстрый цикл правок для общего Compose UI: превью в commonMain, hot reload и где всё ещё нужен Xcode?

    Превьюйте stateless-composable из commonMain, итерируйтесь на desktop-таргете с Compose Hot Reload, а Xcode открывайте, чтобы проверить поведение на iOS, а не чтобы верстать.

    // commonMainimport androidx.compose.ui.tooling.preview.Preview   // multiplatform since CMP 1.10 @Preview(showBackground = true)@Composablefun OrderCardPreview() {…
  14. 14

    Какие Gradle-плагины подключает модуль Compose Multiplatform и почему обновление Kotlin ломает сборку?

    Модуль CMP подключает org.jetbrains.kotlin.multiplatform, org.jetbrains.kotlin.plugin.compose (компилятор Compose) и org.jetbrains.compose.

    plugins {  alias(libs.plugins.kotlinMultiplatform)     // org.jetbrains.kotlin.multiplatform  alias(libs.plugins.composeCompiler)         // org.jetbrains.kotlin.plugin.compose  alias(libs.plugins.composeMultiplatform)    // org.jetbrains.compose  alias(libs.plugins.androidLibrary)}…
  15. 15

    Что реально можно выпустить на Compose Multiplatform для веба и что там до сих пор больно?

    Compose для веба компилируется в WebAssembly и рисует всю страницу на одном canvas.

  16. 16

    Нужно отдать тестировщикам подписанный .dmg и .msi desktop-таргета. Что делает конвейер упаковки Compose и где он обычно ломается?

    Gradle-плагин управляет jlink и jpackage: собирает урезанный JDK-runtime (модули по умолчанию плюс те, что вы перечислили в modules(...)), кладёт его рядом с вашими jar'ами и делает нативный инсталлятор для той ОС, на которой вы запустились.

    compose.desktop {  application {    mainClass = "MainKt"    buildTypes.release.proguard {      configurationFiles.from(project.file("proguard.pro"))   // keep rules for reflection    }…
  17. 17

    Compose Multiplatform для iOS стабилен с 2025 года. Что именно это покрывает и чем он всё ещё отличается от нативного приложения на SwiftUI?

    Stable означает замороженный публичный API, поддержку в продакшене и закрытые базовые вещи: VoiceOver, ввод текста, interop с UIKit и SwiftUI.

  18. 18

    Во что реально обходится внедрение Compose Multiplatform в тестах, размере бинаря и повседневном тулинге?

    Общие Compose-UI-тесты действительно гоняются на всех таргетах, iOS-бинарь прибавляет примерно на размер Skia-фреймворка, а оставшийся разрыв в тулинге касается цикла сборки под iOS.

  19. 19

    У вас работающее SwiftUI-приложение. Как добавить один экран на Compose Multiplatform, не переписывая его?

    Единицей внедрения служит один экран, обёрнутый в ComposeUIViewController и показанный как любой другой view controller.

    // iosMain: the entire Compose surface the Swift app seesfun SettingsViewController(  userId: String,  onDone: () -> Unit): UIViewController = ComposeUIViewController {  AppTheme { SettingsScreen(userId = userId, onDone = onDone) }…
  20. 20

    Состояние общего экрана переживает поворот на Android, но обнуляется каждый раз, когда SwiftUI показывает Compose-экран. Где это состояние на самом деле живёт?

    ViewModel живёт ровно столько, сколько живёт создавший её ViewModelStoreOwner, а на iOS этим владельцем выступает ComposeUIViewController, который строит ваш Swift-код: пересоздали контроллер, получили новый store, onCleared() у старого и чистое состояние.

    // commonMain: nothing here is platform-specificclass OrderViewModel(private val repo: OrderRepo) : ViewModel() {  val state = repo.orders    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Orders.Empty)}…
  21. 21

    Один и тот же общий экран плавно скроллится на Android и дёргается на iOS. Как к этому подступиться?

    Начните с того, что исключите обычные причины Compose: чаще всего проблема оказывается вовсе не iOS-специфичной.

  22. 22

    Память растёт каждый раз, когда пользователь открывает и закрывает один Compose-экран в iOS-приложении, а Instruments никаких утечек не показывает. Что происходит?

    Цикл ссылок между Kotlin и Swift не виден ни одному сборщику: GC Kotlin/Native не видит ссылок ARC, а ARC не видит ссылок Kotlin, поэтому Leaks молчит, а экран не освобождается.

    // wrong: registers with the platform on every recomposition and never unregisters@OptIn(ExperimentalForeignApi::class)@Composablefun LocationBadge(state: LocationState) {  NSNotificationCenter.defaultCenter.addObserverForName(    name = "LocationChanged", `object` = null, queue = null…