Пришвидшення мобільної розробки за допомогою Kotlin Multiplatform

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Привіт! Мене звати Михайло Микитин, я 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.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось7
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Використав версію Gradle, яка йшла за замовчуванням при створенні нового проєкту в Android Studio.
Планую перейти на Gradle 9 після перевірки сумісності — додав це в TODO

Expect / Actual

гарно виглядає лише в теорії. на практиці воно не працює якщо вам треба iOS як одна з платформ.
Причина в тому, що реалізація на кожну платформу це все ще kotlin код, а всі реальні бібліотеки для iOS це swift і тд. Ви не можете з котлін використати код свіфт.

Я так і не знайшов ні одного реально прикладу де це працює з чимось реальним. Наприклад — читання та запис файлів. теоретично це можна було б зробити але практично як?

Я від цього повністю відмовився і використовую інтерфейси. Описую інтерфейс в спільному коді а конкретні імплементації вже для андроід і айос. Тоді передаю створені обʼєкти в спільний код.

Для невеликих платформних API зручно використовувати expect/actual, для більших сервісів — інтерфейси/DI. Обидва підходи валідні і доповнюють один одного. В офіційній документації це також розглядається:

If the platform-specific logic is too big and complex, you can simplify your code by defining an interface to represent it in the common code and then providing different implementations in the platform source sets.

У iosMain це все ще котлін код, але він має доступ до Objective-C API через interop. Наприклад, можна використовувати platform.Foundation.NSFileManager без проблем.

Ось приклад з демо-додатку:
github.com/…​tabase/DatabaseBuilder.kt

Swift напряму з Kotlin дійсно не викликається, але використовується Objective-C API (через interop) або можна зробити Swift wrapper з @objc

я не хотів навіть починати розбиратися з Objective-C. Тому одразу використав інтерфейси

Не розумію хайпу навколо KMP. Як людина, що пише під обидві платформи, скажу: спроба всидіти на двох стільцях зазвичай закінчується поганим DX на обох. В iOS це перетворюється на милиці з дженеріками та flow, а в Android тягне за собою зайві абстракції.
Якщо вже так треба спільну функціональність (для складних обчислень, наприклад) — то краще вже Cрр, там хоч контроль повний і адекватна швидкість та можливості.
А бізнес-логіку простіше тримати нативною, дякую за статтю, але особисто для себе ні.

А бізнес-логіку простіше тримати нативною

скорее всего просто не было опыта работы со сложными приложениями

Якщо ви про те, що пхати KMP у «складні додатки» замість нормального Cpp ядра — це ознака відсутності архітектурного досвіду, то я з вами повністю погоджуюсь.
А якщо ви вирішили поставити діагноз мені — то це дуже сміливо. Особливо враховуючи, що в моїх задачах «складність» вимірюється не кількістю формочок, а перформансом, де KMP просто зайвий.

Якщо для вас Срр ядро в 2026 норма, то ви — крінж та вам за 35.

Я роблю додатки цим самим способом і мені це дуже підходить. Було трохи складно до появи ШІ, а тепер взагалі просто.

Я тут писав топік про це dou.ua/forums/topic/54897

З того часу стало ще простіше. Я використовую claude code . Роблю view або activity для андроід а потім кажу «тепер зроби таке саме для ios» і він робить у 99% випадках правильно аналогічно до андроід.

В мене є описані інтсрукції про тому як ставити відповідність компонентів. Як організована структура екранів в обох системах, як робити фонові процеси і тд

Є категорії додатків, де складні обчислення не потрібні, але є багато правил бізнес-логіки — і тут є сенс тримати це в спільному коді. Kotlin для цього підходить дуже добре. Тому в KMP є своя ніша.

Підписатись на коментарі