Мобильная разработка Нативная Android-разработка: Kotlin, Compose, жизненный цикл, экосистема
0%

Нативная Android-разработка: Kotlin, Compose, жизненный цикл, экосистема

Нативная 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 обрабатывает кадр в три фазы, и фаза, на которой читается изменяющееся состояние, определяет объём работы. Это самая практичная модель производительности во фреймворке.

Три фазы кадра в 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, закрывающие задачи, где голая платформа неудобна или несовместима между версиями. Практически любое современное приложение собрано из этого набора.

  • 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, и продукт не должен на них полагаться.
  • Сборка и релиз — часть инженерной работы, а выбор между нативом и кроссплатформой определяется долей платформенной специфики, размером команды и наличием нативной экспертизы.

Источники

Что дальше

Обе нативные платформы разобраны — теперь есть база, чтобы обсуждать альтернативу честно: что именно кроссплатформенные фреймворки абстрагируют, где абстракция протекает и сколько на самом деле стоит «один код везде».

Кроссплатформа: Flutter, React Native, KMP — сравнение и цена решения

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов