Нативная Android-разработка: Kotlin, Compose, жизненный цикл, экосистема
В предыдущей статье мы разобрали iOS: один вендор владеет всем стеком, матрица устройств узкая, парк обновляется быстро. Android устроен наоборот — это открытая база (AOSP), поверх которой десятки производителей кладут свои ядра и оболочки. Ваш код исполняется и на флагмане с 16 ГБ памяти, и на бюджетнике за 100 USD с прошивкой, убивающей фон через десять минут «ради экономии батареи»; версия ОС у пользователя может отставать на пять лет.
Отсюда главный сдвиг мышления. На iOS инженер спрашивает «как правильно по гайдлайнам Apple». На Android — «что произойдёт, если этого не случится»: если сервис убьют, разрешение не дадут, процесс умрёт в фоне, оболочка проигнорирует контракт. Статья — про сегодняшний вид нативной разработки: Kotlin, корутины и Flow, Compose, Jetpack, Gradle; платформенные основы (Binder, zygote, ART, компоненты, песочница) разобраны в статье о платформах.
Что именно вы выбираете, выбирая нативный Android
Вы получаете: нулевой лаг относительно платформы (новый API доступен в день выхода превью, а не через полгода, когда его завернут в плагин); один язык на весь стек — UI, домен, сборка, тесты, при желании сервер (Ktor) и общий код с iOS (KMP); лучшие в классе инструменты диагностики (Perfetto, Macrobenchmark, Layout Inspector); отсутствие моста, который однажды окажется узким местом на списке из тысячи элементов.
Вы платите: второй командой — каждая фича делается дважды (не «вдвое дороже»: продукт, дизайн, бэкенд и аналитика общие, но клиентская часть удваивается честно); расхождением поведения двух реализаций, которое обнаруживается в проде; фрагментацией — матрица из версий ОС, размеров экрана, оболочек OEM и классов производительности ваша. Честное сравнение с кроссплатформой — в конце статьи, когда станет ясно, от чего именно вы отказываетесь.
Kotlin: язык, а не «Java без точек с запятой»
Kotlin — основной язык Android с 2019 года, и это не косметика над Java: он меняет то, какие ошибки
возможны в принципе. Главный класс runtime-ошибок Java — NullPointerException — переезжает
в компиляцию: String и String? суть разные типы.
data class Order(val id: String, val totalKopecks: Long, val comment: String? = null)
fun commentLine(order: Order): String =
order.comment?.takeIf { it.isNotBlank() } // безопасный вызов: null летит дальше
?: "без комментария" // элвис: значение по умолчанию
// !! — осознанный запрос «уронить приложение, если null», а не способ убрать
// красное подчёркивание в IDE; в ревью каждый !! требует объяснения
Граница с JSON остаётся дырявой: DTO с полем val name: String спокойно примет null из сети
и упадёт при доступе, поэтому сетевой слой описывают отдельными DTO с nullable-полями и
отображают их в доменные модели, где null уже невозможен. Состояние экрана при этом — не
«булев флаг loading плюс nullable-данные плюс nullable-ошибка» (восемь комбинаций, из которых
осмысленны три), а закрытая сумма типов.
sealed interface OrdersState {
data object Loading : OrdersState
data class Ready(val orders: List<Order>, val fromCache: Boolean) : OrdersState
data class Failed(val cause: Throwable, val cachedAt: Long?) : OrdersState
}
fun title(state: OrdersState): String = when (state) { // без else — намеренно
OrdersState.Loading -> "Загружаем…"
is OrdersState.Ready -> if (state.fromCache) "Сохранённые заказы" else "Заказы"
is OrdersState.Failed -> "Нет сети"
}
Ветки else нет специально: когда завтра появится data object Empty, компилятор укажет на все
when, которые надо дополнить, — дешёвая рефакторинг-безопасность (идея разобрана в треке
Функциональное программирование).
Из ежедневного: @JvmInline value class Kopecks(val raw: Long) даёт типобезопасные деньги без
накладных расходов в рантайме, функции-расширения дописывают поведение к чужим типам, делегаты
убирают шаблон. Kotlin умеет и мультиплатформу (KMP), где доменный код компилируется
в нативный бинарь для iOS: граница «натив/кроссплатформа» не бинарная, а плавная.
Корутины и Flow: конкурентность, привязанная к жизненному циклу
Мобильный код по природе асинхронный: сеть, диск, база, датчики. Ключевая идея корутин — структурная конкурентность: у задачи есть родитель, отмена родителя отменяет детей, ошибка ребёнка не теряется молча.
// coroutineScope: обе задачи — дети; ошибка одной отменяет вторую и летит наружу
suspend fun loadScreen(): ScreenData = coroutineScope {
val profile = async { api.profile() }
val orders = async { api.orders() }
ScreenData(profile.await(), orders.await())
}
// withContext переключает диспетчер на время блока, а не «уводит работу в фон навсегда»
suspend fun readCache(p: String): ByteArray = withContext(Dispatchers.IO) { File(p).readBytes() }
Пользователь ушёл с экрана — его запросы обязаны прекратиться, иначе вы платите батареей,
трафиком и крэшем при обновлении уничтоженного UI; отмена приходит бесплатно, если запускать
корутины в правильном скоупе, а не в GlobalScope. Flow — холодный поток (подписались — пошла
работа, отписались — остановилась), StateFlow — горячий держатель значения под «состояние экрана».
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class OrdersViewModel(private val repo: OrdersRepository, private val handle: SavedStateHandle) :
ViewModel() {
private val query = handle.getStateFlow("query", "") // переживает смерть процесса
val state: StateFlow<OrdersState> = query
.debounce(300) // не дёргаем поиск на каждую букву
.flatMapLatest { q -> repo.observeOrders(q) } // прошлая подписка отменяется
.map<List<Order>, OrdersState> { OrdersState.Ready(it, fromCache = false) }
.catch { emit(OrdersState.Failed(it, repo.lastSyncAt())) }
.stateIn(
scope = viewModelScope, // отменится вместе с ViewModel
// 5 с после ухода последнего подписчика: поворот экрана не перезапускает загрузку
started = SharingStarted.WhileSubscribed(5_000),
initialValue = OrdersState.Loading,
)
fun onQueryChange(value: String) { handle["query"] = value }
}
Рабочий код отличают три детали: WhileSubscribed(5_000); catch внутри потока, а не
try/catch снаружи — ошибка это значение, которое надо превратить в состояние UI; ключ поиска живёт
в SavedStateHandle, поэтому переживает убийство процесса.
Jetpack Compose: декларативный UI и три фазы кадра
Compose заменил императивную систему View/XML: вы не мутируете дерево виджетов
(textView.text = "..."), а описываете, как выглядит UI при данном состоянии. Модель та же,
что в React и SwiftUI, — интуиция из фронтенда
переносится почти целиком.
@Composable
fun OrdersScreen(vm: OrdersViewModel = hiltViewModel()) {
// collectAsStateWithLifecycle прекращает сбор в фоне; collectAsState() жжёт батарею за кадром
val state by vm.state.collectAsStateWithLifecycle()
OrdersContent(state, onRetry = vm::retry) // контент не знает про ViewModel
}
@Composable
private fun OrdersContent(state: OrdersState, onRetry: () -> Unit) = when (state) {
OrdersState.Loading -> LoadingSkeleton()
is OrdersState.Failed -> ErrorBlock(state, onRetry)
// без key список теряет позицию и состояние строк при обновлении данных
is OrdersState.Ready -> LazyColumn {
items(state.orders, key = { it.id }) { order -> OrderRow(order) }
}
}
Подъём состояния: что и где обязано выжить
Правило Compose: состояние поднимают вверх, события опускают вниз — композабл, хранящий своё
состояние внутри, невозможно ни протестировать, ни переиспользовать. А выбор между remember,
rememberSaveable, ViewModel и диском не стилистический: это ответ на «что должно выжить».
Почему UI тормозит: три фазы кадра
Compose обрабатывает кадр в три фазы, и фаза, на которой читается изменяющееся состояние, определяет объём работы. Это самая практичная модель производительности во фреймворке.
Box(Modifier.offset(x = offsetPx.dp)) // дорого: рекомпозиция на кадр
Box(Modifier.offset { IntOffset(offsetPx.roundToInt(), 0) }) // дешевле: только Layout + Draw
Box(Modifier.graphicsLayer { translationX = offsetPx }) // дёшево: только Draw
val listState = rememberLazyListState()
// без derivedStateOf кнопка рекомпозируется на каждый пиксель прокрутки
val showScrollTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 5 } }
// Пропуск рекомпозиции ломает объект, создаваемый заново на каждый кадр:
val sorted = remember(orders) { orders.sortedBy { it.date } } // а не sortedBy прямо в аргументе
С Kotlin 2.0.20 включён strong skipping: нестабильные параметры сравниваются по ссылке, лямбды
запоминаются автоматически — но проверять результат надо инструментами (Recomposition counts
в Layout Inspector, отчёты компилятора о skippable), а не глазами. И поскольку композабл может
быть вызван сколько угодно раз в любом порядке, сетевых вызовов и аналитики в теле функции быть
не должно — для этого есть отдельные API эффектов.
LaunchedEffect(orderId) { viewModel.track(orderId) } // корутина на время жизни композабла
DisposableEffect(lifecycleOwner) { // подписка с гарантированной отпиской
val observer = LifecycleEventObserver { _, event -> /* ... */ }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Система View никуда не делась — она под капотом у половины сторонних SDK, и Compose с ней
двусторонне совместим (AndroidView, ComposeView): смешанный режим — нормальная стратегия
миграции, а не технический долг сам по себе.
Жизненный цикл: главное отличие от веба
В вебе вкладка живёт, пока её не закрыли. На Android система вправе уничтожить ваш процесс
в любой момент, когда приложение не на переднем плане, ради того, что пользователь видит сейчас.
onDestroy при этом не вызывается: процесс просто исчезает.
- Смена конфигурации. Поворот, тема, язык, размер шрифта, складывание устройства — Activity
пересоздаётся;
ViewModelэто переживает, поля Activity — нет. Соблазн написатьandroid:configChanges="orientation|screenSize"— ловушка: смерть процесса этим не отменить. - Смерть процесса — штатный сценарий. Свернул, двадцать минут смотрел видео, вернулся: всё,
что жило только в памяти, потеряно. Проверка обязательна —
adb shell am kill <package>при свёрнутом приложении, плюс режим «Don’t keep activities», включённый у кого-то в команде всегда. - Собирать данные надо в правильном состоянии: подписка, живущая, пока экран в фоне, — прямая
утечка батареи и трафика. И не только у Activity: у Fragment есть свой жизненный цикл плюс
более короткий цикл его View (
viewLifecycleOwner), а подписка наthis— классическая утечка с дублирующимися коллбэками.
// Вне Compose: сбор только между onStart и onStop
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state.collect { render(it) } }
}
// Состояние, переживающее убийство процесса, живёт в SavedStateHandle
class SearchViewModel(handle: SavedStateHandle) : ViewModel() {
var query: String by handle.saveable { mutableStateOf("") }
}
Убийство процесса с восстановлением из Bundle — android-специфика: iOS решает близкую задачу
через @SceneStorage, но срабатывает это заметно реже и по другим правилам, а кроссплатформенные
фреймворки обязаны эмулировать android-модель с разной степенью честности.
struct SearchView: View {
@SceneStorage("query") private var query: String = "" // iOS: восстановление сцены
var body: some View { TextField("Поиск", text: $query) }
}
Экосистема Jetpack: из чего собирают приложение
Jetpack — библиотеки AndroidX, закрывающие задачи, где голая платформа неудобна или несовместима между версиями. Практически любое современное приложение собрано из этого набора.
Material 3, Navigation"] --> VM["ViewModel
+ SavedStateHandle"] VM --> UC["Use cases
чистый Kotlin, без Android SDK"] UC --> R["Repository
единственный источник правды"] subgraph DATA["Слой данных"] R --> DB[("Room")] R --> DS[("DataStore")] R --> NET["Retrofit / Ktor + OkHttp"] end WM["WorkManager
отложенная работа"] --> R FCM["FCM: пуш будит приложение"] --> WM DB -. "Flow: изменения текут вверх" .-> VM style DATA fill:#8a939e,fill-opacity:0.12,stroke:#8a939e
- ViewModel — держатель состояния, переживающий смену конфигурации; не должен знать про
Contextактивности (утечка) и про View вообще. Navigation Compose даёт типобезопасные маршруты на@Serializable-классах, и каждая точка назначения получает свойViewModel. - Room — надстройка над SQLite с проверкой SQL на компиляции и миграциями; возвращает
Flow, поэтому запись в базу сама перерисовывает экран. DataStore — заменаSharedPreferences: асинхронный и транзакционный, но не для секретов (см. безопасность). - WorkManager — единственный способ гарантированно выполнить работу позже, переживая перезагрузку. Hilt (Dagger) — DI с проверкой графа на компиляции; Koin дешевле в освоении, но уводит ошибки в рантайм (принципы разработки).
Слои, MVVM/MVI и границы — статья «Архитектура мобильного приложения».
Оффлайн как режим по умолчанию
Приложение обязано работать в метро. Это не фича, а исходное условие, и оно диктует архитектуру: UI читает только из локальной базы, сеть лишь наполняет эту базу.
@Dao interface OrderDao {
// Flow переизлучает результат сам, как только в таблицу что-то записали
@Query("SELECT * FROM orders ORDER BY updated_at DESC") fun observeAll(): Flow<List<OrderEntity>>
@Upsert suspend fun upsertAll(orders: List<OrderEntity>)
}
class OrdersRepository(private val dao: OrderDao, private val api: OrdersApi) {
fun observeOrders(q: String): Flow<List<Order>> =
dao.observeAll().map { rows -> rows.filter { it.matches(q) }.map(::toDomain) }
// Обновление не блокирует чтение и тянет дельту, а не всё целиком
suspend fun refresh(): Result<Unit> =
runCatching { dao.upsertAll(api.orders(after = dao.maxUpdatedAt()).map(::toEntity)) }
}
Ключевое — первый кадр не ждёт сеть, а ошибка сети становится баннером, а не пустым экраном с бесконечным спиннером. Конфликты и стратегии синхронизации — «Данные и оффлайн».
Фон, батарея, разрешения: где ОС говорит «нет»
Здесь Android наказывает наивность жёстче всего. С Android 6 работает Doze, с Android 9 —
App Standby Buckets: чем реже пользователь открывает приложение, тем реже система даёт ему сеть
и джобы. С Android 12 запрещён старт foreground-сервиса из фона, с Android 14 обязателен
foregroundServiceType с обоснованием при публикации, с Android 15 работа типа dataSync
ограничена шестью часами в сутки. Модель мышления: вы не запускаете работу, а описываете условия,
при которых система согласится её выполнить.
val sync = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // только Wi-Fi: трафик платный
.setRequiresBatteryNotLow(true).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"orders-sync", ExistingPeriodicWorkPolicy.KEEP, sync, // без дубликатов при каждом запуске
)
О чём узнают поздно: точные будильники под запретом — SCHEDULE_EXACT_ALARM с Android 12
выдаётся не всем, USE_EXACT_ALARM разрешён только будильникам и календарям, так что «напоминание
ровно в 14:00» — продуктовое требование, а не техническая задача; пуш это сигнал, а не доставка
данных — FCM не гарантирует ни факт, ни время доставки, поэтому паттерн «пуш будит, приложение
идёт за данными само»; оболочки OEM нарушают контракты — Xiaomi, Huawei, Oppo и Samsung убивают
фон агрессивнее AOSP (dontkillmyapp.com), и лечится это не кодом,
а решением не полагаться на фон (фоновая работа и пуши).
Разрешения тратятся один раз. С Android 6 они запрашиваются в рантайме, с Android 11 действует
автоотзыв при неиспользовании, с Android 13 уведомления требуют POST_NOTIFICATIONS, с Android 14
доступ к медиа может быть частичным.
// Системный диалог показывается один раз: до него нужен экран-праймер с объяснением пользы,
// после отказа — путь в Настройки и рабочий сценарий без разрешения
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
granted -> if (granted) onGranted() else showDegradedMode()
}
Button(onClick = { launcher.launch(Manifest.permission.POST_NOTIFICATIONS) }) { Text("Включить") }
Правило: каждая функция, требующая разрешения, обязана иметь работающий сценарий без него — доставка без геолокации должна давать ввести адрес руками. Плюс политика Play проверяет обоснованность чувствительных разрешений: лишняя строка в манифесте стоит недели ревью.
Сборка: Gradle, R8, версии SDK
compileSdk,targetSdk,minSdk— три разные вещи: против чего компилируем, под какие правила подписываемся и где приложение вообще запустится. Play ежегодно требует подниматьtargetSdk, аminSdk— экономическое решение: каждая версия вниз добавляет ветки совместимости и устройства в тестовую матрицу.- R8 (
isMinifyEnabledплюсisShrinkResourcesв релизном билд-типе) экономит десятки процентов размера, но ломает всё, что ходит через рефлексию: собирать релиз в CI и прогонять тесты по нему обязательно, иначе крэши найдут пользователи. - Version catalogs (
libs.versions.toml) держат версии едиными; компилятор Compose с Kotlin 2.0 подключается плагиномcompose.compiler, а кодогенерация переехала с kapt на быстрый KSP. Многомодульность ускоряет сборку и фиксирует границы архитектуры компилятором, но каждый модуль — накладные расходы.
Качество: производительность, тесты, релиз
Мобильная производительность — четыре разных бюджета: старт, плавность, память, батарея.
- Холодный старт мерят
Macrobenchmark, а не секундомером; всё, что делается вApplication.onCreate(), задерживает каждый запуск. Почти бесплатные 20–40% ускорения даёт Baseline Profile — список горячих методов, AOT-компилируемый при установке. - Джанк — пропущенные кадры; при 120 Гц бюджет кадра около 8 мс.
JankStatsв проде, Perfetto для разбора залипания, Layout Inspector для подсчёта рекомпозиций. - Память и батарея. ART собирает мусор, и аллокационное давление в скролле — прямая причина джанка (фото 12 Мп в ARGB занимает около 48 МБ, поэтому битмапы декодируют в целевой размер). Энергию жгут экран, радио и wake locks: у радио есть «хвост» в несколько секунд после передачи, поэтому десять мелких запросов дороже одного батча.
- ANR. Главный поток, не ответивший ~5 секунд на ввод, попадает в Android Vitals и влияет на
видимость в магазине;
StrictModeв debug ловит диск и сеть на главном потоке раньше юзера.
Инструментальные тесты медленные, поэтому чем больше логики вынесено в чистый Kotlin без Android SDK, тем дешевле её проверять.
@Test fun `при ошибке сети показываем кэш`() = runTest {
val vm = OrdersViewModel(FakeRepo(failing = true), SavedStateHandle())
vm.state.test { // Turbine
assertEquals(OrdersState.Loading, awaitItem())
assertIs<OrdersState.Failed>(awaitItem())
}
}
UI проверяют через createComposeRule() — тот же тест идёт и на JVM через Robolectric,
и на устройстве, причём OrdersContent тестируется без ViewModel, потому что не знает о ней.
Отдельно проверяйте смерть процесса (am kill), отсутствие сети
(adb shell svc data disable) и отказ в разрешении: три сценария, чаще всего доезжающие до
прода непроверенными (тестирование мобильного).
Релиз тоже часть инженерии: формат AAB вместо APK (Play собирает под конкретное устройство,
отдавая нужные плотности, ABI и языки), Play App Signing с ключом на стороне Google, треки
internal → closed → open → production с поэтапной раскаткой. Полноценного отката не существует —
только новая версия с бо́льшим versionCode, поэтому фичефлаги остаются единственным реальным
«откатом» (релиз и сторы).
Нативный Android против кроссплатформы: честный счёт
Стоимость. Расчёт «натив вдвое дороже» неверен в обе стороны: клиентская разработка удваивается, но продукт, дизайн, бэкенд, аналитика и QA-сценарии общие — экономия кроссплатформы обычно порядка 30–40% бюджета мобильного направления. Взамен появляется новая статья расходов, платформенные мосты: каждая возможность, не покрытая фреймворком, пишется дважды нативно плюс обвязка — дороже, чем в нативе. Продукт с большим объёмом платформенной специфики (камера, фон, BLE, виджеты) съедает экономию целиком.
Качество. Тезис «кроссплатформа тормозит» устарел, но механика различий осталась: Flutter рисует собственным движком — предсказуемый пиксель, но свои виджеты вместо системных и вопросы к доступности; React Native рисует нативными вью — родной вид, но граница JS↔native остаётся местом сюрпризов; KMP шарит только логику при нативном UI — качество нативное, экономия меньше. Общая слепая зона одна: обновления ОС. Когда новая версия Android меняет поведение edge-to-edge или foreground-сервисов, натив чинится в день выхода превью, а кроссплатформа — когда обновятся фреймворк и его плагины.
Найм. Рынок Android-разработчиков глубже рынка Flutter-разработчиков, но кроссплатформа позволяет одному человеку закрывать обе платформы — критично для команды из трёх инженеров и несущественно для команды из тридцати. Отдельный риск: команда без нативной экспертизы рано или поздно упирается в задачу, которую некому решить — падение на конкретной прошивке, отказ ревью, новое системное требование.
Посчитайте долю экранов и функций, которые обязаны быть платформенно-специфичными. Меньше 10% — кроссплатформа почти наверняка окупится. Больше 40% — вы платите за абстракцию больше, чем экономите. Между ними решает наличие нативной экспертизы в команде.
Типичные ошибки
- Логика в Activity/Fragment — умирает вместе с пересозданием и не тестируется без устройства.
GlobalScope.launch— корутина, которую никто не отменит, переживёт экран и пользователя.collectAsState()вместоcollectAsStateWithLifecycle()— тихая утечка батареи в фоне.- Игнорирование смерти процесса — «у меня всё работает», потому что вы не сворачивали приложение.
- Разрешение на старте — диалог без контекста получает отказ, а отказ почти навсегда.
- Вера в фон — гарантированной фоновой работы нет, есть работа, которую система разрешит.
- Отсутствие релизной сборки в CI — R8-специфичные крэши находят пользователи.
- Один тестовый девайс — флагман разработчика, тогда как аудитория живёт на бюджетниках.
- Огромный
Bundle— транзакция Binder ограничена мегабайтом на процесс,TransactionTooLargeExceptionприлетает в проде, а не в разработке.
Мини-итог
- Android — открытая фрагментированная платформа: код обязан быть защитным, а тестовая матрица реальной, а не «Pixel в эмуляторе».
- Kotlin убирает целые классы ошибок на компиляции, корутины дают отмену, привязанную к жизненному
циклу,
Flow— реактивное состояние экрана. - Compose — декларативный UI с тремя фазами кадра: производительность определяется тем, на какой фазе читается изменяющееся состояние, а не количеством кода.
- Жизненный цикл — центральное отличие от веба: смена конфигурации и смерть процесса штатны, а уровень хранения состояния выбирается по вопросу «что это должно пережить».
- Оффлайн-first — не фича, а архитектура: UI читает из локальной базы, сеть её наполняет; фон, будильники и пуши ограничены системой и оболочками OEM, и продукт не должен на них полагаться.
- Сборка и релиз — часть инженерной работы, а выбор между нативом и кроссплатформой определяется долей платформенной специфики, размером команды и наличием нативной экспертизы.
Источники
- Guide to app architecture — рекомендованные слои и потоки данных.
- Jetpack Compose documentation — фазы кадра, состояние, эффекты, производительность.
- Kotlin Coroutines guide — структурная конкурентность и Flow.
- Activity lifecycle и Processes and app lifecycle.
- Background work overview — WorkManager, ограничения фона, foreground-сервисы.
- App performance guide — Macrobenchmark, Baseline Profiles, JankStats, Perfetto.
- Now in Android — эталонное приложение Google на Compose с многомодульной архитектурой.
- dontkillmyapp.com — поведение оболочек OEM в отношении фоновых процессов.
Что дальше
Обе нативные платформы разобраны — теперь есть база, чтобы обсуждать альтернативу честно: что именно кроссплатформенные фреймворки абстрагируют, где абстракция протекает и сколько на самом деле стоит «один код везде».
Кроссплатформа: Flutter, React Native, KMP — сравнение и цена решения