Kotlin Multiplatform — технология, которая позволяет использовать общую кодовую базу для бизнес-логики приложений разных платформ и писать платформенный код только там, где это необходимо. Хотя последнее время о ней много и часто говорят, найти информацию о нюансах внедрения KMP в проекты довольно сложно. В этом мы убедились лично, когда пытались разобраться, что и как именно можно безболезненно выносить в common-модуль. 

Меня зовут Сергей, я Android-разработчик в компании MobileUp. В этой статье я поделюсь своим опытом работы с KMP и на примере одного из наших проектов покажу, как мы выносим код в общий модуль. 

Общий модуль в KMP

Немного про структуру KMP мобильного приложения:

  • commonMain – здесь хранится код, который можно объединить полностью. Например чистая логика без какого-либо обращения к нативу; 

  • iosMain – здесь хранится код, который будет специфичен для iOS;

  • androidMain – здесь хранится код, который будет специфичен для Android;

Все эти части пишутся на Kotlin. При компиляции приложения под Android используются commonMain и androidMain. При компиляции под iOS – commonMain и iosMain. 

Так как по сути объединять функциональность позволяет именно commonMain модуль, а androidMain и iosMain являются платформенными, под общим модулем будем подразумевать именно его.

Общий модуль в KMP позволяет существенно уменьшить дублирование кода, так как ключевая логика приложения пишется один раз и на обе платформы. Это, в свою очередь, сокращает время на разработку, и, как следствие, уменьшает её стоимость. Также снижается количество потенциальных багов, ведь кодовая база общая, а значит и баги на обеих платформах тоже общие.

Дальше поэтапно разберём, как объединять функциональность между платформами. 

Логика экранов (ViewModel)

Для создания единой логики экранов в MobileUp мы используем Decompose, но подойдёт  любая KMP-библиотека для реализации логики экранов, например moko-mvvm

Мы начали работать с Decompose и компонентным подходом ещё до внедрения KMP, потому что это удобно. Если объяснять простым языком, компонент — это вью модель. Для конкретного экрана создаётся компонент (либо несколько компонентов) — класс, описывающий возможные действия и поля пользователя. Вот так будет выглядеть интерфейс компонента самого простого экрана регистрации: 

interface RegisterComponent {
   val login: CStateFlow<String>
   val password: CStateFlow<String>
  
   fun onLoginChanged(login: String)
   fun onPasswordChanged(password: String)
   fun onRegisterClick()
}

Создается и реализуется этот интерфейс в общем модуле.

Далее просто передаем реализацию компонента в UI (у нас он нативный — Jetpack Compose на Android и SwiftUI на iOS) и используем. 

Так как UI у нас пишется два раза, а компонент один, то желательно следить за тем, чтобы вся логика была именно в компоненте, а не в UI. Таким образом получится избежать ненужного дублирования кода.

В примере интерфейса компонента видно незнакомую сущность — CStateFlow. Это обертка над StateFlow. Она нужна из-за особенностей взаимодействия Kotlin со Swift. Вот ссылка на Gist где можно посмотреть ее реализацию.

Навигация между экранами

Чтобы вынести навигацию в общий модуль мы также используем библиотеку Decompose. Для организации навигации в данной библиотеке используется сущность ChildStack. Это стек компонентов, который должен находиться в ещё одном компоненте. Получается древовидная структура, когда одни компоненты являются дочерними для других. 

Иерархия компонентов
Иерархия компонентов

Вручную на нативной стороне нам достаточно создать экземпляр только одного компонента — RootComponent. 

Стек компонентов – это просто наблюдаемое значение. За ним наблюдает UI и меняет экран в зависимости от текущего активного компонента.

interface RootComponent {
    
    val childStack: CStateFlow<ChildStack<*, Child>>

    sealed interface Child {
        class Auth(val component: AuthComponent) : Child
        class Home(val component: HomeComponent) : Child
        ...
    }
}

RootComponent является родительским для всех остальных компонентов. В родительском компоненте нужно объявить его дочерние компоненты и описать их создание. Затем просто передать созданный RootComponent в UI (напоминаю, что создаём экземпляр RootComponent мы в нативе). 

Как это выглядит в Android:

class MainActivity : ComponentActivity() {
   
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       val rootComponent = RealRootComponent(defaultComponentContext())`
       setContent {
           AppTheme {
               RootUi(rootComponent)
           }
       }
   }
}

Больше прочитать про организацию навигации с помощью библиотеки Decompose можно в этой статье.

Работа с сетью

Для организации сетевого взаимодействия отлично подходят мультиплатформенные библиотеки Ktor, Ktorfit и KotlinX Serialization. Ktorfit является оберткой над Ktor, которая позволяет писать код как на Retrofit. Это облегчает процесс внедрения Android-разработчиков в KMP-разработку. Основная сущность в Ktor — это HttpClient. Вот так он создаётся:

val httpClient = HttpClient {
   // настраиваем HttpClient, устанавливаем плагины здесь
}

Ktorfit создается так:

val ktorfit = Ktorfit.Builder()
   .baseUrl(backendUrl)
   .httpClient(httpClient)
   .build()

Далее пользуемся инстансом Ktorfit так же, как с Retrofit. Например, так выглядит интерфейс Api в Ktorfit:

interface EventsApi {
    
    @GET("profile/events")
    suspend fun getEvents(@Query("userId") userId: Long): EventsResponse
    
    @POST("profile/events")
    suspend fun createEvent(@Body eventCreateEditRequest: EventCreateEditRequest): EventResponse
    
    @PATCH("profile/events")
    suspend fun editEvent(
        @Query("eventId") eventId: Long,
        @Body eventCreateEditRequest: EventCreateEditRequest
    )
    
    @DELETE("profile/events")
    suspend fun deleteEvent(
        @Query("userId") userId: Long,
        @Query("eventId") eventId: Long
    )
}

И создаём инстанс этого интерфейса:

val eventsApi: EventsApi = ktorfit.create()  

Отличительная особенность Ktor — высокая кастомизируемость, достигаемая за счёт плагинов. «Из коробки» Ktor уже предоставляет множество стандартных плагинов, которые покрывают большую часть юзкейсов:

  • Logging — для логирования;

  • ContentNegotiation — для сериализации;

  • DefaultRequest — для указания дефолтных параметров запросов (например, можно указать contentType = “application/json” для всех запросов);

  • HttpSend — для использования интерсепторов.

DI

Для реализации DI мы используем мультиплатформенную библиотеку Koin. Под каждую фичу создаётся отдельный DI-модуль. Пример простенького модуля:

val eventsModule = module {
    single<EventsApi> { get<Ktorfit>().create() }
    single<EventsRepository> { EventsRepositoryImpl(get(), get(), get()) }
    single<EventStorage> { InMemoryEventStorageImpl() }
}

На каждой из сторон, androidMain и iosMain, есть свой платформенный модуль, который позволяет передавать в DI платформенную функциональность. Вот так выглядит сигнатура функции, возвращающей платформенный модуль:

expect fun platformCoreModule(configuration: Configuration): Module

С помощью expect/actual мы реализуем эту функцию на каждой платформе. Почитать про expect/actual можно тут.

Благодаря этому получается сделать такой ход: создаем интерфейс в commonMain, в androidMain и iosMain реализуем его, используя нативные библиотеки, и передаем в DI. У интеропа со Swift есть ограничение — подключать в iosMain библиотеки, написанные на чистом Swift, нельзя. Поэтому мы решили реализовывать интерфейсы на стороне Swift и передавать в платформенный модуль с помощью сущности Configuration (её видно в сигнатуре функции).

Хранение данных

Для хранения данных мы используем своё решение.

Идея взята с multiplatform-settings, но нам было проще реализовать интерфейсы самим, чем изучать стороннюю библиотеку.

interface SettingsFactory {
   
   fun createSettings(name: String): Settings
   
   fun createEncryptedSettings(): Settings
}
interface Settings {
   
   suspend fun getString(key: String): String?
   
   suspend fun putString(key: String, value: String)
   
   suspend fun remove(key: String)
}

Репозитории находятся в общем модуле. В них передаём SettingsFactory, а сами Settings для хранения создаём уже в репозитории. Реализации Settings и SettingsFactory создаются на нативной стороне, реализация SettingsFactory передаётся в общий модуль с помощью DI. 

Строковые ресурсы

Даже строковые ресурсы можно делить между платформами. Для этого есть хорошая библиотека moko-resources. Объявление ресурсов очень похоже на объявление ресурсов в Android-разработке. Есть xml-файл, в котором вы указываете строковые ресурсы. При выполнении gradle-задачи ресурсы берутся из этого xml-файла и далее генерируются под каждую платформу. Также создаётся объект, в котором хранятся id данных ресурсов (на Android – одни, на iOS – другие). 

Получить сами строки из общего модуля не получится (для этого, как минимум, нужен Context). Поэтому данная библиотека не предоставляет способ получения самих значений строк в общем модуле. Она предоставляет возможность передавать id на ресурс, значение которого будет получаться уже на нативной стороне. 

Нативные инструменты (датчики, переход в другие приложения)

Нам на наших KMP проектах довольно часто приходилось подключать нативные инструменты к общему коду, в связи с чем нам пришлось создать способ легкого подключения их к общему модулю. Для этого достаточно создать интерфейс-прослойку, реализовать его на нативной части и передать в общий модуль посредством DI (примерно так же как было и с хранением данных). 

Например, нам нужно получить доступ к геолокации устройства. Создаём интерфейс в общем модуле — LocationService с методом getCurrentLocation():

data class GeoCoordinate(
   val lat: Double,
   val lng: Double,
)

interface LocationService {
   
   suspend fun getCurrentLocation(): GeoCoordinate
}

Создаём реализации на нативных сторонах:

class AndroidLocationService(
   private val context: Context
) : LocationService {

   @OptIn(ExperimentalCoroutinesApi::class)
   override suspend fun getCurrentLocation(): GeoCoordinate {
       try {
           val fusedLocationProviderClient =
               LocationServices.getFusedLocationProviderClient(context)
           val cancellationTokenSource = CancellationTokenSource()
           val currentLocationTask = fusedLocationProviderClient.getCurrentLocation(
               PRIORITY_HIGH_ACCURACY,
               cancellationTokenSource.token
           )
           return currentLocationTask.await(cancellationTokenSource).let {
               GeoCoordinate(it.latitude, it.longitude)
           }
       } catch (e: Exception) {
           throw LocationNotAvailableException(e)
       }
   }
}

Затем передаем это в DI:

Общий модуль не зависит от натива, а предоставляет интерфейсы для реализации
Общий модуль не зависит от натива, а предоставляет интерфейсы для реализации

Таким образом не придется использовать сервисы в нативе (то есть в UI), можно будет использовать их во вью модели.

Пример фичи — обработка push-уведомлений

Теперь давайте разберём реальный пример фичи, довольно популярной в коммерческой разработке. Задача: нам приходят push-уведомления с названием, текстом и диплинком. При нажатии на уведомление нас должно направлять в ту часть приложения, которая указана в диплинке.

Получение и отображение самих уведомлений рассматривать не будем, так как это полностью нативная часть. Рассмотрим именно обработку нажатия на уведомление.

Создаем класс PushData в commonMain:

data class PushData(
   val map: Map<String, String>
)

Затем создаём метод onPushPressed(pushData: PushData) в RootComponent (у нативных сторон есть прямой доступ к нему): 

override fun onPushPressed(pushData: PushData) {
   componentScope.launch {
       val deeplink = pushParser.parseDeeplink(pushData)
       deeplink?.let { handleDeeplink(it) }
   }
}

Реализация PushParser’а у вас будет своя, в зависимости от того, какой формат данных для описания диплинков используется. 

Так как библиотека Decompose включает в себя ещё и навигацию, можно легко сделать перенаправление пользователя в нужную часть приложения прямо в общем модуле (в RootComponent):

private fun handleDeeplink(deeplink: Deeplink) {
   goToHome(HomeComponent.Page.Main)
   when (deeplink) {
       is Deeplink.Payment -> {
           navigation.push(ChildConfig.Payment(deeplink.paymentLink))
       }

       is Deeplink.Catalog -> {
           getCurrentHomeComponent()?.showCatalog(
               filters = deeplink.filters,
               sorting = Sorting.DEFAULT,
               category = Category.DEFAULT
           )
       }

       is Deeplink.Profile -> {
           getCurrentHomeComponent()?.switchPage(HomeComponent.Page.Profile)
       }

       is Deeplink.Events -> {
           navigation.push(ChildConfig.Events)
       }
       ...
   }
}

Теперь просто при нажатии на уведомление достаточно вызвать метод RootComponent.onPushPressed(). Не нужно настраивать навигацию по нажатию на пуши отдельно под каждую платформу, все собрано в одном месте, можно легко добавить новые переходы. 

Наша статистика по KMP: количество строк кода .kt и .swift, экономия по часам разработки

KMP позволяет существенно сократить время разработки. Приведу пример одного из наших проектов. Код общего модуля у нас пишут Android-разработчики. Суммарно получилось 2072 часа рабочего времени Android-разработчиков и 843 часа рабочего времени iOS-разработчиков. Получается, что затраты на iOS часть составили всего 40% от затрат на общую и Android части! И это при условии, что разработчикам требовалось время чтобы привыкнуть к новой технологии и познакомиться с ней. 

По количеству строк кода тоже заметна польза. Android, common и iOS части приложения получились примерно равными по количеству строк. Получается из каждого приложения, Android и iOS, мы вынесли в общий модуль примерно половину их кода. А это уменьшение затрат на разработку на 33%!

Количество строк кода:

commonMain
commonMain
androidMain + androidApp
androidMain + androidApp
iosMain + iosApp
iosMain + iosApp

Коротко о главном

Стоит отметить, что это не весь потенциал KMP. Да, сейчас видно, что возможности KMP огромны — можно обобщать большую часть кода между платформами, экономить время и ресурсы. Но всё-таки, часов Android-разработчиков уходит больше, чем при нативной разработке. Поэтому настоящий потенциал технологии будет раскрыт, когда появится больше готовых решений именно под KMP (библиотеки, паттерны), и к ней привыкнут разработчики. 

И, конечно, хочется надеяться, что ещё больше возможностей появится с выходом  Compose Multiplatform, ведь он позволит объединять ещё и UI. Поэтому я искренне верю что за KMP будущее. Всем добра!

Комментарии (6)


  1. Algrinn
    28.11.2023 14:10
    +2

    Будущее за Kotlin Multiplatform, если он сможет победить React Native и Flutter. Так что мы внимательно следим за JetBrains. :^) Им уже получилось один раз расправиться с Eclipse и NetBeans-ом, а во второй раз помочь сделать Android Studio и пнуть джаву на Android-e. Так что это безусловно опасная фирма.


    1. idd451289
      28.11.2023 14:10
      +1

      Ну как по мне не выгонит он флаттер и реакт. Просто реакт это не про то когда мы пишем проект с нуля и можем набрать любой стек, а скорее про то когда у нас есть веб морда на реакте, команда, и нам говорят что хотят мобильное приложение и побыстрее, а из бюджета чёрствый бублик)


  1. dkreshikhin
    28.11.2023 14:10

    Суммарно получилось 2072 часа рабочего времени Android-разработчиков и 843 часа рабочего времени iOS-разработчиков. Получается, что затраты на iOS часть составили всего 40% от затрат на общую и Android части!

    Не понятно как вы сделали этот вывод. Предположим если без KMM у вас было 1000 на Android и 1000 часов на iOS, потом появился KMP и к этому коду добавился еще 1000 часов. То все равно выглядело бы так что на iOS ушло всего 50% времени разработки!

    > По количеству строк кода тоже заметна польза. Android, common и iOS части приложения получились примерно равными по количеству строк. Получается из каждого приложения, Android и iOS, мы вынесли в общий модуль примерно половину их кода. А это уменьшение затрат на разработку на 33%!

    Тот же аргумент. Процент iOS кода уменьшился только благодаря тому что Android кода стало больше.

    Если я был должен Васе 100 рублей, потом занял у Пети 900 рублей это не значит что мой долг Васе сократился в 10 раз (со 100% до 10%).

    Плюс подмечу, что по моему опыту, в проект на 20-30 тыс. строчек кода без учёта кода библиотек и т.д. можно упихнуть практически любой функционал. Так что если для поддержки основного кода требуется наличие такого количество iOS кода, то выглядит всё отнюдь не радужно.


    1. kursor1337 Автор
      28.11.2023 14:10

      Процент iOS кода уменьшился только благодаря тому что Android кода стало больше.

      Да, отчасти это так, я об этом писал в выводе:

      Да, сейчас видно, что возможности KMP огромны — можно обобщать большую часть кода между платформами, экономить время и ресурсы. Но всё-таки, часов Android-разработчиков уходит больше, чем при нативной разработке.

      Думаю, нужно было это прямо в том пункте и написать, чтобы не вводить в заблуждение, спасибо за замечание.

      Но зависимость не 1:1, то есть уменьшение затрат со стороны iOS команды не спровоцировало увеличения затрат Android команды на аналогичный объем. Поэтому, даже с учетом бо́льших затрат со стороны Android-команды экономия ресурсов все же есть, просто она меньше 40%.

      Насчет 20-30 тыс. строк кода как будто бы довольно странное утверждение, учитывая, что проекты бывают совершенно разные.


      1. dkreshikhin
        28.11.2023 14:10

        Да в целом я это и хотел сказать, вы все правильно поняли.

        Насчет 20-30 тыс. строк кода как будто бы довольно странное утверждение, учитывая, что проекты бывают совершенно разные.

        Да приложения бывают разные, но рефакторинг и повторное использования кода позволяют удерживать кодовую базу в разумных приделах. Что-то вроде эффекта сжатия - когда архивируется небольшой кусок текста, например, LZW алгоритмом, то эффективность сжатия низкая, а когда кусок текста большой - эффективность растёт настолько что конечный размер архива практически перестаёт зависеть от объёма текста.

        Вот даже картинка есть (LZW compression results-text length dependence):

        Что-то подобное происходит и с кодовой базой за счёт повторного использования.

        Конечно речь не про супераппы где могут быть объединены приложения совершенно разной природы, а когда функционал наращивается в конкретной доменной области.


  1. GovnocoderHunt42
    28.11.2023 14:10
    -2

    Удивительно, из мультиплатформы, пытаются сделать кроссплатформу, и самое забавное, ориентируются именно на кроссплатформу! Молодцы, мало того что откинулись на два десятка лет назад, когда ровно тоже делали, только без слов "котлин мультиплатформ", так еще и на те же грабли наступают, в следствии чего, и многие айти гиганты, начали развивать кроссплатформу, но нет, в джетбреинс классика, сделать херню и продвигать ее под видом современных технологий. Очень современно, писать платформенный код под две платформы, и шаред с констрированной стд либой, в которой ни работать с валютой невозможно, ни с временем (я даже не упоминаю огрызок kotlinx.time), с японским временем нормально не поработаешь, оффсет от зоны парсер отличить не может, валюты, проценты, большие числа, все что встречается в любом крупном приложении, тут будет задачей изобрести то, что существует уже десяток лет в JDK, в дотнете. Весело дурят котлинистов, в основном тех, кто относится к категории вкатуны и зумеры.