Kotlin Muptiplatform в мобильной разработке. Рецепты общего кода для Android и iOS

16de0f6ce2db26d67d038004cc770c6d.png

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
   val password: CStateFlow
  
   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>

    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 { get().create() }
    single { EventsRepositoryImpl(get(), get(), get()) }
    single { 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
)

Затем создаём метод 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 будущее. Всем добра!

© Habrahabr.ru