
Привет, Хабр! Меня зовут Артем Клименко, я 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;
Александру Кочетову — за поддержку с инициативой;
и Андрею Аврамчуку — за помощь в работе над этим материалом.
kurtov
Мы успешно применяем KMP. Но вначале очень не нравилась необходимость дублировать обертку ViewModel на iOS. После нескольких итераций мы нашли подход как использовать shared ViewModel без необходимости добавлять обертку.