Kotlin Multiplatform у реальному проєкті: переїзд Android на кросплатформу з Compose Multiplatform
Вітаю, Mobile-спільното! Мене звати Ваня, я працюю на позиції Android Developer у компанії Spendbase. Ми розробляємо FinTech-платформу, яка допомагає бізнесам оптимізувати витрати на софт і хмарні сервіси.
Попри те, що я почав свій шлях у розробці й продовжую як Android Developer, я завжди мріяв розробляти застосунки під дві платформи — Android/iOS. І ось вже близько року наш мобільний продукт відійшов від класичного підходу з двома проєктами Kotlin/Swift та використовує Kotlin Multiplatform та Compose Multiplatform, де приблизно 90% коду написано на Kotlin й уніфіковано в одному місці.
Я займався повною міграцією нативного production-ready Android-застосунку на кросплатформу та продовжую підтримувати цей проєкт. У першій статті я хочу поділитися власним досвідом переходу на кросплатформу: з якими труднощами зіштовхнувся, що вразило й переїхало на KMP «безболісно», та як зі свого невеличкого експерименту з міграцією у вільний час я довів обидва застосунки Android/iOS до першого релізу продукту в production.
Ця стаття буде корисною як для тих, хто планує впроваджувати KMP у свій продукт, так і для розробників, які просто цікавляться KMP. Моя головна мета — на своєму прикладі показати, що KMP — це не щось «сире», «страшне» або «складне», як я часто чую серед Mobile-спільноти, а повноцінне production-ready рішення, на яке варто звернути увагу.
Контекст: як iOS залишився позаду
Почну з того, що додам трохи контексту та відповім на логічне питання: «У вас було два готових нативних застосунки — нащо взагалі ви полізли у той KMP?».
Життя кожного бізнесу/продукту — це не лише написання коду розробниками. Особливо у Fintech-домені, як у випадку Spendbase, на темп розвитку сильно впливають зовнішні фактори: перемовини з партнерами, compliance-процеси, регуляторні обмеження. У певні моменти ці речі можуть повністю заблокувати реліз, незалежно від готовності технічної частини.
Саме в такій ситуації ми й опинилися. На етапі підготовки до першого релізу розробка мобільних застосунків Spendbase була тимчасово заблокована через очікування апрувів від банку та партнерів. В такі періоди моя задача як розробника — не «скласти руки», а бути максимально корисним там, де потрібні мої навички. У той момент фокусною для нас стали розробка Web-платформи та Backend, а мобільні застосунки тимчасово відійшли на другий план.
Попри зміну пріоритетів, я завжди намагався знайти час на апдейт Android-застосунку: оновлення API, розробку нових фіч (коли був капасіті), синхронізацію з оновленим бекендом. В той період iOS-напрямок залишився без розвитку, оскільки основний фокус все ще залишався на Web та Backend. Але через декілька місяців, коли розробка мобільних застосунків розблокувалась, ми опинилися в наступній ситуації:
- Android-проєкт був up-to-date та production ready, готовий до імплементації нових фіч, які наблизять продукт до першого релізу;
- iOS суттєво відставав: потребував інтеграції з оновленим бекендом, редизайну компонентів та доопрацювання фіч, які вже були на Android.
Саме в цей момент у мене в голові виникло питання: що з цим робити, щоб наблизити нас до першого релізу?
Чому KMP, а не оновлення Swift-застосунку
Зіштовхнувшись з iOS-проблемою, ми навіть не замислювались над KMP. У команді прокручувались два варіанти: або готувати великий скоуп робіт з оживлення iOS-проєкту та запускати процес найму нової людини в команду, або мені, Android Dev, вчити SwiftUI, бо давно хотів писати під обидві платформи (як мінімум цей варіант розглядав я у своїй голові).
Але ці два варіанти мені не подобались, оскільки обидва були long-term та віддаляли нас від довгоочікуваного першого релізу двох застосунків. Тож мене не покидали думки, як оптимізувати цю історію й витратити мінімум часу на те, щоб оживити наш iOS-застосунок.
Після цього я деякий час проводив ресерч та спілкувався зі знайомими з Android community, які ділились своїм досвідом з KMP на пет-проєктах та в комерції. Так я заглибився у тему KMP.
Чим мене підкупила ідея з кросплатформою? Для цього варто відповісти на питання: «Що має буквально кожен мобільний застосунок?». Робота з бекендом, локальним сховищем та бізнес-логіка взаємодії різних data sources. Головний мінус підходу з двома нативними проєктами — відсутність Single Source of Truth для бізнес-логіки. Ми постійно робимо подвійну роботу: пишемо те саме на Kotlin і на Swift. А оскільки розробники мислять по-різному і по-своєму розуміють ТЗ, імплементація на різних платформах може повністю відрізнятись у підході, хоча й давати той самий результат.
Як результат у нас є дві різні імплементації бізнес-логіки, два потенційних розсадники багів та вдвічі більше часу на тестування бізнес-логіки у QA-команди.
Також в цей час дуже активно розвивається Compose Multiplatform і для iOS він вже був доволі стабільний. Проаналізувавши дизайни нашого продукту, я побачив, що кастомні елементи не відрізнялись на обох платформах, що також наводило на думку, що це можна уніфікувати.
Це все надихало на те, що варто спробувати поклацати KMP та CMP у рамках розвитку. Робити прості пет-проєкти і сетапити з нуля застосунок мені здалося на той момент простою та нудною ідеєю. Хотілось більш реального кейсу, який можна зустріти на практиці в комерційних проєктах.
І тоді я подумав: а що, якщо взяти цей реальний кейс з моєї роботи й поставити перед собою такий челендж — змігрувати на KMP з CMP та завести нативний production-ready Android-проєкт на iOS. Робив я це у свій неробочий час для свого ж розвитку, не підставляючи компанію та не створюючи ризиків для бізнесу.
Ось так пошук short-term рішення для першого релізу та мій маленький успішний експеримент за
Що мав на Android: стек і архітектура
Що я мав перед міграцією? Доволі стандартний набір для більшості застосунків: single module app, розбитий по packages з використанням Recommended app architecture (data, domain, presentation), MVI pattern для presentation шару, Jetpack Compose, Jetpack Navigation 2, Coil3, Retrofit, Moshi, Jetpack DataStore, Room, Kotlin Coroutines, Flow, Koin. Забігаючи наперед — саме цей архітектурний підхід зекономив мені багато часу при міграції, але деякі речі все-таки потребували переробки.
Стратегія міграції
Насамперед я проаналізував усі бібліотеки проєкту, щоб зрозуміти, які з них підтримують KMP і можуть бути легко портовані (наприклад: Koin, DataStore, ViewModel, Compose). Також я виділив ліби, що не мають кросплатформової підтримки, проте піддаються заміні — зокрема, Retrofit можна замінити на Ktor Client. Найскладнішою групою виявилися сервіси без жодних альтернатив у KMP, такі як Google/Microsoft SSO, Firebase Cloud Messaging та Security Keystore.
Після цього пройшовся по всьому застосунку, написав список фіч, які є платформо-специфічними (робота з камерою, файловою системою, біометрія, permissions тощо), провів ресерч, оцінивши, наскільки важко буде все імплементувати, і спробував окремо реалізувати
Після успішної імплементації платформо-специфічної фічі та появи розуміння, що «немає нічого неможливого», я взявся до планування міграції.
Ідея була у наступному: мігрувати по шарам (Domain, Data, Presentation), перенести весь код, який ми можемо пошерити між платформами (common module) і залишити інтерфейси та заглушки імплементацій для платформо-специфічних фіч.
А після успішного запуску основної частини застосунку на iOS (те, що планував показати на демо) зайнятися вже функціоналом, який потребує реалізації під кожну платформу. Як то кажуть, найсмачніше на десерт :)
Структура KMP-проєкту
Перед тим, як заглибитись у міграцію, коротко поясню структуру KMP-проєкту:
├── androidApp/ # Нативний Android-застосунок ├── iosApp/ # Нативний iOS-застосунок (Swift) └── shared/ # KMP-бібліотека (спільний код) ├── commonMain/ # Код для всіх платформ (~90% коду) ├── androidMain/ # Android-специфічний код (Kotlin) └── iosMain/ # iOS-специфічний код (Kotlin/Native)
- commonMain — серце проєкту: бізнес-логіка, UI, моделі — все, що однакове для обох платформ.
- androidMain — код, який використовує Android SDK (Context, специфічні API).
- iosMain — код, який використовує iOS SDK через Kotlin/Native.
- androidApp / iosApp — точки входу для кожної платформи, які підключають shared-бібліотеку.
По суті, androidApp та iosApp — це тонкі «обгортки», а вся логіка живе у shared-модулі.
Міграція Domain-шару
Почав я з Domain-шару, оскільки це по факту серце вашого застосунку, де лежить вся бізнес-логіка (use cases, repository interfaces) та модельки + цей модуль є самостійним і ніяк не залежить від data та presentation (data ← domain → presentation).
Я очікував найпростіший переїзд у порівнянні з усіма шарами, але на практиці виявилось не все так легко, і тут я зіштовхнувся з першим підводним каменем.
Мене очікував сюрприз у роботі з датою та часом. Думаю, більшість з вас використовує такі класи, як LocalDate, LocalDateTime, LocalTime та інші, і навіть не подумали б ніколи на етапі планування: «А що ж тут може піти не так?»
А це є пакет java.time (пу-пу-пу), який не є кросплатформним, оскільки це Java-пакет, а не Kotlin. Тому мені довелося повністю переписати всі місця коду, де використовується java.time та замінити пакет на kotlinx.datetime, який підтримує KMP.
Невелика порада від мене: якщо плануєте мігрувати на KMP або закладаєте цю можливість у нативному проєкті, намагайтесь менше використовувати Java-пакетів або потім з щасливим обличчям переписуйте їх, як я :)
Міграція Data-шару
З цим шаром було трохи складніше, оскільки тут ми вже переходимо від interfaces з domain-шару до імплементацій. З DataStore, Room все було досить просто: на рівні data-шару data sources, репозиторіїв все залишається незмінним і переноситься у common module Cmd+C / Cmd+V.
Цікавіша історія вже пішла з Retrofit та Moshi, бо довелося переїжджати Retrofit → Ktor Client, Moshi → Kotlin Serialization. У випадку з Moshi довелось замінювати анотації на всіх нетворк-модельках на Serializable, SerialName.
А от з переписуванням на Ktor довелося трохи більше попрацювати. У такі моменти ти розумієш на практиці, навіщо потрібні знання архітектури й написання коду зі слабкою зв’язаністю.
Міграція з Retrofit на Ktor Client була для мене менш болісною, оскільки ще в нативному проєкті я заклав data source pattern, який мені дуже сильно полегшив ситуацію. Не треба було чіпати логіку взаємодій різних data source на рівні репозиторіїв, а лише зробити нову імплементацію від існуючого interface network data source.
Міграція UI: Jetpack Compose → Compose Multiplatform
Presentation-шар став для мене приємною несподіванкою: він переїхав на KMP так легко, ніби завжди там і був. Кастомна тема, ресурси, Composable-функції скрінів, компонентів дизайн-системи, анімації, Coil3, Material, Jetpack Navigation 2 (який тепер має офіційну KMP-підтримку завдяки контрибуціям команди Compose Multiplatform), робота з keyboard — усе, що стосується UI, переїхало гладко у модуль common, запустилось та чудово працювало як на Android девайсі, так і на iOS.
Найбільше я переживав, як переїдуть на KMP такі елементи, як Date Picker, Snackbar або Bottom Sheet. Як же було приємно, радісно і моментами трохи смішно побачити такі важкі й рідні material UI-елементи ідеально та гладко працюючими, з усіма анімашками на iOS-пристрої.
Але трохи негативу в міграцію цього шару я також додам. З такого, що доведеться перероблювати — функціонал, пов’язаний з Android Context. В KMP та iOS, на жаль, цього немає, ця історія специфічна лише Android SDK. В цілому вона неважко хендлиться. Поділюсь, як я з цим боровся, трохи нижче у розділі «Платформо-специфічний код».
Також хотів би звернути вашу увагу: при плануванні міграції на Compose Multiplatform обов’язково подивіться дизайни та узгодьте з продуктовою командою й дизайнерами, чи підходить для iOS-платформи Material-тема. Деякі Material UI-компоненти можуть сильно відрізнятись від нативних iOS (наприклад, Date Picker).
Платформо-специфічний код
Найцікавіша частина всього процесу міграції — це реалізація фіч, які відрізняються за імплементацією залежно від платформи. Особливо для мене, як Android-розробника, цікава була реалізація фіч для iOS, адже це було щось нове і цікаве.
Почати хочеться з підходу реалізації кросплатформеного коду. Ви можете це робити за допомогою ключових слів expect та actual в Kotlin Multiplatform або за допомогою Koin DI. Опишу коротко обидва підходи, не заглиблюючись у подробиці.
Перший підхід полягає у тому, що у модулі commonMain декларуємо функцію, проперті або обʼєкт з ключовим словом expect, а у модулях androidMain, iosMain ми пишемо платформо-специфічну імплементацію з ключовим словом actual для нашого expect функціоналу.
Приклад:
// In the commonMain source set: interface Platform expect fun platform(): Platform <br>
// In the androidMain source set: class AndroidPlatform : Platform actual fun platform() = AndroidPlatform() <br>
// In the iosMain source set: class IOSPlatform : Platform actual fun platform() = IOSPlatform()
Другий підхід, думаю, знайомий для всіх — ми не раз зустрічались з ним у нативній розробці, коли працювали з interface (наприклад, interface Repo, class RepoImpl). Єдина різниця лише у тому, що є дві імплементації нашого інтерфейсу, а Koin DI вже резолвить, яку імплементацію потрібно використати залежно від платформи (як це відбувається, розкажу пізніше у розділі Koin DI).
Ці два підходи можна комбінувати, не обов’язково дотримуватися якогось одного. Expect/actual більше підійде для простих кейсів (helper-функцій або platform-specific properties). А Koin DI вже допоможе в складніших випадках, коли потрібні інжекти додаткових класів, проперті з графу залежностей для складнішої бізнес-логіки.
В цілому, попри підхід, який ви обрали, ми маємо просту спільну ідею для кросплатформенних фіч: це декларація інтерфейсу у commonMain та дві імплементації (Android/iOS) у модулях androidMain, iosMain.
Я не хотів би сильно зупинятись на Android-імплементації, бо це те, що кожен розробник робить кожен день. Лише ділимо нашу фічу на інтерфейс та імплементацію і реалізуємо їх, як у звичайному нативному Android-застосунку. Бо androidMain це звичайна Android-бібліотека з build.gradle.kts, де можна підключати всі знайомі Android-залежності, якими ми користуємось щодня і які не є кросплатформенними (ось тут й вирішується проблема з Android Context).
Цікавіша історія вже з iOS-імплементацією, на якій ми зупинимось детальніше. Почати варто з iosMain модуля. iosMain — це source set для iOS-платформи, аналогічний androidMain. Ключова особливість: Kotlin/Native надає інтероп з iOS SDK, тому у вас є доступ до UIKit, Foundation, CoreData та інших iOS-фреймворків. По суті, ви пишете нативний iOS-код, але на Kotlin. Для мене, як Android-розробника, це було справжнє відкриття: сидиш у звичному Kotlin, а пишеш під iOS без жодного рядка Swift. Магія!
Технічно це працює так: iosMain компілюється у .framework — стандартний iOS-бінарник. Нативний iosApp просто підключає цей framework як залежність, аналогічно до того, як ви підключаєте будь-яку iOS-бібліотеку у Swift-проєкті або Android-бібліотеку в Android-проєкті.
Далі поговоримо детальніше про імплементацію фіч. Я б розділив їх на два види: ті, які є в iOS SDK (наприклад, Camera, Permissions, FaceID), та ті, що потребують імпорту додаткових Swift-бібліотек і не є частиною iOS SDK (наприклад, Microsoft SSO, HubSpot SDK, Centrifugo, PostHog).
Якщо щодо фіч, що є частиною iOS SDK, ви вже маєте певне уявлення, як реалізувати (ми можемо їх написати на Kotlin/Native), то що робити з фічами, які потребують додаткових залежностей? Як їх додати у наш KMP-проєкт та мати доступ в iosMain?
І ось тут я відразу хотів би підсвітити найголовнішу проблему та недолік KMP — це імпорт будь-яких iOS-бібліотек у наш проєкт, щоб мати доступ до них у iosMain. На цю мить є два способи додати iOS-залежності в KMP-проєкт: через cinterop tool (генерація Kotlin-біндінгів для бінарників) або через CocoaPods dependency manager (але pure Swift pods не підтримуються).
На мою думку, жодне з цих рішень не є production-ready, і я не рекомендую їх використовувати. CocoaPods — це застарілий, фактично deprecated спосіб менеджменту залежностей в iOS-розробці. Більшість сучасних iOS-проєктів давно перейшли на Swift Package Manager (SPM). А cinterop tool потребує ручного звантаження бінарників бібліотек, що дуже важко підтримувати в довгостроковій перспективі.
Сподіваюсь, у майбутньому KMP отримає повноцінну підтримку SPM — це значно спростить життя для кросплатформних проєктів.
І тут назріває логічне питання: а як тоді інтегрувати такі фічі у наш KMP-проєкт? Давайте згадаємо, як KMP працює з нативною iOS App. По суті, наш KMP-проєкт — це просто бібліотека, яка підключається до нативного iOS-проєкту (iosApp impl KMP lib). В нас є однонаправлений зв’язок між цими модулями. Схематично це виглядає так: KMP lib → iOS App, тобто iOS App знає про KMP lib, але KMP lib нічого не знає про iOS App.
Ті, хто колись робив багатомодульні Android-проєкти, частково можуть провести аналогію з тим, як ми підключаємо модулі в gradle файлі й чому в нас не може бути зв’язку KMP lib ↔ iOS App (circle dependency errors one love). І тут я себе спіймав на думці: «А що, якщо такі фічі писати не на Kotlin/Native у KMP-проєкті, а на Swift у iOS App?»
Проблема в тому, що зв’язок односторонній: iOS App знає про KMP lib, але KMP lib нічого не знає про iOS App. Як тоді викликати Swift-код з Kotlin?
Рішення — Callback pattern. Ідея проста:
- У iosMain (KMP) оголошуємо інтерфейс з callback-функціями — це контракт того, що нам потрібно від iOS.
- У Swift-проєкті (iOS App) пишемо імплементацію цього інтерфейсу з використанням потрібних iOS-бібліотек.
- При старті iOS App реєструємо цю Swift-імплементацію в KMP.
- Коли KMP-код викликає callback, виконується Swift-імплементація.
По суті, ми інвертуємо залежність: замість того, щоб тягнути iOS-бібліотеки в KMP, «просимо» iOS App виконати роботу за нас. KMP каже, «що» потрібно зробити (інтерфейс), а iOS App вирішує «як» (імплементація на Swift).
Схематично:
KMP lib (iosMain): interface Callback { fun doSomething() }
iOS App (Swift): class CallbackImpl: Callback { func doSomething() { /* Swift код */ } }
При старті: KMP.registerCallback(CallbackImpl())
Такий підхід дозволяє використовувати будь-які Swift-бібліотеки (Microsoft SSO, HubSpot тощо) без болю з cinterop чи CocoaPods. Перевагами такого підходу є те, що ми залишили iOS-специфічний код у його звичному середовищі, у Swift-проєкті. Це набагато легше у підтримці ніж код, написаний на Kotlin/Native.
Так, спочатку це здається круто і прикольно — писати на Kotlin/Native. В тебе є цей вау-ефект, магія, і відчуття себе володарем мобайл-світу, бо ти опанував дві платформи та все написав на Kotlin. Але на практиці оцей «франкенштейн», код, написаний на Kotlin/Native, важкий у розробці, дебагу та підтримці.
І це ще не все. Є одна дуже болісна штука при роботі з iOS SDK через Kotlin/Native — це типи даних та їх конвертація. По-перше, iOS-типи не завжди коректно кастяться до Kotlin-аналогів (Int, String, ByteArray тощо). Те, що у Swift виглядає просто, у Kotlin/Native може потребувати ручної конвертації з несподіваними edge cases.
По-друге, самі імпорти виглядають як щось з фільму жахів:
LAPolicyDeviceOwnerAuthenticationWithBiometrics AVAuthorizationStatusAuthorized UIDocumentPickerDelegateProtocol
Спробуйте зрозуміти, що робить код вище, без досвіду в iOS-розробці. А тепер спробуйте знайти документацію — її практично немає. Вам доведеться гуглити Swift/Objective-C аналоги, читати Apple-доки, а потім здогадуватись, як це називається у Kotlin/Native біндінгах.
Дебаг — окрема історія болю. Breakpoints працюють не завжди коректно, стектрейси часто незрозумілі, а повідомлення про помилки іноді взагалі не вказують на реальну проблему.
Тому мій висновок: Kotlin/Native — потужний інструмент для простих речей з iOS SDK. Але для складних інтеграцій зі сторонніми бібліотеками залиште Swift у Swift-проєкті. Ваше майбутнє «я» скаже вам дякую.
Koin DI
З нативного Android-проєкту всі модулі (repositories, use cases, viewmodels, константи) переїжджають без змін: dataModule, domainModule, presentationModule. Єдина відмінність — додавання platformModule для платформо-специфічних залежностей. На практиці це виглядає ось так:
// In the common source set: import org.koin.dsl.module interface Platform expect val platformModule: Module <br>
// In the androidMain source set:
class AndroidPlatform : Platform
actual val platformModule: Module = module {
single<Platform> {
AndroidPlatform()
}
}
<br>
// In the iosMain source set:
class IOSPlatform : Platform
actual val platformModule = module {
single<Platform> { IOSPlatform() }
}
<br>
Після того як всі наші модулі з залежностями готові, нам залишається лише ініціалізувати Koin з цими модулями.
Для Android-застосунку це залишається без змін та ініціалізується у Application class:
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MainApplication)
androidLogger()
modules(appModule() + androidModule)
}
}
}
Для iOS створюємо helper-функцію в iosMain, яку потім викликаємо при старті Swift-застосунку:
// Let’s prepare a wrapper to our Koin function (Helper.kt in the iosMain source set):
fun initKoin(){
startKoin {
modules(appModule())
}
}
<br>
// We can initialize it in our Main app entry:
@main
struct iOSApp: App {
// KMM - Koin Call
init() {
HelperKt.initKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
<br>
Висновки
На початку статті я писав, що часто чую серед Mobile-спільноти, що KMP — це щось «сире», «страшне» або «складне». Після року роботи з KMP у production можу сказати: це не так. Так, є свої підводні камені — java.time, біль з Kotlin/Native, відсутність підтримки SPM. Але переваги значно переважають недоліки: ~90% спільного коду, Single Source of Truth для бізнес-логіки та UI, одна команда замість двох, вдвічі швидше додавання нових фіч.
KMP — це не магічна пігулка, але це повноцінне production-ready рішення, яке вже сьогодні можна використовувати в комерційних проєктах. Якщо ви Android-розробник і мрієте писати під обидві платформи — спробуйте. Можливо, як і я, ви здивуєтесь, наскільки це простіше, ніж здається.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

1 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів