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