Привет, Хабр! Меня зовут Артем Клименко, я 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:

  1. Закидываем архив с iOS-библиотекой в хранилище.

  2. Вычисляем его hash-сумму.

  3. Подставляем ее в путь до архива в 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;

  • Александру Кочетову — за поддержку с инициативой;

  • и Андрею Аврамчуку — за помощь в работе над этим материалом.