Каждый тип Kotlin сначала отображается в свой аналог в Objective-C и только потом импортируется в Swift, поэтому non-null примитивы и String проходят чисто, а nullable-примитивы и коллекции чисел приходят упакованными.
Примитивы: Int становится Int32, Long превращается в Int64, Boolean в Bool, Double остаётся Double. Int в Swift 64-битный, поэтому на месте вызова нужна конвертация: api.page(size: Int32(n)).
Nullable-примитивы: у Int? нет скалярной формы в Objective-C, поэтому он превращается в KotlinInt?, подкласс NSNumber. Значение читают через .int32Value, а создают как KotlinInt(int: 5).
Коллекции: List<T> становится [T], Map<K, V> превращается в [K: V], Set<T> в Set<T>. Элементы обязаны быть объектами, поэтому List<Int> превращается в [KotlinInt]. ByteArray приходит как KotlinByteArray, а не Data.
Nullability: заголовок помечает типы как nullable или non-null, и Swift проверяет обычные вызовы. В рантайме не проверяет никто: nil, проскочивший через неявно развёрнутый опционал или Any, попадает в non-null параметр Kotlin и роняет приложение глубоко внутри Kotlin-кода.
Практическое правило: в экспортируемых сигнатурах держите non-null примитивы, String, списки и свои data-классы, а параметр делайте nullable везде, где Swift действительно может передать nil.
AndroidSenior
Interop KMP и Swift
ObjC-мост, suspend и Flow в Swift, SKIE, XCFramework, SPM, линковка, Swift export
Закрыто вопросов
0 / 15
- 0Повторить
Вопросы
В Objective-C есть только облегчённые дженерики (lightweight generics) у классов, поэтому параметр типа у Kotlin-класса сохраняется как подсказка для компилятора, а дженерик-интерфейсы, дженерик-функции и kotlin.Result его теряют, и Swift видит Any.
Дженерик-классы: class Page<T> в Swift становится Page<Post>. Облегчённые дженерики стираются в рантайме, так что это лишь обещание компилятору Swift.
Дженерик-интерфейсы: протокол Objective-C не может иметь параметр типа, поэтому interface Repository<T> его теряет, и каждый метод возвращает Any?. Именно поэтому в экспортируемом API KMP часто используют абстрактный класс вместо интерфейса.
Дженерик-функции: в селекторе негде хранить параметр типа, поэтому fun <T> List<T>.secondOrNull(): T? отдаёт в Swift Any?.
Примитивы в дженерик-позициях: упаковываются: Page<Int>.items имеет тип [KotlinInt].
**Result<T>**: sealed interface Result<T> экспортируется как не-дженерик протокол, поэтому нагрузка имеет тип Any?; kotlin.Result<T> из стандартной библиотеки является value-классом и тоже приходит как Any?. Дженерик sealed class сохраняет параметр на базовом классе, но Swift всё равно не может перебрать его исчерпывающе.
Решение в том, чтобы экспортировать конкретные типы: FeedResult и UserResult с типизированной нагрузкой в каждом случае. Обобщённую механику оставьте внутри commonMain, а в заголовок пускайте только конкретные типы.
Четыре вещи выглядят не по-свифтовски: аргумент по умолчанию пропадает, copy() превращается в doCopy(), вторая перегрузка получает подчёркивание, а функция верхнего уровня висит на классе GreetingKt, потому что в Objective-C нет аргументов по умолчанию, перегрузки и свободных функций.
Analytics.shared.track(event: "open", params: [:]): к object обращаются через .shared (к companion object через .companion), а params приходится передавать каждый раз. @JvmOverloads на Native ничего не делает.
User(id: 1, name: "Ada") работает, а user.copy(...) не существует: copy относится к зарезервированному семейству методов Objective-C, поэтому метод экспортируется как doCopy(id:name:). id в Swift имеет тип Int64.
loader.load(id: 42) и loader.load(id_: "ada"): обе перегрузки дают один и тот же селектор loadId:, поэтому компилятор добавляет _ к более поздней.
GreetingKt.greet(user: user): функции верхнего уровня становятся статическими методами класса, названного по имени файла.
Как исправить поверхность API: дайте перегрузкам разные имена, вместо значений по умолчанию добавьте явные перегрузки (SKIE генерирует значения по умолчанию в Swift только там, где вы включили @DefaultArgumentInterop.Enabled), переименуйте через @ObjCName(swiftName = ...), а внутренности спрячьте через @HiddenFromObjC.
Заголовок фреймворка написан на Objective-C, а там нет закрытых иерархий: sealed-тип приходит как обычный класс или протокол с подклассами, поэтому Swift не может знать, что список случаев полный, и требует default.
sealed interface AuthState становится протоколом (а sealed class становится классом), а AuthState.LoggedIn превращается в класс AuthStateLoggedIn, который ему соответствует. data object даёт класс с экземпляром shared.
switch в Swift превращается в цепочку приведений case let s as AuthStateLoggedIn с обязательным default.
У Kotlin enum class та же проблема: это класс со статическими экземплярами, а не enum в Swift.
Настоящая цена: добавьте случай в Kotlin, и when на Android перестанет компилироваться, а сборка iOS не перестанет. Новое состояние молча уходит в default.
Два способа вернуть гарантию:
SKIE генерирует настоящий enum Swift для каждой sealed-иерархии, и switch onEnum(of: state) проверяет компилятор Swift.
**Экспортировать функцию в стиле fold**, которая принимает по лямбде на каждый случай. when проверяет Kotlin, а новый случай добавляет параметр, и все вызовы в Swift перестают компилироваться.
Она экспортируется как метод Objective-C с completion handler, который Swift импортирует как async throws. Отмена Task в Swift до Kotlin не доходит: корутина работает до конца, а её результат выбрасывается.
Сигнатура: suspend fun fetchUser(id: Long): User в заголовке становится fetchUserId:completionHandler:, а в Swift превращается в func fetchUser(id: Int64) async throws -> User. Примитивный результат упаковывается: suspend fun count(): Int возвращает KotlinInt.
Ошибки: функция всегда throws, но как NSError приходят только CancellationException и типы из @Throws. Любое другое исключение завершает приложение.
Отмена: мост запускает корутину и ждёт одного колбэка; обратного канала нет. Экран, закрытый посреди запроса, оставляет загрузку работать.
Решения: SKIE и KMP-NativeCoroutines связывают отмену задачи в Swift с Job в Kotlin. Без плагина экспортируйте функцию, которая запускает работу и возвращает хэндл, который Swift может отменить.
У loadProfile нет @Throws, поэтому ApiException так и не превращается в NSError: рантайм Kotlin/Native считает его необработанным и завершает приложение, а catch в Swift не выполняется. Обрыв соединения роняет приложение точно так же.
Почему баг не виден: любая suspend-функция через мост в Swift становится async throws, поэтому try await компилируется и выглядит безопасно. Без @Throws мост превращает в NSError только CancellationException; любое другое исключение логируется с сообщением "Function doesn't have or inherit @Throws annotation", и программа завершается.
Как работает исправление: @Throws(Exception::class) заставляет сгенерированный мост ловить на границе любой подкласс Exception и передавать его в completion handler как NSError, который Swift затем выбрасывает в catch.
Почему не узкий список: @Throws(ApiException::class, CancellationException::class) тоже компилируется (список у suspend-функции обязан покрывать CancellationException), но при потере соединения Ktor выбрасывает IOException, которого в списке нет, и приложение всё равно падает. Узкий список уместен, только если функция сама ловит всё остальное.
Как поймать в следующий раз: добавьте правило Konsist или detekt для экспортируемого модуля: у каждой публичной suspend fun должен быть @Throws. Подкрепите его XCTest-тестом, который вызывает фасад против стаб-сервера, отвечающего 404.
Сырой Flow доходит до Swift как непрозрачный протокол с единственным методом collect(collector:completionHandler:), поэтому вы либо сами оборачиваете его в API «колбэк плюс хэндл», либо поручаете KMP-NativeCoroutines или SKIE превратить его в AsyncSequence.
Самописная обёртка: Kotlin-функция принимает (T) -> Unit, вызывает launchIn на скоупе и возвращает хэндл отмены. Ни одной зависимости и точные типы, но по обёртке на каждый поток и колбэк вместо того, что Swift может await.
KMP-NativeCoroutines: KSP-плагин плюс Swift-пакет. Объявления помечаются @NativeCoroutines (или @NativeCoroutinesState для StateFlow), а Swift потребляет их через asyncSequence(for:), Combine-паблишеры или async-функции, с проброшенной отменой. Цена складывается из аннотации на каждом объявлении и зависимости на стороне Swift.
SKIE: плагин компилятора, который переписывает фреймворк: Flow<T> становится AsyncSequence с типизированными элементами, и for await posts in repo.posts() работает без всяких аннотаций.
Как выбрать: SKIE подходит, если команда согласна на плагин компилятора, привязанный к версии Kotlin; KMP-NativeCoroutines подходит для Combine или явного подключения; самописная обёртка годится для нескольких потоков. Что бы вы ни выбрали, Swift должен уметь отменить сбор, иначе коллектор живёт вечно.
SKIE представляет собой плагин компилятора от Touchlab, который генерирует слой Swift поверх Objective-C-фреймворка и кладёт его в тот же фреймворк, так что Swift получает enum, async и AsyncSequence без установки чего-либо на стороне iOS.
Что он генерирует
Настоящие enum в Swift для sealed-иерархий, типизированные AsyncSequence для Flow и async для suspend с проброшенной отменой.
Аргументы по умолчанию работают только по явному включению: по умолчанию выключено, включается на объявление через @DefaultArgumentInterop.Enabled (генерирует дополнительные перегрузки, а в документации это названо временным решением).
Чего он стоит
Время сборки: линковка фреймворка теперь ещё и компилирует сгенерированный Swift. На широком экспортируемом API это заметно, и отдельные фичи можно выключить на уровне модуля или объявления в блоке skie { features { } }.
Привязка к версиям: каждый релиз SKIE поддерживает конкретные версии Kotlin, поэтому обновление Kotlin ждёт подходящего SKIE.
Ещё один слой: в стеках крэшей и в отладчике между Swift и Kotlin появляются сгенерированные Swift-фреймы.
Чего он не меняет
Внизу всё тот же Objective-C-мост: дженерик-интерфейсы по-прежнему стираются, nullability в рантайме не проверяется, имена по-прежнему искажаются.
Он дорабатывает уже собранный Objective-C-фреймворк, поэтому несовместим со Swift export.
Честный аргумент: SKIE возвращает две гарантии, которые мост уничтожает и которые дисциплиной не заменить (проверку исчерпываемости компилятором и настоящую отмену). Если экспортируемый API маленький, самописный фасад может обойтись дешевле.
Владейте Kotlin-объектом из одной @MainActor-модели, заставьте Kotlin вызывать все колбэки на главном потоке и никогда не передавайте экспортированные Kotlin-объекты между акторами: это классы Objective-C, и они не Sendable.
Где выполняются колбэки
В Kotlin/Native больше нет заморозки, так что рантайм не мешает фоновому колбэку трогать состояние UI.
Колбэк или коллектор Flow выполняется на диспетчере Kotlin-скоупа. С Dispatchers.Default обновление @Published вызывает предупреждение SwiftUI «Publishing changes from background threads is not allowed», а модель @Observable обновляет вью вне главного потока.
await бриджованной suspend-функции внутри @MainActor-метода возобновляется на главном акторе, поэтому присваивать её результат безопасно. У колбэка-замыкания такой гарантии нет.
Ловушка Swift 6
Замыкание, написанное внутри @MainActor-класса, выводится как изолированное на главном акторе. Когда код Objective-C вызывает его с фонового потока, Swift 6 проверяет изоляцию в рантайме и падает в dispatch_assert_queue.
Поэтому надёжное исправление делается в Kotlin: скоуп ViewModel использует Dispatchers.Main (MainScope() или viewModelScope), а тяжёлая работа уходит в withContext(Dispatchers.Default).
Передача Kotlin-объекта в Task.detached или другой актор падает с ошибкой «risks causing data races». @preconcurrency import Shared глушит её для всего модуля: это костыль на время миграции, а не решение.
Правило: один переход между потоками на поток данных, в одном видимом месте, и место это находится в Kotlin.
guard let self превращает слабый захват в сильный на всё время цикла, а цикл не заканчивается, потому что StateFlow никогда не завершается. Задача держит модель, модель держит Kotlin-ViewModel, а Kotlin-коллектор продолжает работать.
**[weak self] помогает только до guard**: после успешной проверки self становится сильной локальной ссылкой, которая живёт столько же, сколько for await.
Задачу никто не отменяет: неструктурированный Task, запущенный в init, не привязан ни к какому вью. SKIE отменяет сбор в Kotlin только при отмене задачи в Swift.
Kotlin-сторона тоже остаётся живой: работающий коллектор является корнем GC, поэтому ViewModel и его скоуп выживают вместе со всей запущенной в нём работой.
Instruments не показывает утечку: ссылочного цикла нет, есть лишь задача, которая не заканчивается, поэтому Leaks и граф памяти выглядят чистыми.
Исправление: запускайте сбор из .task в SwiftUI, который отменяет его при исчезновении вью, а сам цикл сделайте async-методом модели. Собственный скоуп Kotlin-ViewModel закрывается отдельным шагом, который должен сделать владелец.
Относитесь к экспортируемой поверхности как к спроектированному API с одним потребителем: всё public в общем модуле попадает в заголовок, поэтому экспортируйте только фасады уровня экрана с простыми конкретными типами.
Решите, что выходит из модуля
Репозитории, юзкейсы, мапперы и DI-обвязку делайте internal. Они только раздувают заголовок, бинарник и время сборки.
@HiddenFromObjC убирает из iOS-заголовка объявление, которое должно остаться public для Android.
@ObjCName(swiftName = ...) исправляет имя, которое плохо читается в Swift; @ShouldRefineInSwift экспортирует объявление как __name, чтобы Swift-расширение обернуло его во что-то идиоматичное.
Придайте форму тому, что выходит
Один тип состояния на экран, один поток этого состояния, одна функция на каждое намерение пользователя.
Параметры и результаты: String, числа, Boolean, списки и ваши data-классы. Дженерик-интерфейсы, Result<T> и сырой Flow в сигнатуры не пускайте.
Ошибки передаются как значения в состоянии, а не как исключения через мост.
Пусть Swift доделает остальное
Экспортированные классы остаются классами Objective-C, поэтому расширение Swift в приложении может сделать их Identifiable или Hashable, не трогая Kotlin.
Отобразите Kotlin-enum в enum Swift один раз на границе или поручите это SKIE.
Проверка: если вы не положили бы это в REST-ответ, спроектированный для одного этого приложения, не кладите это в заголовок.
Либо iOS-сборка сама компилирует Kotlin через Gradle, либо она потребляет версионированный XCFramework через Swift Package Manager. Именно этот выбор, а не формат упаковки, iOS-команда ощущает каждый день.
Прямая интеграция, один репозиторий
Фаза Run Script вызывает ./gradlew :shared:embedAndSignAppleFrameworkForXcode, который собирает фреймворк под текущую архитектуру и конфигурацию.
Каждому Mac и CI-раннеру нужны JDK и прогретый кэш Gradle, а сборка Kotlin добавляется к каждой сборке iOS.
Xcode 15+ в новых проектах ставит ENABLE_USER_SCRIPT_SANDBOXING в YES, и скрипт ломается; для этого таргета настройку нужно выключить.
Опубликованный XCFramework, отдельные репозитории
DSL XCFramework() даёт задачу assembleSharedXCFramework: один артефакт со слайсами для устройства и симулятора.
SPM подключает его как .binaryTarget: по локальному path во время разработки или по url плюс checksum для релиза. iOS-сборка никогда не запускает Gradle.
Ценой становится релизный цикл: поднять версию, опубликовать, обновить зависимость. KMMBridge от Touchlab его автоматизирует.
Плагин CocoaPods
kotlin("native.cocoapods") генерирует podspec, а его зависимости pod(...) по-прежнему самый простой способ вызвать из Kotlin SDK, который есть только в виде пода.
CocoaPods в режиме поддержки, а его trunk в декабре 2026 становится только для чтения, так что для новых проектов это наследие.
Правило: один репозиторий и одна команда означают прямую интеграцию; отдельный iOS-репозиторий или SDK для других команд означает XCFramework через SPM.
Статический фреймворк линкуется в каждый бинарник, который его использует; динамический собирается в отдельный образ, который dyld грузит при запуске. Для одного таргета статический проще и чуть быстрее; с расширениями он копирует Kotlin в каждый таргет, поэтому динамический часто меньше.
Статический (isStatic = true)
Kotlin-код, рантайм и сборщик мусора линкуются в исполняемый файл приложения: нет шага embed-and-sign и нет лишнего образа при запуске.
Kotlin-символы попадают в собственный dSYM приложения, который вы и так загружаете.
Каждый таргет линкует свою копию: приложение, виджет и share extension дают три копии рантайма Kotlin в бандле.
Динамический
Один Shared.framework в папке Frameworks/ приложения, который загружают и приложение, и его расширения: одна копия на диске.
Ещё один образ для dyld при каждом запуске: миллисекунды для одного фреймворка, но они складываются, когда фреймворков становится много.
Отдельный Shared.framework.dSYM; если забудете его загрузить, Kotlin-фреймы в отчётах о крэшах будут hex-адресами.
Что линковка не меняет
Приложение и каждое расширение работают в отдельных процессах, поэтому Kotlin object между ними не разделяется никогда. Для общих данных нужен контейнер App Group, например UserDefaults(suiteName:) или файл базы данных в папке группы.
Два статических Kotlin-фреймворка в одном исполняемом файле конфликтуют по дублирующимся символам; используйте один umbrella-фреймворк.
Правило: для одного таргета приложения берите статический; для приложения плюс несколько расширений измерьте бандл и обычно выбирайте динамический. Задавайте isStatic осознанно, а не наследуйте выбор шаблона.
LLDB умеет останавливаться внутри Kotlin, потому что компилятор Kotlin/Native генерирует отладочную информацию DWARF, но только в debug-фреймворке, чьи записанные пути к исходникам существуют на вашей машине; для читаемых значений вдобавок нужны LLDB-форматтеры xcode-kotlin.
Почему брейкпоинт не срабатывает
Фреймворк должен быть debug-варианта. При прямой интеграции задача Gradle следует CONFIGURATION из Xcode, а KOTLIN_FRAMEWORK_BUILD_TYPE=release её переопределяет, и тогда останавливаться не на чем.
Готовый XCFramework с CI хранит абсолютные пути сборочного агента. У LLDB есть символы, но он не найдёт ваш .kt-файл, пока вы не сопоставите пути через settings set target.source-map.
Без плагина Xcode вообще не знает о .kt-файлах, так что в них даже нельзя поставить брейкпоинт.
Как сделать значения читаемыми
xcode-kotlin от Touchlab добавляет подсветку Kotlin, брейкпоинты в .kt-файлах и LLDB-форматтеры, которые показывают Kotlin-объекты с их полями.
Часто лучшим инструментом оказывается Kotlin-IDE: IntelliJ IDEA или Android Studio с плагином Kotlin Multiplatform запускает iOS-приложение и отлаживает Kotlin-код; Xcode оставьте для Swift.
На стыке
Исключение, вылетевшее из Kotlin, завершает процесс; верхние фреймы принадлежат рантайму Kotlin, а ваш код ниже, во фреймах kfun:.
Сохраняйте dSYM фреймворка для релизных сборок, иначе отчёты о крэшах покажут hex-адреса.
Правило: баги Swift отлаживают в Xcode, баги Kotlin в Kotlin-IDE, а смешанная сессия нужна, только когда баг сидит ровно на границе.
Swift export заставляет компилятор Kotlin генерировать Swift-биндинги напрямую, так что API больше не обязан сначала укладываться в Objective-C. Он убирает большую часть проблем с именами и упаковкой, но пока не стабилен и покрывает не всё, поэтому в 2026 году он для прототипов, а не для релизов.
Что он меняет
Kotlin-классы больше не подклассы NSObject. Пакеты отображаются в пространства имён Swift или схлопываются через flattenPackage, а каждый модуль может стать отдельным Swift-модулем.
Nullable-примитивы становятся обычными опционалами Swift вроде Int32? вместо KotlinInt?, а перегрузки сохраняют имена без подчёркиваний.
Меняется сборка: блок swiftExport { } в Gradle и задача embedSwiftExportForXcode вместо embedAndSignAppleFrameworkForXcode.
Чего он пока не даёт
Покрытие системы типов ещё растёт и меняется между версиями Kotlin: корутины (suspend, Flow), дженерики и некоторые другие объявления могут поддерживаться ограниченно, поэтому смотрите документацию своей версии.
SKIE дорабатывает Objective-C-фреймворк и со Swift export не работает, поэтому включить его значит отказаться от enum, потоков и отмены, которые даёт SKIE.
Что делать сейчас
Попробуйте его в ветке или на запасном модуле и читайте release notes каждой версии Kotlin.
Готовьтесь через форму API, а не через инструменты: узкий фасад из value-типов, один поток состояния и одна функция на намерение мигрируют почти бесплатно.