Пришвидшення мобільної розробки за допомогою Kotlin Multiplatform
Привіт! Мене звати Михайло Микитин, я Senior Mobile Developer at Levi9. Працюю розробником уже понад 10 років, і мій основний фокус увесь цей час — Android. Паралельно з цим у різні моменти карʼєри я працював і з кросплатформенними рішеннями: Cordova, Flutter, Kotlin Multiplatform (KMP). У цьому матеріалі вирішив розібрати останнє. У якийсь момент стало очевидно, що Android та iOS-команди по суті роблять одну й ту саму роботу: підтримують однакові моделі, правила валідації та бізнес-логіку. Це забирало подвійний час і постійно створювало ризик розбіжностей у деталях.
На відміну від повноцінних кросплатформенних фреймворків, Kotlin Multiplatform дозволяє ділитися бізнес-логікою, не зачіпаючи UI та архітектуру. Завдяки цьому кожна платформа зберігає нативність і контроль над власною реалізацією.
Як працює Kotlin Multiplatform
Kotlin Multiplatform — це технологія/спосіб ділитися бізнес-логікою між Android та iOS, не обʼєднуючи інтерфейс користувача. Візуальна частина застосунку створюється окремо для кожної платформи з використанням її звичних інструментів.
На відміну від Flutter чи React Native, які працюють через власний шар абстракцій, Kotlin Multiplatform не підміняє собою платформу й дає прямий доступ до її API. По суті, це спільне ядро застосунку, навколо якого кожна платформа будує свою реалізацію.
Про спільний код і source sets
У Kotlin Multiplatform код поділяється за тим, до яких платформних API він має доступ. Самі застосунки залишаються звичайними нативними application-модулями, а спільний код виноситься в окремий Kotlin Multiplatform shared-модуль, який підключається до кожної платформи. Цей модуль розбитий на source sets.
commonMain — спільний код на чистому котліні, без залежностей від будь-якої платформи, включно з JVM. Тут зазвичай живе бізнес-логіка, спільна для всіх платформ.
androidMain — котлін-код із доступом до Android / JVM API (Context, File тощо).
iosMain — котлін-код із доступом до iOS API (Foundation, CoreData тощо).
commonTest — спільний код для тестів.
Це базові source sets, але їх насправді більше — склад і комбінації визначаються KMP-плагіном і конфігурацією проєкту. При цьому commonMain залишається центральним, від нього відштовхується вся інша логіка.
Target platforms
Платформи, під які компілюється спільний код, задаються через Kotlin targets. Для Android і iOS Kotlin Multiplatform створює окремі бінарні файли — .aar і .framework відповідно, що інтегруються з нативними застосунками. Swift-код може імпортувати цей framework і використовувати спільну бізнес-логіку без додаткових обгорток, викликаючи Kotlin-функції як звичайні Swift-функції.
Expect / Actual на практиці
Найцікавіше тут — механізм expect / actual. Саме він дозволяє звертатися до платформних API, не виносячи платформну логіку в спільний код. У commonMain оголошується expect API — функція або клас без реалізації. У кожному платформному сорс сеті для нього створюється відповідна actual-реалізація.
Приклад:
// commonMain expect fun getPlatformName(): String // androidMain actual fun getPlatformName(): String = "Android" // iosMain actual fun getPlatformName(): String = "iOS"
Використовуємо в спільному коді:
fun printPlatform() {
println("Running on ${getPlatformName()}")
}
Демо-застосунок
Щоб показати, як KMP працює на практиці, я зробив демо-застосунок OnlineShop.
Функціонал:
- список продуктів з можливістю додавання в кошик;
- кошик із редагуванням кількості, видаленням продуктів і підрахунком загальної суми;
- екран користувача з можливістю входу або реєстрації.
У спільний код винесено:
- бізнес-логіку;
- роботу з мережею;
- базу даних;
- навігацію між екранами.
Структура проєкту:
Основні модулі:
- Domain;
- Database;
- Network.
Функціональні модулі:
- Auth;
- Cart;
- Customer;
- Product.
Модулі застосунків:
- Android;
- iOS.
Основні компоненти KMP
Розглянемо основні компоненти на прикладі функціоналу екрану «Список продуктів». За цей функціонал відповідає feature-модуль Product, який залежить від трьох core-модулів: Domain, Database і Network.
Модуль Product робить наступне:
- завантажує список продуктів з бекенду;
- кешує завантажені продукти для можливості перегляду офлайн;
- надає інтерфейс доступу до списку продуктів.
У коді це виглядає приблизно так:

Є інтерфейс ProductRepository. Його реалізація залежить від двох data source:
- RemoteDataSource — інкапсулює взаємодію з мережею.
- LocalDataSource — інкапсулює взаємодію з базою даних
Відповідно, для цього потрібні зовнішні бібліотеки. Також використовується внутрішня бібліотека — модуль Domain.
Додаємо новий модуль (Core — Domain)
Kotlin Multiplatform модулі збираються через Gradle, як і звичайні Android-проєкти, тому весь процес керується знайомими інструментами. Новий KMP-модуль можна додати прямо з IDE, і шаблон створюється автоматично з усіма потрібними source sets та налаштуваннями.
В модулі Domain в нас визначені основні спільні класи і інтерфейси, які будуть використовуватись всюди у проєкті. Наприклад:
data class Product( val id: String, val name: String, val productDescription: String, val imageUrl: String, val price: Money, val isFeatured: Boolean = false )
У модулі Domain ми зберігаємо основні спільні класи та інтерфейси, які використовуються по всьому проєкту. Тут нам не потрібен код, що залежить від платформи, тому androidMain і iosMain source sets можна просто видалити. Увесь код у цьому модулі знаходиться в commonMain:

Додаємо зовнішню бібліотеку (для Core — Network)
Щоб реалізувати згаданий вище RemoteDataSource, нам потрібен HTTP-клієнт. Найбільш поширеним варіантом для Kotlin Multiplatform є Ktor Client.
Зовнішні бібліотеки в KMP підключаються так само, як і в звичайному Gradle-проєкті: достатньо додати залежність у commonMain або в платформений source set у build.gradle.kts. Наприклад, для Ktor достатньо додати відповідні артефакти — Gradle сам підтягне як спільну, так і платформні частини бібліотеки.
sourceSets {
commonMain {
dependencies {
api(libs.ktor.client.core)
api(libs.ktor.serialization.kotlinx.json)
api(libs.ktor.client.content.negotiation)
api(libs.ktor.client.auth)
}
}
androidMain {
dependencies {
implementation(libs.ktor.client.okhttp)
}
}
iosMain {
dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
API Ktor-клієнта повністю доступний у спільному коді, але початкова конфігурація потребує взаємодії з платформою. Конструктор HttpClient приймає HttpClientEngineFactory, який відрізняється для кожної платформи: OkHttp для Android і Darwin для iOS. Щоб отримати доступ до платформно-залежної реалізації HttpClientEngineFactory, використовуємо механізм expect / actual.

Після цього Ktor-клієнт можна використовувати в інших модулях проєкту. Наприклад, ось як це виглядає для ProductsAPI, який використовується в RemoteDataSource:
class DefaultProductsApi(
val httpClient: HttpClient
): ProductsAPI {
override suspend fun getFeaturedProducts(): List<Product> =
httpClient.get("store/products") {
url {
parameters.append("fields","*variants.prices")
}
}.body<MedusaProductResponse>()
.products
.map(MedusaProductDto::toDomain)
}
База даних (SQLite)
Наступний крок для нашого Product-модуля це можливість локально зберігати результат, отриманий з сервера. Є кілька популярних рішень, які підтримують KMP:
- Room;
- SQLDelight.
SQLDelight більш поширена в KMP-проєктах і використовується в ком’юніті вже давно.
Room від Google отримала підтримку KMP відносно недавно. Я, як Android-розробник, уже знайомий з Room, тому використав саме цю бібліотеку. Це ще одна перевага KMP — можливість застосувати свій досвід із нативної розробки у спільному коді.
Як і у випадку з Ktor, увесь Room API доступний у спільному коді, але налаштування вимагає взаємодії з платформним кодом. Нам потрібно створити RoomDatabase.Builder окремо в iOS та Android source sets:

Описуємо базу даних звичним способом і додаємо специфічний для KMP конструктор й функцію доступу (getRoomDatabase).

Зверніть увагу, що ми не можемо створити об’єкт AppDatabase в спільному коді, але можемо отримати доступ до нього через Dependency Injection (про це написано нижче).
Presentation layer
Ми додали мережевий клієнт і базу даних — тепер у нас є функціональний ProductRepository, інтерфейс, який інкапсулює доступ до списку продуктів. Наступний крок — зробити його доступним для UI.
У KMP ми можемо використати звичний для Android підхід із ViewModel і Flow. Google постаралися й додали підтримку KMP для бібліотеки AndroidX ViewModel. Це дозволяє нам використовувати ViewModel у спільному коді:
class ProductsViewModel(
private val productsRepository: ProductRepository,
) : ViewModel(){
data class HomeUiState(
val isLoading: Boolean = true,
val products: List<ProductUiModel> = emptyList()
)
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun fetchFeaturedProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
_uiState.update {
HomeUiState(
products = productsRepository.getFeatured()
.map(Product::fromDomain))
}
} finally {
_uiState.update { it.copy(isLoading = false) }
}
}
}
}
Екземпляр класу ViewModel потрібно створювати безпосередньо в коді нативного застосунку. Для спрощення цього процесу варто використати Dependency Injection.
Dependency Injection
Як і в Android-розробці, так і у світі KMP використання DI є дуже поширеним. Ось бібліотеки, які підтримують KMP:
— Koin;
— kotlin-inject;
— Kodein.
Я використав Koin, оскільки вже працював з ним в комерційних Android-проєктах.
Koin дозволяє створювати конфігурації для спільного коду, а також додаткові конфігурації для кожної з платформ. Ось приклад зі спільного коду:
fun appModule() = module {
single<ProductRepository> { DefaultProductRepository(get(), get()) }
single { ProductRemoteDataSource(get()) }
single { ProductLocalDataSource(get()) }
single<ProductDao> { get<AppDatabase>().getDao() }
single<ProductsAPI> { DefaultProductsApi(get()) }
single<HttpClient> { createHttpClient(get(named("baseUrl")), get()) }
single<String>(named("baseUrl")) { baseUrl }
}
Щоб Koin запрацював, потрібно викликати startKoin API, ця функція має викликатись при старті застосунку. Ось як це виглядає для Android:
class OnlineShopApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
val androidModule = module {
androidContext(this@OnlineShopApp)
single<AppDatabase> {
getRoomDatabase(getDatabaseBuilder(get()))
}
viewModelOf(::ProductsViewModel)
// ...
}
modules(appModule(), androidModule)
}
}
}
Зверніть увагу також на те, що тут визначаємо AppDatabase, адже так ми можемо отримати доступ до неї в спільному коді, просто викликавши get<AppDatabase>().
Тепер наш ProductsViewModel можна використовувати в UI-коді:
@Composable
fun App(
productsViewModel: ProductsViewModel = koinViewModel()
){
val uiState by productsViewModel.uiState.collectAsState()
// ...
}
Особливості для iOS
Імпорт фреймворку
Зібраний KMP-модуль під iOS експортується як .framework, який просто підключається в Xcode; після цього Swift може імпортувати його як звичайний модуль.
iosTargets.forEach { target ->
target.binaries.framework {
baseName = "Shared"
export(libs.androidx.lifecycle.viewmodel)
export(projects.feature.product)
}
}
import SwiftUI
import Shared
struct ContentView: View {
@StateObject private var viewModelStoreOwner = IosViewModelStoreOwner()
@State private var products: [ProductUiModel] = []
@State private var isLoading: Bool = true
private var productsViewModel: ProductsViewModel {
viewModelStoreOwner.productsViewModel(
factory: KoinDependencies().getViewModelFactory()
)
}
// ...
}
Kotlin і Swift
Під час інтеграції Kotlin Multiplatform зі Swift слід враховувати кілька технічних обмежень. Деякі Kotlin-конструкції (наприклад, складні generics або sealed-класи) не мають точного відповідника у Swift і експортуються у спрощеному вигляді. Також існують нюанси з типами nullability, відображенням корутин у Swift Concurrency та мапінгом Exception на NSError, тому інтероперабельність, хоч і зручна, але не повністю прозора.
Робота з Flow
Для покращення Developer Experience є додатковий плагін, який знімає багато обмежень під час використання Kotlin у Swift. Це плагін SKIE для Kotlin Multiplatform:
Ось що додає SKIE (Swift Kotlin Interop Enhancer) plugin у KMP-проєкті:
- Нативні Swift-enum.
- Коректні Swift-friendly назви методів і властивостей (згідно зі Swift API Design Guidelines).
- Кращу підтримку suspend-функцій — автоматичне перетворення на Swift async/await.
- Використання Flow на Swift як AsyncSequence.
За використання цього плагіну робота з Flow виглядає наступним чином у Swift:
for await state in productsViewModel.uiState {
isLoading = state.isLoading
products = state.products
}
Навігація
Працюючи з KMP, я виявив, що навіть такі на перший погляд платформозалежні елементи, як навігація між екранами, можуть бути винесені у спільний код. Традиційні API для навігації тісно пов’язані з UI. У KMP ми можемо мати навігаційну логіку окремо від імплементації UI. Одне з рішень, яке допомагає це реалізувати, — бібліотека Decompose.
Я використав її в демо-застосунку для навігації на екрані користувача:

Decompose використовує компонентно-орієнтований підхід для організації навігації та управління станом у KMP.
Кожен екран або логічна частина програми реалізується як окремий компонент. Компоненти мають свій стан і життєвий цикл, яким керує Decompose.
interface GuestComponent {
fun onLoginClicked()
fun onRegisterClicked()
}
class GuestComponentImpl(
componentContext: ComponentContext,
private val onLogin: () -> Unit,
private val onRegister: () -> Unit,
) : GuestComponent, ComponentContext by componentContext {
override fun onLoginClicked() = onLogin()
override fun onRegisterClicked() = onRegister()
}
Наступний код визначає конфігурації екранів (Config) і може розглядатися як аналог аргументів навігації: кожен об’єкт відповідає конкретному маршруту або стану, до якого можна перейти.
@Serializable
sealed class Config {
@Serializable data object Guest : Config()
@Serializable data object Register : Config()
@Serializable data object Login : Config()
@Serializable data object Customer : Config()
}
У Decompose StackNavigation використовується для управління стеком навігації. У нашому прикладі:
private val navigation = StackNavigation<Config>()
...
GuestComponentImpl(
componentContext,
onLogin = { navigation.pushToFront(Config.Login) },
onRegister = { navigation.pushToFront(Config.Register) }
)
З нативним застосунком Decompose інтегрується так, що ми на рівні UI взаємодіємо просто з інтерфейсом компонента. Ось приклад зі SwiftUI коду:
struct GuestView: View {
let component: GuestComponent // <-- Kotlin interface
...
Button(action: {
component.onLoginClicked()
})
...
Button(action: {
component.onRegisterClicked()
})
}
Я не описав тут усіх деталей того, як інтегрується ця бібліотека — це можна подивитися в коді демо-застосунку. Я просто продемонстрував ідею, що можливо мати спільну логіку для навігації. Це прибирає продубльовану логіку з UI-рівня: нативний застосунок відповідає за рендеринг, а KMP обробляє події й вирішує, який екран показати в поточному стані застосунку.
Тести
Завдяки спільному KMP-коду тести достатньо написати один раз у commonTest, і вони працюватимуть для всіх платформ без дублювання.
Test source sets
В KMP для тестів використовується бібліотека kotlin-test (JUnit не підходить через залежність на JVM-платформу). Kotlin-test дублює основний функціонал JUnit. Ключова перевага — тести пишуться один раз, а Gradle автоматично проганяє їх для кожного таргета (Android, iOS тощо).
За потреби можна додати платформені тести в androidTest чи iosTest, але основний обсяг тестування залишається у спільному commonTest.
Приклад з демо-застосунку:
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CartTest {
@Test
fun addItem_IncrementsQuantity_IfProductExists() {
val cart = Cart()
val product = createProduct("1", 10.0)
cart.addItem(product)
cart.addItem(product)
assertEquals(1, cart.items.size)
assertEquals(2, cart.items.first().quantity)
}
}
Mocking
Вибираємо з того, де є підтримка KMP. Наприклад, Mockito не підійде також через залежність на JVM-таргет. В демо-застосунку я використав бібліотеку Mokkery:
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verifySuspend
class RegisterUseCaseImplTest {
private val authRepository = mock<AuthRepository>()
private val tokenStorage = mock<TokenStorage>()
private val customerRepository = mock<CustomerRepository>()
private val useCase = RegisterUseCaseImpl(
authRepository = authRepository,
tokenStorage = tokenStorage,
customerRepository = customerRepository
)
@Test
fun `invoke should pass correct customer info to repository`() = runTest {
// given
everySuspend { authRepository.register(credentials) } returns AuthToken("reg_token")
everySuspend { authRepository.login(credentials) } returns AuthToken("sess_token")
everySuspend { tokenStorage.saveToken(any()) } returns Unit
everySuspend { authRepository.clearSession() } returns Unit
everySuspend { customerRepository.createCustomer(any(), any()) } returns Unit
// when
useCase.invoke(credentials, customerInfo)
// then
verifySuspend { customerRepository.createCustomer("[email protected]", customerInfo) }
}
}
KoinTest
У модульних тестах потрібно підміняти справжні залежності на тести-дублери — моки, фейки або спеціальні тестові імплементації. KoinTest спрощує це, надаючи окремий Koin-контекст для тестів. Завдяки цьому можна визначати власні тестові модулі, запускати DI у ізольованому середовищі та інʼєктити залежності так само, як у реальному застосунку, але з контрольованими тестовими значеннями.
Ось приклад з демо-застосунку:
class CartViewModelTest : KoinTest {
class FakeCartRepository : CartRepository { ... }
@BeforeTest
fun setup() {
startKoin {
modules(appModule(), module {
single<ProductRepository> { InMemoryProductRepository() }
single<CartRepository> { FakeCartRepository() }
factoryOf(::CartViewModel)
})
}
}
@AfterTest
fun teardown() {
stopKoin()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun checkProductRemovedFromCart() = runTest {
// Set test dispatcher for Main before ViewModel creation
val testDispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
// Create Fake repository and pre-fill cart
val cartRepo = getKoin().get<CartRepository>()
val productsRepo = getKoin().get<ProductRepository>()
val productToAdd = productsRepo.getFeatured().first()
val cart = Cart().apply {
addItem(productToAdd)
addItem(productToAdd)
addItem(productToAdd)
}
cartRepo.saveCart(cart) // sets initial state
// Create ViewModel after Main dispatcher is set
val vm = getKoin().get<CartViewModel>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
vm.shoppingCart.collect { }
}
// Trigger action (remove product)
vm.removeProduct(productToAdd.id)
// Advance coroutines to process emissions
advanceUntilIdle()
// Collect the latest cart from the Flow
val updatedCart = vm.shoppingCart.first()
// Assert
assertEquals(1, updatedCart.items.size)
assertEquals(2, updatedCart.items.first().quantity)
// Cleanup
Dispatchers.resetMain()
}
}
Інтеграційні тести
Інтеграційні тести дозволяють перевірити, як кілька компонентів працюють разом. У KMP-проєктах це особливо важливо, адже логіка однакова для Android та iOS, але платформи можуть по-різному працювати з базами даних, потоками чи файловою системою.
У наступному прикладі протестуємо взаємодію з базою даних. Щоб мати доступ до платформних API, використовуємо механізм expect / actual.
Спільний тест, який використовує Room DB:
class CartLocalDataSourceTest : PlatformTest() {
private lateinit var db: AppDatabase
@BeforeTest
fun setUp(){
db = createTestDatabase()
}
@AfterTest
fun tearDown() {
db.close()
}
}
Створення Room DB відбувається в платформному коді. Налаштуємо функцію createTestDatabase() наступним чином:
// commonTest expect fun createTestDatabase(): AppDatabase
// iosTest
actual fun createTestDatabase(): AppDatabase {
return Room.inMemoryDatabaseBuilder<AppDatabase>()
.setDriver(NativeSQLiteDriver())
.build()
}
// androidDeviceTest
actual fun createTestDatabase(): AppDatabase {
return Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java
)
.allowMainThreadQueries()
.build()
}
Для Android особливість у тому, що нам потрібно мати доступ до Context. В instrumented-тестах ми отримуємо його через InstrumentationRegistry.getInstrumentation().targetContext.
Щоб інтегрувати це в KMP, ми виносимо платформно-специфічну логіку в expect / actual. На спільному рівні оголошуємо абстрактний клас для тестів:
expect abstract class PlatformTest()
Андроїд-реалізація (actual):
@RunWith(AndroidJUnit4::class) actual abstract class PlatformTest actual constructor()
Цей раннер дозволяє тестам отримувати доступ до InstrumentationRegistry і, відповідно, до Android Context.
Приклад тесту:
class CartLocalDataSourceTest : PlatformTest(){
@Test
fun clearCart_removes_all_items() = runTest {
val product = createProduct("1", 10.0)
val cart = Cart().apply { addItem(product) }
dataSource.saveCart(cart); advanceUntilIdle()
dataSource.clearCart(); advanceUntilIdle()
val loadedCart = dataSource.getCart().first()
assertTrue(loadedCart.items.isEmpty())
}
}
Висновки
Kotlin Multiplatform дозволяє зменшити дублювання логіки між Android та iOS і спростити підтримку спільної частини застосунку. При цьому він не забирає контроль над платформенною частиною й не змушує відмовлятися від нативних API — кожна платформа залишається у своїй зоні відповідальності.
Мій досвід роботи з Kotlin Multiplatform загалом позитивний. Найбільше тут сподобалось використання Kotlin як спільної мови, передбачувана взаємодія з платформами та можливість поступово інтегрувати KMP у поточний проєкт. Окремий бенефіт — зростаюча кількість Android-бібліотек із підтримкою KMP, що дозволяє напряму переносити нативний досвід у спільний код без додаткового «перевчання».
Я б рекомендував Kotlin Multiplatform як інструмент, який логічно доповнює нативну розробку. Він не замінює платформи, а дозволяє чіткіше розділити відповідальність між спільною логікою та UI й краще зрозуміти обмеження й можливості кожної з них.
Детальний код можна знайти за посиланням на GitHub.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.
11 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів