KMP компилирует один и тот же Kotlin-код под несколько таргетов (в JVM-байткод для Android и в нативный бинарник через LLVM для iOS), поэтому бизнес-логика пишется один раз, а UI на каждой платформе остаётся своим.
Как компилируется: commonMain проходит через JVM-бэкенд для Android и через Kotlin/Native для iOS, который собирает его в Apple-фреймворк с небольшим рантаймом и сборщиком мусора. Никакой VM или моста при старте приложения не поднимается.
Что обычно общее: сеть (Ktor), сериализация (kotlinx.serialization), хранение (SQLDelight, Room), доменные правила, валидация и часто ViewModel через мультиплатформенные артефакты androidx.lifecycle.
Что обычно нативное: UI (если не выбран Compose Multiplatform), навигация и всё, что завязано на ОС: разрешения, пуши, фоновая работа, платежи.
Что не стоит выносить: тонкие обёртки над платформенными API. Написать их дважды часто дешевле, чем держать пару expect/actual вместе с интеропом.
Зачем это всё: одна реализация правил означает, что два приложения не могут тихо разойтись в ценах, валидации или кэшировании.
KMP стабилен с ноября 2023 года, и Google официально поддерживает его для общей бизнес-логики Android и iOS.
AndroidSenior
Kotlin Multiplatform (KMP)
expect/actual, source set-ы, шаринг логики, ktor, SQLDelight, interop с iOS
Закрыто вопросов
0 / 16
- 0Повторить
- 1Часто спрашивают
Вопросы
expect объявляет API в commonMain без тела, а каждый платформенный source set даёт свою реализацию actual; если хотя бы у одного таргета её нет, сборка падает.
Работает для функций, свойств, объектов и классов; actual для класса может быть typealias на существующий платформенный тип. Функции и свойства стабильны; expect/actual-классы в Kotlin 2.x всё ещё в Beta и выдают предупреждение, если не передать -Xexpect-actual-classes.
Хорошо подходит для маленьких листовых хелперов, у которых ровно одна реализация на платформу: имя устройства, typealias на платформенный тип.
actual может лежать в промежуточном source set вроде iosMain, и одна реализация покрывает все iOS-таргеты.
Интерфейс плюс DI лучше, когда в тестах нужен фейк, реализацию выбирают в рантайме или её пишут на Swift. actual фиксируется на этапе компиляции, и подложить фейк некуда.
У каждого Gradle-таргета свой листовой source set, а default hierarchy template надстраивает над ними общие наборы (iosMain, appleMain, nativeMain) вплоть до commonMain, который компилируется под все таргеты.
Листовые наборы: по одному на таргет: androidMain, iosArm64Main, iosSimulatorArm64Main, jvmMain, wasmJsMain. Код, который действительно зависит от архитектуры, встречается редко.
Промежуточные наборы: iosMain служит родителем всех iOS-таргетов, appleMain добавляет macOS, watchOS и tvOS, nativeMain покрывает всё, что собирает Kotlin/Native. Код там может вызывать Apple API и при этом пишется один раз.
Правило: source set может использовать только то, что поддерживают все таргеты под ним. iosMain может импортировать UIKit; commonMain видит только общую stdlib и мультиплатформенные библиотеки.
Зависимости объявляются по source set: ядро Ktor в commonMain, движок OkHttp в androidMain, движок Darwin в iosMain.
Тесты повторяют структуру: у каждого xxxMain есть xxxTest, а commonTest запускается на всех таргетах.
Default hierarchy template создаёт промежуточные наборы автоматически начиная с Kotlin 1.9.20.
actual лежит в листовом source set iosArm64Main, поэтому он есть только у таргета для устройства; таргет симулятора тоже компилирует commonMain, находит expect и не находит для него actual.
У каждого таргета должен оказаться ровно один actual для каждого expect: в его листовом наборе или в любом промежуточном наборе выше.
Исправление: перенести файл в src/iosMain/kotlin. С default hierarchy template iosMain становится родителем iosArm64Main и iosSimulatorArm64Main, так что одного actual хватает обоим.
Почему проскакивает: Xcode собирает только архитектуру текущего run destination, и разработчик, который тестирует на телефоне, таргет симулятора вообще не компилирует. Натыкается на ошибку коллега на симуляторе или CI.
Профилактика: компилировать в CI все объявленные таргеты и считать листовой набор вроде iosArm64Main тревожным сигналом: почти всему iOS-коду место в iosMain или appleMain.
Типичный стек включает Ktor Client с kotlinx.serialization, SQLDelight или Room для хранения, Koin для DI, плюс корутины и kotlinx-datetime; библиотеку можно использовать из commonMain, только если она публикует артефакты для всех ваших таргетов.
Сеть: Ktor 3.x: ktor-client-core в commonMain, а платформенный движок (OkHttp на Android, Darwin на iOS) в платформенных source set.
Хранение: SQLDelight 2.x (вы пишете SQL, он генерирует типизированный Kotlin) или Room 2.7+, у которого поддержка KMP стабильна. Ключ-значение: multiplatform-settings или DataStore.
DI: Koin 4.x или обычная передача через конструктор в небольшом модуле. Hilt работает только на Android и в commonMain появиться не может.
Логирование: Kermit, который пишет в Logcat на Android и в os_log на iOS.
Для Swift: SKIE или KMP-NativeCoroutines, чтобы Flow и sealed-типами можно было пользоваться из Swift.
Правило выбора: библиотека должна публиковать артефакты для всех таргетов. Библиотеки, привязанные к плагину компилятора Kotlin (serialization, Compose), и сгенерированный код (SQLDelight) ломаются первыми при обновлении Kotlin.
Тесты в commonTest компилируются и запускаются на каждом таргете, то есть одни и те же проверки выполняются на JVM, на iOS-симуляторе и на десктопе, и именно поэтому JVM-инструменты вроде MockK и Mockito здесь недоступны.
Фреймворк: kotlin("test") даёт @Test и функции assert* на всех таргетах. ./gradlew allTests запускает всё; jvmTest, iosSimulatorArm64Test или testDebugUnitTest (AGP 8) запускают один таргет.
Корутины: runTest из kotlinx-coroutines-test мультиплатформенный. Внедряйте диспетчер вместо жёстко прописанного Dispatchers.IO, иначе тест не сможет управлять виртуальным временем.
Flow и HTTP: Turbine проверяет значения, которые эмитит Flow; MockEngine из Ktor подменяет сеть без сервера.
Вместо моков: написанные вручную фейки поверх ваших интерфейсов. Если мок действительно нужен, есть мультиплатформенный плагин Mokkery, но нормой остаются фейки. Это ещё и аргумент за интерфейсы вместо expect/actual: у интерфейса есть фейк, а у actual его нет.
Платформенные тесты: androidUnitTest (androidHostTest с плагином Android-KMP library из AGP 9) и iosTest для кода, который действительно платформенный: обёртка над Keychain, путь к файлу.
Прогон на JVM ловит большинство логических багов за секунды, но поведение Kotlin/Native видно только на симуляторе на macOS-раннере.
KMP подходит, когда нужен нативный UI и общая логика, особенно если Android-приложение уже на Kotlin; Flutter или React Native подходят, когда одна команда должна как можно быстрее выпустить один UI на обе платформы.
Что работает на устройстве: Flutter везёт свой движок и сам рисует каждый пиксель из AOT-скомпилированного Dart. React Native выполняет JavaScript на Hermes и отображает нативные вью через новую архитектуру (JSI, Fabric). KMP компилирует Kotlin в JVM-байткод и в нативный фреймворк, без рантайма сверх небольшого сборщика мусора.
UI: в KMP UI остаётся нативным, если не добавить Compose Multiplatform; Flutter и React Native делят UI по умолчанию.
Внедрение: KMP входит в существующее нативное приложение по одному модулю. Flutter и React Native обычно означают новое приложение или встроенные экраны (add-to-app, brownfield), которые тянут за собой целый движок.
Команда: KMP подходит Android-сильной команде, в которой есть настоящие iOS-инженеры. Flutter подходит одной кроссплатформенной команде. React Native подходит командам с сильным React или с веб-приложением, с которым хочется делить код.
Типичная цена: KMP: интероп со Swift через Objective-C-заголовки и более медленная сборка iOS. Flutter: platform channels для нативных функций и больший размер приложения. React Native: граница JS/native и боль при обновлениях.
Системные фреймворки Apple вызываются из iosMain напрямую через биндинги, которые поставляет Kotlin/Native; сторонней C- или Objective-C-библиотеке нужен .def-файл cinterop; API только на Swift или код, которым владеет iOS-команда, прячется за Kotlin-интерфейсом с реализацией на Swift.
Фреймворки Apple напрямую
platform.Foundation.*, platform.UIKit.*, platform.Security.* подключаются обычными импортами в любом iOS source set, без настройки.
Классы Objective-C превращаются в Kotlin-классы. C-подобные API вроде SecItemAdd требуют @OptIn(ExperimentalForeignApi::class), работы с указателями внутри memScoped { } и приведений CoreFoundation, поэтому код Keychain получается шумным, но возможным.
cinterop
Для несистемной C- или Objective-C-библиотеки: .def-файл перечисляет её заголовки и флаги линковщика, и Gradle генерирует Kotlin-биндинги. CocoaPods-плагин генерирует их сам из Pod.
Kotlin/Native читает только C- и Objective-C-заголовки. Чистый Swift API без @objc для Kotlin невидим.
Интерфейс с реализацией на Swift
Объявите интерфейс в commonMain; он экспортируется как Objective-C-протокол, и Swift-класс (не struct) ему соответствует. Экземпляр передаётся при старте через Koin или конструктор.
Swift обязан реализовать каждый член: реализации по умолчанию и аргументы по умолчанию через мост не проходят. suspend-член превращается в completion handler, который нужно вызвать ровно один раз на каждом пути, иначе вызывающий Kotlin-код зависнет.
Бонус: в commonTest можно подставить фейк, а iOS-команда держит код на своём языке и в своём репозитории.
У loadProfile нет @Throws, поэтому Kotlin/Native пропускает в Swift как ошибку только CancellationException. ApiException доходит до моста в Objective-C как необработанное Kotlin-исключение, и приложение завершается; catch в Swift так и не выполняется.
Почему так: в Objective-C нет непроверяемых исключений, поэтому сгенерированный заголовок помечает функцию как бросающую (async throws в Swift) только для классов из @Throws. Suspend-функция без аннотации считается @Throws(CancellationException::class), а любое другое исключение обрывает процесс.
Почему на Android всё работает: вызывающий код на JVM ловит любой Throwable, так что баг проявляется только на iOS, обычно в проде на первой же ошибке сервера.
Исправление: @Throws(Exception::class). Указанный класс покрывает и своих наследников, поэтому ApiException, IOException от движка Darwin при таймауте и CancellationException приходят в Swift как NSError, а исходное исключение лежит в userInfo["KotlinException"].
**Указать только ApiException недостаточно**: для suspend-функции компилятор требует, чтобы в списке был CancellationException (или его суперкласс), а даже @Throws(ApiException::class, CancellationException::class) всё равно уронит приложение на сетевом IOException. Нужен общий суперкласс, например Exception.
Как поймать в следующий раз: проверить путь ошибки со стороны Swift (XCTest с Ktor MockEngine, который отвечает 404); JVM-тест это никогда не воспроизведёт. Многие команды также требуют, чтобы каждый публичный API для iOS либо был помечен @Throws, либо возвращал sealed-результат вместо исключения.
Kotlin/Native компилирует общий код в Apple-фреймворк с Objective-C-заголовком, и Swift импортирует его как любой другой фреймворк; боль возникает во всём, что Kotlin умеет выразить, а Objective-C не умеет.
Suspend-функции: приходят как методы async throws, но отмена Swift-Task не отменяет корутину.
Flow: приходит как непрозрачный класс, а не AsyncSequence, поэтому просто пройти по нему через for await нельзя.
Sealed-классы: становятся обычной иерархией классов; Swift-switch не исчерпывающий и требует default.
Дженерики: сохраняются только у классов; у интерфейсов и функций они вырождаются в Any.
Аргументы по умолчанию: в Objective-C их нет, поэтому Swift передаёт каждый параметр.
Исключения: Swift-ошибками становятся только перечисленные в @Throws (плюс CancellationException для suspend-функций); любое другое исключение завершает приложение, даже внутри do/catch.
Имена: одно плоское пространство имён без пакетов; при коллизиях имена искажаются. @ObjCName и @HiddenFromObjC позволяют вычистить публичную поверхность.
Что используют сегодня: SKIE (Touchlab) генерирует Swift-enum для sealed-типов, AsyncSequence для Flow, аргументы по умолчанию и двустороннюю отмену; KMP-NativeCoroutines закрывает только часть с корутинами. Swift export, который выдаёт Swift API напрямую вместо Objective-C-заголовка, пока в статусе Alpha, и возможны ломающие изменения, поэтому проверьте его статус, прежде чем ставить на него релиз.
Что пора перестать повторять: старая модель памяти с заморозкой и InvalidMutabilityException ушли. Текущая модель памяти включена по умолчанию с Kotlin 1.7.20, а старая удалена в 1.9.20.
ViewModel уже сегодня можно делить через мультиплатформенные артефакты androidx.lifecycle, а навигацию стоит выносить в общий код, только если сам UI на Compose Multiplatform; при SwiftUI на iOS навигация остаётся нативной.
ViewModel
ViewModel и viewModelScope резолвятся в commonMain. На Android сохраняется привычное сохранение состояния при смене конфигурации; на iOS со SwiftUI экземпляром никто за вас не владеет, поэтому экран должен владеть им сам и очищать при закрытии: обычно держит ViewModelStore и вызывает clear(), который запускает onCleared() и отменяет scope.
Отдавайте наружу один StateFlow<UiState> и одну точку входа onAction(action). Эта пара составляет весь контракт, к которому привязывается нативный UI, и платформенный код остаётся «глупым».
В старых кодовых базах встречаются компоненты Decompose, MOKO MVVM или самописный класс со своим CoroutineScope.
Навигация
С Compose Multiplatform JetBrains публикует Navigation Compose под все таргеты: тот же NavHost и типобезопасные маршруты, что на Android. Ему нужен Compose UI, поэтому SwiftUI-приложению он не поможет.
Decompose подходит, когда навигация должна жить в общем коде без UI и нужен back stack, который можно покрыть юнит-тестами.
Большинство KMP-приложений с нативным UI держат навигацию нативной, а общие ViewModel только шлют события. Именно в навигации платформенные конвенции (жест «назад», шиты, таб-бары) важнее всего.
Где общий код ломается
Общая ViewModel с Android-привычками (снекбары, расчёт на SavedStateHandle) даёт iOS-экран, который выглядит портированным.
Если делить всё, связываются релизные циклы: изменение для одной платформы приходится тестировать на обеих.
watch запускает сбор в скоупе, который никто не может отменить. Когда экран закрывается, корутина продолжает работать и держит Swift-замыкание, а каждый onAppear добавляет ещё одного коллектора, так что каждое обновление приходит в колбэк дважды, трижды и так далее.
Почему так: CoroutineScope живёт в локальной переменной, а watch возвращает Unit, поэтому снаружи Job никто не держит. StateFlow никогда не завершается, значит, и collect сам не закончится. На Android его остановили бы viewModelScope или repeatOnLifecycle; на iOS владельца жизненного цикла нет, пока общий API его не выдаст.
Это утечка, а не только дубли: работающая корутина держит onEach, а он держит Swift-замыкание и захваченный им self. После нескольких открытий экрана Memory Graph в Xcode показывает несколько экземпляров CartViewModel.
Исправление: вернуть хэндл: небольшой Closeable, у которого close() отменяет скоуп. Swift сохраняет его и вызывает close() в onDisappear или deinit; отмена останавливает collect и освобождает замыкание.
Библиотеки делают это за вас: SKIE отдаёт Flow как AsyncSequence, который отменяется вместе со Swift-Task, а KMP-NativeCoroutines возвращает замыкание для отмены. Самописная обёртка должна хотя бы возвращать хэндл.
Как поймать в следующий раз: commonTest, который подписывается, закрывает хэндл, эмитит ещё раз и проверяет, что колбэк не вызвался; в версии с багом тесту просто нечего закрыть.
Каждый Kotlin/Native-фреймворк встраивает свой рантайм и свою копию каждой подключённой библиотеки, поэтому два фреймворка дают продублированные несовместимые типы; один umbrella-модуль, зависящий от всех фича-модулей, даёт iOS единый рантайм и цельный API.
Что контролирует export()
В заголовок фреймворка попадает публичный API самого umbrella-модуля плюс модули, переданные в export(). Типы из остальных модулей в бинарнике есть, но Swift видит их как непрозрачные объекты без свойств и методов.
export() требует зависимость api в commonMain; экспорт зависимости implementation роняет сборку.
transitiveExport = true экспортирует всё достижимое. Это раздувает заголовок и замедляет сборку, поэтому явный список модулей остаётся лучшим вариантом по умолчанию.
Почему отдельные фреймворки не работают
Модуль :core:model, собранный в два фреймворка, превращается в два несвязанных класса User: передать один в другой не получится, а as? вернёт nil. Каждый фреймворк ещё и несёт свой сборщик мусора и свою копию корутин и stdlib.
Как держать поверхность маленькой
Экспортируйте только то, чего касается Swift: обычно доменные модели и несколько фасадов-точек входа. Модули сети и хранения остаются implementation.
internal и @HiddenFromObjC не пускают хелперы в заголовок.
Время от времени читайте сгенерированный Shared.h: это и есть настоящий контракт, по которому пишет iOS-команда.
Kotlin/Native компилирует заранее и линкует общий модуль, его рантайм и все библиотеки в один фреймворк, поэтому в сборке iOS появляется неинкрементальный шаг линковки, а приложение прибавляет несколько мегабайт.
Время сборки
Изменённые source sets компилируются в klib, затем весь фреймворк линкуется за один проход. Эта линковка доминирует, а Release-линковка в разы медленнее Debug, потому что оптимизирует и не использует кэши компилятора.
Вызывайте embedAndSignAppleFrameworkForXcode из build phase в Xcode: он собирает только ту конфигурацию и архитектуру, которые нужны текущей схеме. Скрипт, запускающий assembleXCFramework или ./gradlew build, каждый раз собирает всё.
Включите build cache и configuration cache Gradle, дайте демонам Gradle и Kotlin достаточно памяти, а в CI кэшируйте ~/.konan и ~/.gradle.
Не отключайте кэши компилятора Kotlin/Native (kotlin.native.cacheKind=none), если только вас не вынуждает конкретный баг: из-за этого каждая сборка становится медленнее.
Размер бинарника
Фреймворк несёт рантайм, сборщик мусора и каждую подключённую klib: Ktor, serialization и корутины добавляют вес каждая.
Release-сборки выкидывают неиспользуемый код, но всё, что экспортировано в Swift, остаётся живым, так что широкая поверхность export() удерживает больше кода.
Статический фреймворк (isStatic = true) избавляет от загрузки динамической библиотеки при старте, но каждый таргет, который его линкует (приложение, виджет, share extension), несёт собственную копию рантайма.
Сначала измеряйте, потом тюньте: --profile или build scan показывает самую медленную Gradle-задачу, а отчёт о размере в App Store Connect показывает, что реально скачивают пользователи.
KMP окупается, когда большой и долгоживущий слой бизнес-логики иначе пришлось бы писать дважды, и стоит дороже, чем экономит, когда общая часть получится тонкой или iOS-команда не готова в этом участвовать.
Хорошо подходит
Продукты с тяжёлой логикой на обеих платформах (банкинг, e-commerce, такси), где расхождение двух приложений реально стоит денег.
Долгоживущие продукты: стоимость настройки платится один раз, а общий слой окупается годами.
Android-команда уже на Kotlin, плюс iOS-инженеры, готовые читать Kotlin и Gradle.
Существующее приложение, которое можно переводить по одному модулю.
Плохо подходит
Короткоживущие приложения, где настройка мультитаргетной сборки съедает весь проект.
Приложения, где почти всё приходится на UI, и общая логика оказалась бы тонкой прослойкой.
В команде нет iOS-экспертизы: KMP не снимает ни капли работы по выпуску отполированного нативного iOS-приложения.
Бизнес на самом деле хочет «написать приложение один раз»: это Compose Multiplatform или Flutter, а не чистый KMP.
Что заложить в стоимость
Более медленная сборка iOS и больший размер приложения.
Работа над интеропом со Swift (SKIE, форма API) и Kotlin-стектрейсы в iOS-крашрепортах.
macOS-раннеры в CI и библиотеки, у которых может не оказаться iOS-артефактов.
Честная формулировка: «общая бизнес-логика, нативный UI».
Начните с одного самодостаточного куска с плотной логикой (API-клиент с моделями или движок расчёта цен), вынесите его в общий модуль, выпустите внутри обоих приложений и только потом шаг за шагом наращивайте общий слой.
Первый кусок
Выбирайте код, который продублирован, уже приносил баги из-за расхождения двух версий и не содержит UI: модели плюс сеть, правила валидации, движок синхронизации.
Android подключает модуль как обычную Gradle-зависимость; как правило, Android-реализация переезжает в него почти без изменений.
На iOS общий код заменяет Swift-реализацию за тем протоколом, который в приложении уже был, поэтому места вызова почти не меняются.
iOS как партнёр, а не потребитель
Решите вопрос доставки заранее: build phase в Xcode в монорепозитории или версионированный XCFramework через Swift Package Manager, если у iOS-приложения свой репозиторий.
Формируйте Swift API через SKIE и @ObjCName, и пусть iOS-инженер ревьюит каждое изменение публичного API.
До первого релиза, а не после первого краша: читаемые Kotlin-фреймы в iOS-крашрепортах и CI, который собирает и тестирует на macOS.
Наращивание
Дальше хранение, затем общие ViewModel; общий UI переносите последним и только если пилотный экран доказал, что он того стоит.
Оставьте путь назад: если каждый кусок спрятан за интерфейсом, которым приложения уже владеют, откат одного куска сводится к возврату старой реализации, а не к переписыванию.
Следите за несколькими цифрами (баги, исправленные один раз вместо двух, время выпуска фичи на обеих платформах, время сборки iOS), чтобы следующий шаг был решением, а не привычкой.