Kotlin Multiplatform в мобильной разработке. Рецепты общего кода для Android и iOS
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 мы в нативе).
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, устанавливаем плагины здесь }
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) } } }
Таким образом не придется использовать сервисы в нативе (то есть в 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%!
Коротко о главном
Стоит отметить, что это не весь потенциал KMP. Да, сейчас видно, что возможности KMP огромны — можно обобщать большую часть кода между платформами, экономить время и ресурсы. Но всё-таки, часов Android-разработчиков уходит больше, чем при нативной разработке. Поэтому настоящий потенциал технологии будет раскрыт, когда появится больше готовых решений именно под KMP (библиотеки, паттерны), и к ней привыкнут разработчики.
И, конечно, хочется надеяться, что ещё больше возможностей появится с выходом Compose Multiplatform, ведь он позволит объединять ещё и UI. Поэтому я искренне верю что за KMP будущее. Всем добра!