Kotlin Multiplatform
Kotlin Multiplatform делит код между платформами не через общий рантайм, а через общий исходник. commonMain компилируется отдельно под каждую объявленную цель, и компилятор выдаёт per-target артефакт: JVM-библиотеку для Android, нативный .framework (через LLVM) для iOS, .js или .wasm для веба. Ни виртуальной машины, ни моста, ни интерпретатора в рантайме нет — именно этим KMP отличается от Flutter (везёт свой движок и рисует собственные пиксели) и React Native (управляет нативными вью из JavaScript через мост). Платформа сохраняет свой UI и полный доступ к своему API, а общей становится логика. Из того же факта следует главное ограничение темы: раз один и тот же код обязан собраться под каждую цель, commonMain видит только общую (мультиплатформенную) стандартную библиотеку и не может тронуть ни java.*, ни платформенный SDK — это и есть граница commonMain.
Дотянуться от общего кода до платформенного дают три механизма, и все три работают на этапе компиляции, а не в рантайме. Иерархия source set раскладывает код по целям: commonMain — платформенно-независимое, androidMain/iosMain — специфичное, промежуточные наборы (appleMain) делят код между подмножеством целей. Шов expect/actual объявляет API в общем коде без тела, а каждая платформа поставляет actual — и отсутствие хотя бы одного actual роняет компиляцию, а не рантайм. Формат klib — это как KMP-библиотека едет к потребителю: сериализованный IR Kotlin, а не JVM-байткод, который потом доопускается под каждую цель. Всё остальное в теме — следствия: заморозки объектов больше нет (freeze() объявлен, но не компилируется), iOS-интероп течёт через Objective-C-заголовок и теряет часть модели Kotlin, Swift export пока Alpha, а Kotlin/Wasm — Beta. Разбор по слоям — ниже.
Карта темы
- Common и платформенные source set — почему
commonMainсобирается под каждую цель и видит лишь общую stdlib, а платформенный набор вправе звать всё API одной цели. - Объявления expect/actual — как общий код объявляет API без тела, каждая платформа даёт
actual, и почему шов проверяет компилятор, а не рантайм. - Формат klib — почему KMP-зависимость едет как сериализованный IR Kotlin, а не JVM-байткод, и чем это привязывает её к версии компилятора.
- Модель памяти Kotlin/Native — трассирующий сборщик мусора вместо заморозки, почему
freeze()больше не компилируется и откуда берётся гонка данных на iOS. - Интероп с iOS — экспорт через Objective-C-заголовок, что теряется на границе (default-аргументы,
sealed, обобщения) и почему Swift export пока Alpha. - Kotlin/Wasm и веб — компиляция в WebAssembly поверх WasmGC, статус Beta и место рядом с Compose Multiplatform в браузере против старого Kotlin/JS.
- Экосистема KMP-библиотек —
Ktor,kotlinx.serialization,SQLDelight,Koinкак форма «общий API плюс платформенный движок» и почему чистую Java-библиотеку нельзя звать изcommonMain. - Стратегия внедрения KMP — разделяй логику, UI оставляй нативным; постепенная миграция модуль за модулем без заморозки и честные компромиссы.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Ждать, что JVM-API вроде java.io.File виден из commonMain | Общий код собирается под каждую цель и видит лишь общую stdlib — вызов не соберётся под iOS |
| Считать source set папками, которые Gradle сливает в одну компиляцию | Это отдельные компиляции под каждую цель; commonMain обязан собраться под все объявленные |
Думать, что отсутствующий actual — падение в рантайме | Это ошибка компиляции: цель без своего actual просто не соберётся |
Считать, что expect нуждается в теле по умолчанию в commonMain | У expect тела нет вовсе; тело живёт в каждом actual |
Считать шов expect/actual бесплатным и тащить в общий код любой платформенный API | Разрешения, фоновая работа и UI остаются нативными; шов заводят, лишь если общий код обязан их звать |
| Считать JVM-байткод переносимым форматом, который потребляет любая цель Kotlin | Kotlin/Native и Kotlin/JS байткод не потребляют — общий код едет как klib (сериализованный IR) |
Думать, что klib — это мешок готовых бинарников под каждую цель | Внутри — сериализованные объявления Kotlin плюс IR; бинарник компилятор доопускает под цель |
Говорить, что freeze() удалён | Он объявлен, но помечен устаревшим на уровне ошибки — вызов не компилируется, а не «исчез» |
| Считать, что Kotlin/Native до сих пор требует замораживать межпоточный объект | Старого менеджера памяти нет с 1.9.20; трассирующий GC делит объекты между потоками, как JVM |
Хвататься за freeze() как за примитив синхронизации при гонке данных | freeze() им никогда не был; незащищённое состояние в commonMain гоняет и на Android — берите Mutex |
| Ждать, что аргументы по умолчанию Kotlin доживут до Objective-C-заголовка | Objective-C их не знает — Swift требует все параметры; давайте явные перегрузки |
Считать, что sealed class остаётся исчерпывающим для switch в Swift | На границе он приезжает набором обычных классов — Swift просит default; отдайте дискриминатор |
Хвататься за @JvmOverloads в Kotlin/Native | Это JVM-аннотация, на Native она ничего не делает |
| Считать, что Swift export — уже умолчание или стабильный путь | Swift export в статусе Alpha; продакшен-путь к iOS — Objective-C-заголовок |
| Называть Kotlin/Wasm стабильным и брать как прямую замену Kotlin/JS | Kotlin/Wasm — Beta; закладывайте перепроверку на каждом обновлении |
Думать, что Ktor переписывает сетевой стек, и класть его движок в commonMain | Ktor делегирует платформенному движку (OkHttp/Darwin), который подключает платформенный source set |
Считать, что kotlin-reflect доступен в общем коде на всех целях | kotlinx.serialization — плагин компилятора: KSerializer генерируется на компиляции, рефлексия не нужна |
| Считать, что KMP по умолчанию разделяет UI | По умолчанию общей становится только логика; общий UI — отдельный осознанный шаг через Compose Multiplatform |
| Соглашаться на заморозку поставки ради миграции на KMP | Общий модуль потребляется как обычная зависимость — обе команды продолжают релизиться |
Значение для собеседований
Kotlin Multiplatform — тема, где интервьюер проверяет не список фич, а модель исполнения: что физически происходит, когда общий код собирается под чужую платформу. Джуна от middle отделяет один вопрос — «что именно commonMain не может делать и почему»; middle от senior — «что теряется, когда общий модуль пересекает границу Objective-C, и как с этим жить в проде». Правильные ответы почти всегда про этап компиляции: source set — это разные компиляции, expect/actual проверяет компилятор, klib — это IR под доопускание, а не бинарник. И почти всегда против версии: freeze() не «удалён», а не компилируется; Swift export — Alpha; Wasm — Beta.
Что обычно проверяют:
- Чем
commonMainотличается от платформенного source set и где проходит его граница. - Что делают
expect/actualи почему отсутствующийactual— ошибка компиляции. - Что такое
klib, почему KMP-библиотека не публикует JVM-jar и почему привязана к версии компилятора. - Что стало с моделью памяти Kotlin/Native, почему
freeze()не компилируется и что теперь защищает общее состояние. - Как общий модуль попадает в iOS и что теряется на Objective-C-границе (default-аргументы,
sealed, обобщения). - Статус Kotlin/Wasm и Swift export — и что «Beta»/«Alpha» практически означают для релизной ветки.
- Почему
Ktor,kotlinx.serialization,SQLDelightработают изcommonMain, а чистая Java-библиотека — нет. - Где проходит граница шаринга (логика против UI) и как внедрять KMP без заморозки поставки.
Типичный неверный ответ: «KMP — это как Flutter или React Native, только на Kotlin: пишешь общий UI, и он крутится на обеих платформах». Отсюда кандидат выводит, что есть общий рантайм и мост, а значит общий экран медленнее нативного. На деле KMP разделяет логику и компилирует её в нативный бинарник под каждую цель — без VM, без моста, без интерпретатора; UI по умолчанию остаётся платформенным, а его шаринг через Compose Multiplatform — отдельное и более дорогое решение, а не умолчание. Второй классический провал — на вопрос про Kotlin/Native уверенно рассказать про freeze() и заморозку объектов: этой модели нет с 1.9.20, а вызов freeze() сегодня просто не компилируется, и защищать общее состояние надо Mutex или сведением к одному диспетчеру, ровно как на JVM.