
Привет! Я Тимур, разработчик мобильных приложений в KTS.
В прошлом году мы решали любопытную задачу. Нам нужно было вдвоем за одну рабочую неделю собрать прототип мобильного приложения для сервиса бронирования отелей.
Задача осложнялась тем, что заказчик рассматривал не только нашу команду на роль подрядчика. Мы не могли отдать сырой черновик, в котором просто нажимаются нужные кнопки и работают ключевые сценарии — мы должны были превзойти конкурентов-флаттеристов. И нам удалось это сделать с помощью KMP.
Да, тема довольно холиварная, и в статье я не заявляю, что KMP лучше Flutter’а во всем. Каждая технология хороша под свои задачи. Вместо этого я расскажу о конкретном проекте, на котором KMP оказался более удачным выбором. Также коснусь вопросов архитектуры и миграции Android-приложения на iOS с помощью CMP, а заодно подсвечу подводные камни, с которыми пришлось разбираться в процессе.
О самом проекте я уже рассказывал на Podlodka Crew и на Android Broadcast, так что при желании вы можете посмотреть выступление в записи. Однако в статье я подробнее останавливаюсь на некоторых нюансах разработки и даю более свежие комментарии по текущему состоянию использованных технологий, поэтому рекомендую прочитать и ее.
Оглавление
Контекст
Заказчику было необходимо, чтобы в приложении была реализована бесшовная и максимально отзывчивая шахматка. Для тех, кто не сталкивался с понятием: шахматка — это диаграмма Ганта, адаптированная для работы с гостиницами. С ее помощью менеджер может легко посмотреть, в какие даты свободны определенные номера, и управлять бронированиями гостей.

Изначально целевой платформой был Android, поскольку основная часть аудитории будущего приложения пользовалась телефонами на этой ОС. В то же время мы хотели с порога заложить возможность дальнейшего масштабирования на iOS, поэтому было принято решение о кроссплатформенной разработке.
Веб-версия у заказчика была реализована через стандартный пейджер, и пользоваться ей с телефона было крайне неудобно. Отсюда и возникла потребность в бесшовной шахматке для мобильных устройств. Само собой, она должна была работать без фризов и моментально подгружать все бронирования.
С этим требованием и не справилась Flutter-версия от другой команды. Бронирования прогружались с заметным лагом, переходы подтормаживали.
Мы же выбрали не рисковать и не стали сразу собирать кроссплатформенный UI на тогда еще сыроватом CMP (с тех пор он, кстати, похорошел, о чем мы рассказывали в недавнем обзоре). Бизнес-логику мы изначально реализовывали в KMP для разных платформ, а пользовательские интерфейсы планировали разработать нативно под Android и iOS. И поскольку Android-версия была в приоритете для заказчика, ее мы и взяли за основу для демо.
Нативную часть MVP мы думали собрать целиком на Jetpack Compse, включая саму шахматку. Однако в условиях сжатых сроков мы решили не экспериментировать и выбрали проверенный Android View. В итоге наша версия шахматки получилась шустрой и отзывчивой.
Думаю, в этом и кроется секрет успеха презентации, после которой заказчик выбрал именно нашу команду. А когда проект уже был за нами, мы поэкспериментировать с использованием CMP. Но об этом чуть позже — пока беглым взглядом пробежимся по архитектуре.
Исходная архитектура
Архитектуру приложения мы делали многомодульной. Глобально мы разделили модули на две категории: shared (бизнес-логика, реализованная на KMP) и android (нативные для UI).
Для shared-модулей мы выделили следующие группы:
:shared:common— для общего кода, который шарится между всеми модулями:shared:core-*— для логики, которая является ядром определенного функционала, например core-auth-
:shared:feature-*- для нескольких фич:api— для реализации интерфейса между слоем бизнес-логики и UI;impl— для реализации логики фичи, сама имплементация закрыта для других модулей API интерфейсом (мы использовали MVI Kotlin, о котором также уже рассказывали в статье Почему так удобно использовать паттерн MVI в KMM);presentation— для реализации ViewModel на KMP;
:shared:main— модуль «зонтик» для сборки всего кода на KMP.
android -модули делились так:
:android:core-*— для логики, которая является ядром определенного функционала, например core-navigation:android:common-*— подгруппа общих модулей (например, :common-ui для шаринга общих виджетов на Jetpack Compose);:android:feature-*— подгруппа модулей для реализации экранов;:android:app— основной модуль Android приложения
Стек
Бизнес-логика: KMP
Для реализации бизнес-логики мы выбрали довольно стандартный набор библиотек. В него вошли Kotlin Coroutines, уже упомянутый MVIKotlin, Ktor и Koin. Кроме этого, для хранения данных использовали KMP DataStore и SQLDelight, а сериализацию данных — с kotlinx.serialization. На них я не буду останавливаться подробно.
Из любопытного — для хранения состояния бизнес-логики мы использовать Moko-MVVM, поскольку на тот момент Google еще не адаптировали свою ViewModel. Общие для платформ ресурсы мы тоже шарили при помощи Moko-resources, поскольку версия этой библиотеки от Google тоже была только в проекте.
UI: натив и CMP
Стек Android-версии тоже не был примечательным. Как я уже говорил, саму шахматку мы собрали на Android View. Для всего остального мы использовали Jetpack Compose, в частности настроили навигацию с помощью Navigation. А дальше нам нужно было перетащить все это на Compose Multiplatform.
Мы активно следили за развитием CMP и знали, что крупные компании уже тогда обкатывали его в проде. Так, например, им пользовались Sber Autotech, Т-Банк и Контур.
Здесь хочется подробнее остановиться на причинах, по которым перформанс отрисовки UI хромал на iOS.
Во-первых, Skia. Flutter переехал на Impeller, а вот разработчики CMP решили остаться со Skia, который не в полной мере поддерживал графический движок iOS Metal. Во многом это и послужило поводом для критики фреймворка от JetBrains со стороны Flutter комьюнити. Разработчики CMP из JetBrains пока не видят проблемы в Skia — возможно, у них есть какой-то план, которого они придерживаются. Однако никто не исключает, что рано или поздно CMP может последовать по тому же пути, что и Flutter, и перейти на другой движок.
Во-вторых, Compose для iOS управляется компилятором Kotlin/Native, у которого есть свой Garbage Collector. При его запуске в runtime также возникали задержки в отрисовке, и наихудшее время паузы доходило до 1,7 мс. Здесь надо сказать, что эта проблема тоже больше не актуальна: разработчики из JetBrains поработали над оптимизацией и сократили этот показатель до 0,4 мс в последних релизах своего фреймворка.
Вернемся к самому проекту. Как вы помните, пользовательский интерфейс для MVP был платформенно-специфичным, и его нужно было адаптировать под iOS, а ее поддержка в тех версиях CMP все еще оставляла желать лучшего. Перформанс шахматки был критичным требованием, и рисковать им было нельзя.
К счастью, использование CMP не означает, что весь UI должен быть кроссплатформенным. Благодаря гибкому интеропу можно совмещать нативные экраны и экраны на CMP, можно интегрировать в кроссплатформенный интерфейс отдельные элементы на SwiftUI, и даже встраивать CMP-компоненты в готовый нативный интерфейс вам никто не запретит. Так мы и решили сделать.
Миграция UI на iOS
Мы пересмотрели архитектуру приложения. Из android-модулей, которые отвечали за реализацию UI, основная часть была адаптирована под CMP и переехала в shared:
модули
:android:core-*оказались в:shared:core;модули
:android:common-*— в:shared:common;и модули
:android:feature-*, соответственно, в:shared:feature-*:ui.
А по соседству с :android:app разместился новый модуль :ios:app, который является основным модулем для приложения на IOS..
Перед тем, как адаптировать модули на Jetpack Compose под CMP, пришлось поднять версии некоторых библиотек. Первая реализация была на Kotlin 1.8.21, для переезда я поднял ее до 2.0.21 — на тот момент она была лучше всего оптимизирована.
Сама адаптация трудностей не вызвала. Чтобы скопировать нативные блоки, мне пришлось выделить код и нажать Ctrl+C, чтобы пошарить его между разными платформами — открыть целевой модуль и нажать Ctrl+V. Шучу, конечно — некоторые правки все-таки пришлось внести. Поговорим о них подробнее.
Что пришлось адаптировать
Resources
Например, в Compose-виджетах у нас использовались @DrawableRes и @StringRes (Int для ID ресурсов). Все, что нужно было сделать — заменить их на кроссплатформенные. У Moko как раз есть удобная библиотека Moko Resources для Compose, которая реализовывает painterResource и stringResource.
Android Resources |
Moko Resources |
|
|
Начиная с версии 1.6.0, в CMP как раз появилась система работы с ресурсами для всех платформ. Предлагаю посмотреть, как это выглядит по сравнению с Moko:
Moko |
CMP |
|
|
По сути меняются только импорты и некоторые названия типов, например в Moko для картинок используется ImageResource, а в CMP ресурсах DrawableResource.
Context
Как правило в Jetpack Compose часто можно встретить такую строчку:
val Context = LocalContext.current
Контекст, это платформенная штука, в CMP вы его использовать не можете и вам нужно от него избавиться при миграции. Например, в Android-версии нашего приложения с помощью контекста мы показывали всякие тосты. Есть несколько способов решить этот вопрос, но в нашем случае мы просто заменили тосты на снекбары, которые есть в CMP.
Однако такой подход подходит не всегда. Рассмотрим пример с состоянием видимости системной клавиатуры. Работа с ней в Android и iOS, естественно, выглядит по-разному, и здесь на помощь приходит механизм expect/actual, который позволяет реализовать нативное поведение этой функции для разных платформ.
В commonMain я создал функцию keyboardVisibleState(), которая возвращает состояние видимости клавиатуры:
@Composable
expect fun keyboardVisibleState(): State<Boolean>
И для каждой платформы она использовала нативные инструменты для прослушивания клавиатуры:
iosMain:
@Composable
actual fun keyboardVisibleState(): State<Boolean> {
val keyboardState = remember { mutableStateOf(false) }
DisposableEffect(Unit) {
val ref = StableRef.create(keyboardState)
val showObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = UIKeyboardWillShowNotification,
object = null,
queue = null
) { -> ref.get().value = true }
val hideObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = UIKeyboardWillHideNotification,
object = null,
queue = null
) { -> ref.get().value = false }
onDispose {
NSNotificationCenter.defaultCenter.removeObserver(showObserver)
NSNotificationCenter.defaultCenter.removeObserver(hideObserver)
ref.dispose()
}
}
return keyboardState
}
androidMain:
@Composable
actual fun keyboardVisibleState(): State<Boolean> {
val keyboardState = remember { mutableStateOf(false) }
val view = LocalView.current
val viewTreeObserver = view.viewTreeObserver
DisposableEffect(viewTreeObserver) {
val listener = ViewTreeObserver.OnGlobalLayoutListener {
keyboardState.value = ViewCompat.getRootWindowInsets(view)
?.isVisible(WindowInsetsCompat.Type.ime())
.orTrue()
}
viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose {
viewTreeObserver.removeOnGlobalLayoutListener(listener)
}
}
return keyboardState
}
Этот пример с состоянием клавиатуры приведён лишь для демонстрации — с помощью механизма expect/actual вы можете реализовать практически что угодно. Пример отлично показывает, насколько просто работать с нативным кодом в KMP/CMP.
Интероп
CMP → SwiftUI
А раз мы заговорили о совмещении нативного и кроссплатформенного кода, давайте чуть подробнее остановимся и на нем. Как я уже сказал, интероп довольно гибкий: чтобы использовать Compose-элементы в Swift, можно просто обернуть Compose-код в ComposeUIViewController:
fun MainViewController(): UIViewController =
ComposeUIViewController {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "This is Compose code",
fontSize = 20.sp,
)
}
}
Такая функция спокойно вызывается из Swift:
struct ComposeViewController: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
return Main_iosKt.MainViewController()
}
}
CMP → UIKit
Аналогичным способом можно встроить Compose-код в UIKit. В примере ниже ComposeUIViewController встраивается в один Tab, а нативный UIKitViewController — в другой:
let composeViewController = Main_iosKt.ComposeOnly()
composeViewController.title = "Compose Multiplatform inside UIKit"
let anotherViewController = UIKitViewController()
anotherViewController.title = "UIKit"
let tabBarController = UITabBarController()
tabBarController.viewControllers = [
UINavigationController(rootViewController: composeViewController),
UINavigationController(rootViewController: anotherViewController)
]
tabBarController.tabBar.items?[0].title = "Compose"
tabBarController.tabBar.items?[1].title = "UIKit"
SwiftUI → CMP
Элементы SwiftUI тоже легко интегрируются в CMP. Для этого нужно сделать следующее:
1. В CMP — реализовать функцию верхнего порядка, которая отдает UIViewController, и встроить ее в нативный UIKitViewController:
@Composable
fun SwifthUiInCompose(createUIViewController: () -> UIViewController) {
UIKitViewController(
factory = createUIViewController,
modifier = Modifier.size(300.dp),
)
}
2. В нативе — реализовать в лямбде необходимый SwiftUI-элемент, обернуть его в UIHostingController и вернуть результат в CMP:
Main_iosKt.SwifthUiInCompose(createUIViewController: { () -> UIViewController in
let swiftUIView = VStack {
Text("SwiftUI in Compose Multiplatform")
}
return UIHostingController(rootView: swiftUIView)
})
Возможно, такая реализация окажется не самой удобной, если все приложение будет написано на CMP. В этом случае получившуюся функцию придется тащить до корневой. Однако если на CMP у вас реализован какой-то отдельный экран, в который нужно встроить небольшой элемент на SwiftUI, то такой способ вполне подойдет.
UIKit → CMP
Если вы знакомы с интеропом AndroidView → Jetpack Compose, то логика интеропа UIKit → CMP для вас будет как родная, меняется только название. Для встраивания UIKit-элементов используется обертка UIKitView. Например, следующим образом мы встроили нативную вьюшку карты в CMP:
UIKitView(
factory = { MKMapView() },
modifier = Modifier.size(300.dp),
)
Jetpack Compose ⇄ CMP
Всё, что написано на уровне @Composable функций без Android-специфики, можно переиспользовать 1:1.

Нативная функциональность
Permissions
Следующий нюанс, на котором хочется остановиться — настройка доступов для приложения. В Android-версии мы использовали библиотеку Accompanist, которая предоставляет очень удобный API для работы с Jetpack Compose. К сожалению, iOS эта либа не поддерживает, однако для CMP существует хороший аналог moko-permissions. Она позволила добиться следующего результата:

Надо сказать, что настройка прав для iOS оказалась даже проще, чем для Android. Система iOS дает намного больше информации о состоянии конкретного доступа, и это позволяет гибко контролировать диалоги. В нативный iOS-диалог можно даже добавить кастомное пояснение для пользователя, поэтому вы даже можете отказаться от кастомного окна с пояснениями, который показывается в Android.
Если вам нужно реализовать разный флоу работы с доступами под разные платформы, это также легко делается через expect/actual. Они же, кстати, используются и в самой библиотеке moko-permissions.
WebView
Другая сложность может возникнуть с настройкой WebView-экранов. В общем случае их можно реализовать двумя способами:
открыть нативный экран с WebView;
встроить нативные WebView в CMP.
Мы выбрали первый вариант, так как в нашем шаблоне проекта уже был настроен WebView в нативе, где мы предусмотрительно пофиксили многие баги. Однако можно пойти и вторым путем. Для этого нужно настроить уже родные для нас expect/actual и механизмы UI-интеропа:
commonMain:
@Composable
expect fun ActualWebView(
state: WebViewState,
// ...
)
iosMain:
@Composable
actual fun ActualWebView(
state: WebViewState,
// ...
) {
UIKitView(
factory = {
WKWebView()
}
)
}
andriodMain:
@Composable
actual fun ActualWebView(
state: WebViewState,
// ...
) {
AndroidView(
factory = { context ->
WebView(context)
}
)
}
Пример реализации можно глянуть в репозитории.
Навигация
Поскольку для навигации в Android-версии была использована библиотека Compose Navigation, для переезда на кроссплатформу ее нужно было адаптировать. Мы попробовали адаптацию от JetBrains, и практически весь код удалось перенести без правок.
Нужно оговориться, что на тот момент версия от JetBrains была в альфе, поэтому пользоваться ей нужно было аккуратно. К примеру, не поддерживалась обработка DeepLink-ов и переход по ним (благо, наш проект этого и не требовал). В новых версиях ее уже добавили, работает она также как и в Jetpack Compose, единственное что нужно сделать, это добавить интеграцию с платформами, пример можно посмотреть тут. Ниже я расскажу о нюансах навигации, которые нам пришлось подправить под CMP:
Во-первых, нам не хватило поддержки bottomSheet-навигации, реализованной в Android. Пришлось вынести руками bottom sheet destination в shared на KMP:
val sheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
val bottomSheetNavigator = remember { BottomSheetNavigator(sheetState) }
val navController = rememberNavController(bottomSheetNavigator)
ModalBottomSheetLayout(
modifier = modifier,
// ...
) {
NavHost(
navController = navController,
startDestination = startDestination,
builder = {
composable<Screen.Splash> {
// full screen destination
}
bottomSheet<Screen.BottomSheet> {
// bottom sheet screen destination
}
}
)
}
Для этого нужно было заменить expect/actual для BottomSheetNavigator, чтобы повесить аннотацию в androidMain — без этого Android-версия просто будет постоянно крашиться:
@Navigator.Name("BottomSheetNavigator")
actual class BottomSheetNavigator actual constructor(
actual val sheetState: ModalBottomSheetState
) : Navigator<BottomSheetNavigator.Destination>() {
// ...
}
// commonMain
expect class BottomSheetNavigator constructor(
sheetState: ModalBottomSheetState
) : Navigator<BottomSheetNavigator.Destination> {
// ...
}
// iosMain
actual class BottomSheetNavigator
@OptIn(ExperimentalMaterialApi::class) actual constructor(
actual val sheetState: ModalBottomSheetState
) : Navigator<BottomSheetNavigator.Destination>() {
// ...
}
Также для адаптации bottomSheet потребовалось заменить AtomicReference из Java на Kotlin Atomic. Я для этого использовал atomicfu, как на примере.
Было:
import java.util.concurrent.atomic.AtomicReference
internal typealias InternalAtomicReference<V> = AtomicReference<V>
@Stable
internal class InternalMutatorMutex {
private val currentMutator = InternalAtomicReference<Mutator?>(null)
// ...
}
Стало:
import kotlinx.atomicfu.atomic
@Stable
internal class InternalMutatorMutex {
private val currentMutator = atomic<Mutator?>(null)
// ...
}
Во-вторых, при переносе навигации не обошлось без различий в API. В нашем случае эта проблема возникла только в одном месте: в Jetpack Compose функция popBackStack с destinationID была публичной, а в CMP — приватной.
// jetpack compose navigation api
@MainThread
public open fun popBackStack(@IdRes destinationId: Int, inclusive: Boolean): Boolean {
return popBackStack(destinationId, inclusive, false)
}
// compose multiplatform navigation api
@MainThread
private fun popBackStack(destinationId: Int, inclusive: Boolean): Boolean {
return popBackStack(destinationId, inclusive, false)
}
Обойти это ограничение оказалось нетрудно: я заменил destinationID на route, и все заработало.
// jetpack compose
navController.popBackStack(
destinationId = navController.graph.findStartDestination().id,
inclusive = false,
)
// compose multiplatform
navController.graph.findStartDestination().route?.let { route ->
navController.popBackStack(
route = route,
inclusive = false,
saveState = false,
)
}
В-третьих, в предыдущих версиях CMP не было поддержки BackHandler и PredictiveBackHandler — она появилась только в CMP 1.8.0. К счастью, альфа-версия 1.8.0 на тот момент уже была доступна, и я затащил все это в проект. Получился следующий результат:

К слову, для PredictiveBackHandler должна быть настроена анимация. Она влияет на то, как будет выглядеть его работа. Если вы настроите вертикальную анимацию, то и PredictiveBackHandler будет свайпаться вниз (а не вбок, например, как показано выше). Вот как она реализована на нашем проекте:
NavHost(
navController = navController,
startDestination = startDestination,
enterTransition = { fadeIn(animationSpec = tween(DURATION_400)) },
exitTransition = { ExitTransition.None },
popExitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(
durationMillis = DURATION_300,
easing = LinearEasing
)
)
},
popEnterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(
durationMillis = DURATION_300,
easing = LinearEasing
),
initialOffset = { fullOffset -> (fullOffset * 0.3f).toInt() }
) + fadeIn()
},
)
А еще в commonMain оказались недоступны следующие пакеты:
import androidx.compose.ui.backhandler.BackHandlerimport androidx.compose.ui.backhandler.PredictiveBackHandler
Однако в iosMain и androidMain они были видны, так что я просто реализовал поверх них свой expect/actual и спокойно использовал их из commonMain. Выяснилось, что это была недоработка альфы CMP 1.8.0, в stable-версии это пофиксили.
Анимации
На этом проекте проблемы с анимациями не возникло, но для полноты картины я включу в эту статью опыт их адаптации из другого проекта. Там мы столкнулись с дергаными анимациями горизонтального CMP-pager’а в SwiftUI, при этом на Android она была плавной:

Чтобы сгладить ее, я попробовал несколько вариантов и остановился на замене дефолтной анимации flingBehavior:
Дефолтная анимация:
HorizontalPager(
// ...
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Int.VisibilityThreshold.toFloat(),
),
),
)
Измененная анимация:
HorizontalPager(
// ...
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = tween(
durationMillis = DURATION_250,
easing = LinearOutSlowInEasing,
),
),
)
После правки перелистывание на iOS стало плавным:

Такие моменты иногда встречаются в CMP, но они оперативно исправляются с каждой новой версией.
ViewModel
На момент разработки проекта адаптация ViewModel под KMP от Google была еще совсем свежей, и переезжать на нее со старой доброй moko-mvvm мы не спешили. Как говорится: работает — не трогай. Однако при поднятии версии moko-mvvm я уперся в конфликт библиотеки с Koin 4.0.0, который оказался завязан на ViewModel из androidx.lifecycle:lifecycle-*. Так что мигрировать на гугловскую адаптацию мне все же пришлось.
Для этого я просто заменил импорты. Раньше импортировалась mvvm:
import dev.icerock.moko.mvvm.viewmodel.ViewModel
abstract class BaseViewModel<State, Label>(
initialState: State,
) : ViewModel() {
// ...
}
Я заменил ее на androidx.lifecycle.ViewModel, и заодно добавил androidx.lifecycle.viewModelScope:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope as scope
abstract class BaseViewModel<State, Label>(
initialState: State,
) : ViewModel() {
protected val viewModelScope = this.scope
}
Итоги миграции
Перенести все приложение с Android на CMP и KMP с учетом всех вышеописанных правок удалось за 3 рабочих дня. На наш взгляд, результат довольно неплохой (с учетом того, что это был наш первый опыт). В частности:
настроить CMP мне удалось за 4 часа, из которых большая часть ушла на настройку IOS, так как я изначально Андройд разработчик мне было не очень просто;
все компоненты на Jetpack Compose я перенес за 3 часа (модуль
commonUIс небольшими правками ресурсов);платформозависимые элементы (вроде настройки доступов) мы перенесли за 16 часов;
на перенос одного экрана у меня уходило меньше часа (с небольшой оговоркой — общие виджеты уже были переписаны на CMP).
Заключение
Напоследок хочу дать несколько рекомендаций по разработке кроссплатформенных приложений.
Если вы работаете не с Flutter, то пишите бизнес-логику мобильных приложений сразу на KMP. Мы делаем так больше четырех лет, и до сих пор ни разу не пожалели об этом решении. Для Android-разработчика, который пишет на Kotlin, с KMP вообще не возникнет никаких трудностей — просто всегда держите в голове, что на вашем коде будет работать еще и UI iOS-версии.
Выносите в общую часть все, что можно туда вынести. Чем меньше двойной работы, тем лучше.
Используйте мультиплатформенную навигацию. Этот совет — логическое продолжение предыдущего, просто на нем хочется сделать особый акцент, так как на навигации мы сами набили шишек. Если бы мы сразу использовали условный Decompose, нам было бы еще проще перетащить весь UI на CMP.
Если вы создаете приложение эксклюзивно под Android, пишите UI сразу на CMP. Приложение не потеряет в перформансе и вы заранее заложите фундамент для возможного масштабирования в будущем (причем как на версию под iOS, так и под десктоп и веб).
Помните, что KMP и CMP позволяют переезжать на новые платформы постепенно. Необязательно сносить весь нативный код и писать кроссплатформенный с нуля — можно просто обновлять модули поочередно, по мере возникновения необходимости.
Спасибо, что прочитали статью до конца. Если у вас есть свой опыт адаптации нативных приложений под CMP — поделитесь в комментариях, буду рад обсудить.
Читайте также:
Compose Multiplatform 1.8.0: поддержка iOS переходит в stable
40 ударов палкой и Kotlin Multiplatform: как устроена мобильная разработка в Катаре (интервью с Сергеем Раковым, разработчиком в Snoonu — катарском IT-гиганте)
KMP, догфудинг и велосипеды в стартапе американской версии «Кухни на районе» (интервью с Сеней Суздальницким, CTO Sizl — стартапа доставки еды в Чикаго)
house2008
Спасибо, было интересно!)
Мне как ios-нику больно было читать. Я не пишу на КМП, но интересуюсь ей. Я до сих пор не понимаю зачем отбирать работу iOS и писать UI на android стороне с очевидными минусами. Почему просто не писать бизнес логику на KMP и потом это собирать в библиотеки (xcframework), а их уже iOS сторона будет использовать в pure iOS Xcode проекте. На SwiftUI писать одно удовольствие качественный высокопроизводительный UI. Если использовать бизнес логику из общих с Android библиотек то тогда iOS проект вообще не будет знать, что он KMP проект так как он просто подключает их и использует, хоть эти библиотеки и были написаны на c/c++/objc/kotlin, при этом сохранится возможности писать нативные UI и юнит тесты и всё остальное нативное, безболезненная миграция на новые системные технологии и библиотеки, обновления Xcode, публикация апп через fastlane и прочее и прочее)
В вашем случае согласен когда уже все написано на андроиде, конечно лучше все и переиспользовать, я говорю про общую тенденцию писать UI под iOS на андроид стороне, никогда этого не понимал учитывая что на iOS самая платежеспособная аудитория (во всяком случае раньше была, сейчас не знаю)) )
PlatinumKiller
Все просто, экономия ради экономии, а потом на iOS приложения неоптимизированные и вечно летящие по памяти.
И совет разрабов таких: «купите топовое устройство, у нас работает.»