
Привет, Хабр! Меня зовут Артем Клименко, я Lead Android-разработчик в МТС Web Services, занимаюсь продуктом Membrana Kids.
Продукт создавали нативно на каждую платформу, без пересечения кода. В начале года у нас ушло несколько iOS-разработчиков, из-за чего замедлилась поставка новых функций на обеих платформах. Мы решили, что это повод внедрить наконец кроссплатформенную разработку и выровнять поставку фич на обеих платформах. В этом материале расскажу, почему мы остановились на KMP, как погружались в iOS c опытом в Android и как прошло внедрение этого фреймворка. Спойлер: быстрее и проще, чем мы думали.
Выбор KMP* для кроссплатформенной разработки
* Детально о KMP читайте в этом материале и официальной документации.
До МТС я уже работал с KMP: на прошлом месте тоже довел этот фреймворк до прода, поэтому знал, к чему готовиться. Он позволяет:
взять на себя работу iOS-разработчиков;
ускорить поставку функций за счет переиспользования кода в дальнейшем;
устранить рассинхрон логики платформ.
KMP дает возможность писать общий код бизнес-логики для Android и iOS, оставляя нативными лишь верстку и UI-составляющую — это упрощает его поддержку и минимизирует дублирование логики. На Android используется стандартный Kotlin/JVM, а на iOS — компиляция в фреймворк, совместимый со Swift, через Kotlin/Native. Так глубже интеграция с нативными технологиями обеих платформ.
KMP поддерживает популярные библиотеки:
Ktor используется для сетевых запросов;
SQLDelight — для работы с базой данных;
Koin — для внедрения зависимостей;
корутины и Flow — для асинхронного программирования.
При этом UI можно оставлять нативным — использовать SwiftUI на iOS и Jetpack Compose на Android. Еще доступен Compose Multiplatform — если нужно единое визуальное представление. Совсем недавно у него появилась stable-версия под iOS.
Для старта с KMP требуется уверенная база в Kotlin: понимание ООП, работа с корутинами, Flow и sealed-классами. Также важно знание архитектурных паттернов MVVM или MVI, поскольку KMP, как правило, отвечает за бизнес-логику, а не за UI. Базовое понимание Swift нужно, чтобы корректно интегрировать и вызывать Kotlin-код из iOS. Отдельно стоит изучить инструменты сборки — Gradle и Swift Package Manager, а также механизм expect/actual — для реализации платформо-специфичного поведения.
Несмотря на явные преимущества, у KMP есть ряд ограничений. Некоторые возможности iOS он поддерживает не полностью: например, Swift Concurrency (async/await) требует создавать адаптеры под корутины.
Отладка на iOS может вызывать сложности, так как ошибки Kotlin/Native не всегда очевидны и симуляторы не гарантируют полного покрытия реальных сценариев. Экосистема мультиплатформы все еще в стадии развития: далеко не все привычные библиотеки имеют аналоги с поддержкой KMP, а использование некоторых решений, таких как Firebase, требует дополнительных оберток. Версионирование iOS-фреймворков также может озадачить, особен��о при автоматизированной интеграции.
Из альтернатив был Flutter, но его сразу отбросили, так как не было соответствующей экспертизы.
Аргументы против
KMP подойдет не всем. Он требует адаптации iOS-разработчиков — им придется осваивать Kotlin для работы с общей логикой. В компаниях, где много таких специалистов, это может быть критично.
Есть ряд технических моментов. Так, мы сомневались по поводу MVVM — использовали этот паттерн в Android и решили перенести в KMP, но в iOS он не так популярен. Пришлось вводить дополнительную обертку под viewModel.
Были опасения по поводу версионирования фреймворка под iOS, но этот вопрос решили в ходе PoC. И еще надо было решить вопрос со стабильностью взаимодействия корутин с Flow на iOS. В результате весь асинхронный код построили на корутинах, при этом обернули так, что до них нельзя дотянуться из iOS напрямую. UI отправляет команду в viewModel, по ней запускается асинхронный код в корутине, и по результату возвращается сайд-эффект в UI-слой.
Пример обертки viewModel в iOS-коде:
class SwiftCreateKidViewModel: ObservableObject { @Published var uiState: CreateKidUiState = .Initialize() @Published var sideEffect: CreateKidSideEffect? let delegate: CreateKidAccountViewModel private var stateSubscription: FlowSubscription? private var sideEffectSubscription: FlowSubscription? init(viewModel: CreateKidAccountViewModel) { self.delegate = viewModel setupBindings() } private func setupBindings() { stateSubscription = delegate.state.observe { [weak self] newState in self?.uiState = newState } sideEffectSubscription = delegate.sideEffect.observe { [weak self] newSideEffect in self?.sideEffect = newSideEffect } } deinit { stateSubscription?.cancel() sideEffectSubscription?.cancel() }
Пример взаимодействия: в Actions UI отсылает команды во viewModel (общий код), handleSideEffect перехватывает ответы от viewModel:
private class Actions: CreateKidAccountListener { private let delegate: CreateKidAccountViewModel private let navCoordinator: NavigationCoordinator init(delegate: CreateKidAccountViewModel, navCoordinator: NavigationCoordinator) { self.delegate = delegate self.navCoordinator = navCoordinator } func onCreateClick(name: String, birthdayMills: Int64, gender: AccountInfoItem.Gender, avatarId: String?) { delegate.createAccount(name: name, birthdayMills: birthdayMills, gender: gender, avatarId: avatarId) } func onRetryClick() { delegate.initialize(isNotFirstInitialize: true) } } @MainActor private func handleSideEffect(sideEffect: CreateKidSideEffect?) async { guard let sideEffect = sideEffect else { return } switch sideEffect { case let result as CreateKidSideEffect.CreateAccountSuccess: do { /* do someting */ } case let result as CreateKidSideEffect.CreateAccountError: do { await actions { Notifications.Business.Action.showPopup(popup: .init( content: .text(txt: UiKitR.strings().error_service_not_responding.value()), messageType: .error, closeType: .autoClosable(afterDelay: 3) )) } if result.isKidsServiceExist { navCoordinator.navigateNewTask(to: KidsEntry().asRoute(), with: [:]) } else { navCoordinator.popBackStack() } }
Почему мы все-таки остановились на KMP
С точки зрения Android-разработчика, у нас появилось несколько важных плюсов, которые позволяют писать логику один раз и переиспользовать ее на обеих платформах.
KMP использует Kotlin без необходимости адаптации под Android. Android-приложение при миграции остается без изменений: требуется только дописать библиотеку на KMP, что отнимает минимум времени. Еще у KMP чистая архитектура с явным разделением data- и UI-слоев. Вот код одной фичи из shared-sdk:

В shared-sdk лежат presentation-логика и бизнес-логика.
А вот та же фича, но в Android-приложении. Тут исключительно UI-составляющая — компоненты Compose:

Аналогично на iOS — только UI-компоненты:

В итоге UI из Android и iOS соединяется с presentation-логикой из общего кода.
После внедрения KMP фичи выходят быстрее, логика между платформами синхронизируется, становится меньше багов из-за расхождений в коде.
Старт внедрения
Для начала мы сделали небольшой PoC, чтобы подтвердить работоспособность KMP. Для этого взяли упрощенную версию приложения Membrana Kids и реализовали:
REST-запросы (Ktor);
WebSockets;
работу с локальными хранилищами (DataStore вместо SharedPreferences);
интеграцию с нативными SDK МТС;
экраны: авторизация, онбординг, список детей, добавление/удаление профилей.
Все работало замечательно и стабильно. Больше всего порадовал iOS, который взлетел и практически не пострадал под капотом от такого буйного переезда.
Удивили:
Частые перерисовки экранов на iOS из-за некорректной работы с состояниями во ViewModel. Чтобы подтягивать изменения uiState и сайд-эффектов, мы использовали такую обертку:
import SwiftUI import SharedSDK class SwiftCallFilteringViewModel: ObservableObject { @Published var uiState: CallFilteringMainState = /**/ @Published var sideEffect: CallFilteringSideEffect? = nil @Published var avatarUrl: String? let delegate: CallFilterViewModel // viewModel из общего кода private var stateSubscription: FlowSubscription? // обертки над флоу private var sideEffectSubscription: FlowSubscription? init(viewModel: CallFilterViewModel) { self.delegate = viewModel self.avatarUrl = viewModel.kidData.avatarUrl setupBindings() } private func setupBindings() { stateSubscription = delegate.mainState.observe { [weak self] newState in self?.uiState = newState if let unwrappedAvatarUrl = self?.delegate.kidData.avatarUrl { self?.avatarUrl = unwrappedAvatarUrl } } sideEffectSubscription = delegate.sideEffect.observe { [weak self] newSideEffect in self?.sideEffect = newSideEffect } } deinit { stateSubscription?.cancel() sideEffectSubscription?.cancel() } }
Настройка версионирования и автоматической публикации iOS-фреймворка — тут нам не хватало опыта. Пришлось погружаться в Kotlin и много экспериментировать.
Передача аргументов между экранами: на Android использовали SavedStateHandle, для iOS пришлось делать аналог через expect/actual. Так выглядит expect (общий код):
expect class SavedStateHandle { @Suppress("UnusedPrivateMember") constructor(initialState: Map<String, Any?>) constructor() @MainThread operator fun contains(key: String): Boolean @MainThread operator fun <T> get(key: String): T? @MainThread fun <T> getStateFlow(key: String, initialValue: T): StateFlow<T> @MainThread fun keys(): Set<String> @MainThread fun <T> remove(key: String): T? @MainThread operator fun <T> set(key: String, value: T?) }
Реализация в iOS через внутренние мапы:
actual class SavedStateHandle { private val regular = mutableMapOf<String, Any?>() private val flows = mutableMapOf<String, MutableStateFlow<Any?>>() actual constructor(initialState: Map<String, Any?>) { regular.putAll(initialState) } actual constructor() actual operator fun contains(key: String): Boolean = regular.containsKey(key) actual operator fun <T> get(key: String): T? { @Suppress("UNCHECKED_CAST") return regular[key] as? T? } actual fun <T> getStateFlow(key: String, initialValue: T): StateFlow<T> { @Suppress("UNCHECKED_CAST") return flows.getOrPut(key) { if (!regular.containsKey(key)) { regular[key] = initialValue } MutableStateFlow(regular[key]).apply { flows[key] = this } } .asStateFlow() as StateFlow<T> } actual fun keys(): Set<String> = regular.keys actual fun <T> remove(key: String): T? { @Suppress("UNCHECKED_CAST") val latestValue = regular.remove(key) as? T? flows.remove(key) return latestValue } actual operator fun <T> set(key: String, value: T?) { regular[key] = value flows[key]?.value = value } }
Нативная реализация в Android:
import androidx.lifecycle.SavedStateHandle as AndroidSavedStateHandle actual typealias SavedStateHandle = AndroidSavedStateHandle
После первых экспериментов мы поняли, что взаимодействие между UI- и data-слоями в iOS и Android сильно различается. Поэтому нам потребовалось некоторое время, чтобы вникнуть. В итоге поняли, что нужно грамотно выстраивать логику асинхронного кода, чтобы UI не находился в подвисшем состоянии.
Публикация на прод
Для этого мы:
Вынесли общий код в отдельную многомодульную библиотеку. Под Android каждый модуль сейчас публикуется отдельно, подменяя локальные проектные зависимости в рантайме на аналогичные в том же артифакторе. Под iOS пока собираем единый монолитный .xcframework.
Настроили публикацию нативных библиотек под платформы.
Заменили библиотеки на KMP-аналоги:
Hilt → Koin (DI);
Retrofit → Ktor (сетевые запросы);
SharedPreferences → DataStore (локальное хранилище).
Наши выводы: на что обратить внимание при внедрении KMP
По поводу верстки не волнуйтесь: SwiftUI на стороне iOS обеспечивает концептуальное сходство с Jetpack Compose, что упрощает согласование подходов. Основные сложности возникают с другими вещами.
Корректная работа Kotlin Coroutines в iOS-окружении
Механизмы асинхронности в Kotlin и Swift устроены принципиально по-разному. Для вызова suspend-функций из Swift необходимы аннотации вроде @MainActor и обертки, такие как Task. Отдельно об обработке отмены корутин: при неаккуратной реализации может утекать память.
Кроме того, Flow при трансляции в Swift превращается в AsyncSequence, cold Flow не всегда демонстрирует ожидаемое поведение при множественных подписках, а пробрасывать ошибки бывает сложно. Решается это также обертками: корутины не вызываются напрямую из Swift, а для работы с Flow мы написали прослойку, адаптирующую поведение под специфику iOS.
Версионирование общего фреймворка
Здесь основной вызов — поддерживать совместимость между двумя экосистемами со своими методами управления зависимостями: Gradle на Android и CocoaPods или Swift Package Manager на iOS. Обновление общего модуля требует выпуска новой версии .framework или .xcframework, а также ручного обновления зависимостей в соответствующих конфигурационных файлах (Podfile). Обновление файла Package.swift мы автоматизировали через CI:
Закидываем архив с iOS-библиотекой в хранилище.
Вычисляем его hash-сумму.
Подставляем ее в путь до архива в Package.swift.
Важно контролировать версии Kotlin-кода и фреймворка: на стороне Android модуль подключается через implementation, а на iOS требует сборки, загрузки и явного указания путей. В ряде случаев у нас возникали сложности с кешированием в Xcode, что требовало ручной очистки DerivedData.
Для минимизации рутинных операций мы немного автоматизировались: настройка CI/CD позволила собирать .xcframework при каждом изменении KMP-модуля, а механизм версионирования централизовали через gradle.properties.
Callback hell при взаимодействии Kotlin со Swift
Проблема возникает из-за необходимости связывать асинхронные вызовы между языками: suspend-функции, замыкания (completion handlers) и потоки данных (Flow) требуют различных подходов и преобразований.
Риск страхуют на уровне архитектуры: делают обертки, обеспечивающие контролируемое и читаемое взаимодействие между слоями.
Итоги внедрения
Изначально я закладывал на миграцию без регресса и переноса тестов три спринта, но мы погрузились в iOS за два.
KMP мы довольны: переход снизил трудозатраты на новые функции. Почти в два раза меньше дублируется код между iOS и Android, бизнес-логика пишется один раз, а мы не так сильно зависим от наличия iOS-разработчиков в команде.
Кроме того, мы устранили рассинхронизацию логики между платформами, особенно критичную в проектах с быстрыми изменениями и параллельной разработкой. KMP позволил наладить четкие процессы и делегирование задач — теперь верстка отделена от бизнес-логики.
Важно, что в миграции нас поддержало руководство и коллеги, которые помогали с новой платформой. И мы были готовы откатиться к нативному решению в случае критических проблем в продакшне, но, к счастью, так и не пришлось.
P. S. Огромное спасибо коллегам:
Антону Кремлеву и Камилю Ишмуратову — за помощь с миграцией и разработкой;
Алексею Григорьеву — за консультации по iOS;
Александру Кочетову — за поддержку с инициативой;
и Андрею Аврамчуку — за помощь в работе над этим материалом.