Введение: Compose — теперь обязательная тема

«Расскажите, как устроен Jetpack Compose» — открытый вопрос, с которого начинается проверка UI-части почти на каждом собеседовании. И он же роняет кандидатов, которые писали на Compose, но не строили ментальную модель.

Пять лет назад можно было ответить «работаю с XML, RecyclerView и адаптерами» — и этого хватало. Сегодня Compose — основной способ создания UI в Android, новые проекты на нём, и интервьюеры копают глубже, чем «какой модификатор для отступов». Проверяют декларативную модель, состояние, жизненный цикл composable-функций и сайд-эффекты — всё то, что отличает «написал экран» от «понимаю, как оно работает».

В этой статье — полный разбор темы для собеседования: от ментальной модели до типичных ошибок кандидатов. Если готовитесь — читайте, а потом попробуйте ответить на вопросы из статьи вслух.

Декларативная модель: UI как функция состояния

Главное, что нужно объяснить на собеседовании: в Compose UI — это функция состояния, а не набор инструкций. В императивном подходе (XML) ты описываешь, как построить экран: создать View, найти его по id, обновить вручную при изменении данных. В декларативном — ты описываешь, каким экран должен быть при текущем состоянии, и фреймворк сам приводит UI к этому описанию.

ProfileScreen.kt
@Composable
fun ProfileScreen(user: User) {
    // поменялся user — поменялось описание UI, Compose перестроит экран
    Column {
        Avatar(user.avatarUrl)
        Text(user.name)
        Bio(user.bio)
    }
}

Когда состояние меняется, Compose пересоздаёт затронутые функции — это рекомпозиция. Подробно про неё я писал отдельно: Recomposition в Compose: как работает. Здесь зафиксируем главное: Compose пересоздаёт не весь экран, а только функции, входные данные которых могли измениться, и делает это эффективно.

Дальше — два вопроса, которые интервьюер задаст почти наверняка:

  • Как Compose узнаёт, что состояние изменилось? Через чтение: функция, которая прочитала State, подписывается на него. Изменилось значение — функция пересоздаётся.
  • Что такое однонаправленный поток данных? Состояние течёт вниз (от родителя к детям), события — вверх (от детей к родителю через колбэки). Никакого «состояние живёт само по себе» в произвольном месте иерархии.

Короткая формула

UI = f(state). Функция описала зависимость от состояния один раз, дальше Compose сам следит за изменениями. Читаешь State в composable — подписываешься на изменения. Передаёшь события через лямбды — поток данных остаётся однонаправленным.

Состояние: mutableStateOf, remember и подъём

В Compose состояние создаётся через mutableStateOf. Само по себе оно не переживает рекомпозицию — если функцию пересоздали, обычная переменная создастся заново. Чтобы состояние пережило пересоздание, его оборачивают в remember:

CounterScreen.kt
@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text("Счёт: $count")
        Button(onClick = { count++ }) {
            Text("+1")
        }
    }
}

Ключевые понятия, которые нужно уверенно различать:

  • remember — сохраняет значение между рекомпозициями, но не переживает выход из композиции (например, удаление элемента из списка).
  • rememberSaveable — переживает не только рекомпозицию, но и воссоздание Activity (поворот экрана): значение уходит в Bundle.
  • remember(key) — сбрасывает значение, когда ключ меняется: например, при смене userId нужно пересоздать состояние экрана пользователя.
  • derivedStateOf — для состояний, которые вычисляются из других: например, «можно ли нажать кнопку» на основе списка введённых полей.

Второй блок вопросов — про подъём состояния (state hoisting). Правило простое: состояние должно жить на самом низком уровне иерархии, где оно нужно, но подниматься выше, как только нужно нескольким функциям или когда его нужно сбрасывать.

CounterDemo.kt
@Composable
fun CounterDemo() {
    var count by remember { mutableStateOf(0) }

    // состояние поднято наверх, дети — статeless-функции
    Counter(count = count, onIncrement = { count++ })
}

@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Column {
        Text("Счёт: $count")
        Button(onClick = onIncrement) { Text("+1") }
    }
}

Статeless-компоненты легче тестировать, переиспользовать и понимать. На собеседовании фраза «я выношу состояние наверх, когда оно нужно нескольким компонентам, и передаю изменения через колбэки» закрывает половину вопросов про архитектуру UI.

Жизненный цикл composable и сайд-эффекты

У composable-функции три фазы жизненного цикла: вход в композицию (composition), рекомпозиция (сколько угодно раз) и выход из композиции (disposition). На собеседовании важно показать, что ты понимаешь последствия: обычный код в теле функции выполняется при каждом пересоздании, а код с привязкой к фазе нужно выносить в сайд-эффекты.

Сайд-эффекты — это операции, которые должны происходить в связи с жизненным циклом: подписки, запуск корутин, показ диалогов. Запускать их прямо в теле функции нельзя — рекомпозиция может быть пропущена или выполнена многократно. Для этого есть специальные API из документации:

  • LaunchedEffect(key) — запускает корутину при входе в композицию, отменяет при выходе. Классика: загрузка данных по ключу.
  • DisposableEffect(key) — для работы с ресурсами: подписка на слушатель в onDispose отменяется.
  • rememberCoroutineScope — скоуп, привязанный к композиции, для запуска корутин по событиям UI (например, по клику).
  • SideEffect — выполняется после каждой успешной рекомпозиции; редкий гость на собеседованиях, но полезно упомянуть.
UserItem.kt
@Composable
fun UserItem(userId: String) {
    // корутина запускается один раз при входе в композицию
    // и отменяется при выходе — привязана к жизненному циклу
    LaunchedEffect(userId) {
        repository.markSeen(userId)
    }
    Text(userId)
}

Обратите внимание на key в LaunchedEffect(userId): поменялся userId — старая корутина отменяется, запускается новая. Это частый вопрос-уточнение: «а что будет, если элемент переиспользуется в списке?».

Ещё один блок, который любят проверять, — как связаны Compose и жизненный цикл Activity/Fragment. Короткий ответ: состояние в rememberSaveable переживает воссоздание Activity, а корутины в LaunchedEffect — нет: при выходе из композиции они отменяются.

Типичные ошибки кандидатов

Ошибки ниже я вижу на собеседованиях регулярно, даже у кандидатов с опытом 2+ года:

  • «При изменении состояния пересоздаётся весь экран». Нет — пересоздаются только функции с изменившимися входными данными, остальное пропускается.
  • Путают remember и rememberSaveable. Первое переживает рекомпозицию, второе — ещё и воссоздание Activity. «При повороте экрана состояние сбросилось» — почти всегда забытый rememberSaveable.
  • Сайд-эффекты прямо в теле функции. Аналитика, запись в репозиторий, обновление ViewModel в композиции. Работает «вроде бы», но ломается при пропуске или многократном пересоздании.
  • Состояние, которое живёт не там. Всё состояние экрана в одной гигантской composable вместо подъёма и статeless-компонентов — интервьюер спросит, как это тестировать.
  • Не знают про ключи в LazyColumn. Без key при обновлении списка Compose не может сопоставить элементы, и пересоздаёт лишнее.

Худший вариант ответа на вопрос про жизненный цикл: «composable — это как Activity, у неё есть onCreate и onDestroy». Это не так. Фазы composable — вход, рекомпозиции, выход, — и сайд-эффекты существуют именно потому, что рекомпозиций может быть сколько угодно.

На что я смотрю, когда спрашиваю про Compose

Я Рустем Бикбулатов, senior Android-разработчик, провожу технические собеседования и менторю в Яндекс Практикуме — больше сотни учеников и разборов за плечами. Когда я спрашиваю про Compose, я не жду пересказа документации. Я жду трёх вещей: умения объяснить ментальную модель своими словами, живой пример с кодом и честное «не помню деталь, но расскажу, где это искать».

С опытом 1–2 года кандидаты обычно застревают именно на связке «состояние — жизненный цикл — сайд-эффекты»: каждый кусок знают, а в один ответ собрать не могут. Это закрывается системной подготовкой: разбор тем, тренировка ответов вслух и мок-собеседования. Я помогаю именно с этим — индивидуальное менторство под переход на middle за 4–8 недель.

Нужна помощь в переходе на middle?

Бесплатная диагностика: разберу ваши пробелы в Compose, Kotlin и Coroutines и составлю план роста за 30–40 минут. Без обязательств — если менторство вам не подойдёт, честно скажу.

Итоги

Jetpack Compose — обязательная тема для собеседования, и проверяют её через понимание модели, а не через знание API.

  • Модель: UI = f(state), однонаправленный поток данных, рекомпозиция пересоздаёт минимум.
  • Состояние: remember против rememberSaveable, подъём состояния, статeless-компоненты.
  • Жизненный цикл: вход, рекомпозиции, выход; сайд-эффекты через LaunchedEffect и DisposableEffect.

Главное, что нужно запомнить

Compose-собеседование выигрывает не тот, кто знает больше модификаторов, а тот, кто может объяснить, почему UI — функция состояния и что из этого следует. Всё остальное — детали.

Если на собеседованиях вы уверенно пишете экраны, но плаваете, когда начинают спрашивать «а почему оно так работает» — не нужно зубрить дальше. Напишите в Telegram, разберём вашу ситуацию и составим план подготовки.