Введение: состояние, которое меняется слишком часто

«Пользователь скроллит список, и каждое изменение позиции вызывает рекомпозицию. А показывать нужно только одну кнопку — "наверх", когда список прокручен». Задача, на которой я ловлю большинство кандидатов: одни не знают, что делать, другие — достают derivedStateOf и snapshotFlow налево и направо, не понимая, зачем они вообще нужны.

Оба API решают по сути одну проблему: согласовать частоту изменения состояния с частотой, в которой оно действительно нужно UI. Но работают они с разными сторонами этого мира: derivedStateOf — это про состояние Compose, а snapshotFlow — мост из Compose в корутины и Flow.

Разберём оба: как работают, когда нужны, когда категорически нет — и что на эту тему спрашивают на собеседованиях.

derivedStateOf: когда входные данные меняются чаще, чем нужно

Официальная рекомендация звучит так: derivedStateOf нужен, когда входные данные композиции меняются чаще, чем требуется её перекомпоновка. Он создаёт новое наблюдаемое состояние, которое обновляется только тогда, когда изменился сам результат — по поведению это похоже на оператор distinctUntilChanged() из мира Flow.

Канонический пример из документации по побочным эффектам Compose — кнопка «наверх», которая появляется после прокрутки списка:

MessageList.kt
@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()

    val showButton by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
    }

    Box {
        LazyColumn(state = listState) {
            // список сообщений
        }
        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

Разберём, что здесь происходит. firstVisibleItemIndex при прокрутке принимает значения 0, 1, 2, 3… — каждое изменение этого значения теоретически требует рекомпозиции. Но UI важен только один факт: индекс больше нуля или нет. derivedStateOf отсекает промежуточные значения: 1, 2, 3 — это всё ещё «true», значит, перекомпоновываться не нужно.

Когда использовать

derivedStateOf — для ситуаций, где вход меняется часто, а UI реагирует редко: позиция прокрутки, прогресс анимации, пороги значений. Если вход и выход меняются с одинаковой частотой — API не нужно.

Когда derivedStateOf не нужен

Теперь обратная сторона. derivedStateOf — ресурсоёмкий механизм, и документация прямо предупреждает: использовать его стоит только для предотвращения лишних рекомпозиций, когда результат не изменился.

Самая частая ошибка — оборачивать в derivedStateOf любое «вычисляемое» значение. Классический антипример из официальной документации:

ProfileForm.kt
// Так делать НЕ нужно — антипример из документации
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // плохо
val fullNameCorrect = "$firstName $lastName" // правильно

Почему «правильно» — обычная строка? Потому что fullName должен обновляться ровно с той же частотой, что и firstName с lastName: каждое изменение фамилии меняет полное имя. Лишней рекомпозиции не возникает — значит, derivedStateOf добавляет только накладные расходы.

Проверка на собеседовании: кандидат говорит «я вычисляю производное состояние, поэтому взял derivedStateOf» — я прошу объяснить, меняется ли результат реже, чем входные данные. Если нет — derivedStateOf здесь не нужен. Этот вопрос отсеивает заученные ответы мгновенно.

snapshotFlow: мост из состояния Compose в Flow

snapshotFlow решает смежную задачу: он превращает State<T> в холодный Flow. Блок snapshotFlow выполняется при старте сбора, читает состояния и выдаёт результат; когда одно из прочитанных состояний меняется, поток выдаёт новое значение — но только если оно отличается от предыдущего, что тоже напоминает distinctUntilChanged().

Зачем это нужно? Затем, что после превращения состояния в Flow к нему применяются все операторы корутин: map, filter, collect — и эффекты вроде аналитики становятся тривиальными. Пример из документации:

ScrollAnalytics.kt
@Composable
fun ScrollAnalytics(listState: LazyListState) {
    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .map { index -> index > 0 }
            .distinctUntilChanged()
            .filter { it }
            .collect {
                MyAnalyticsService.sendScrolledPastFirstItemEvent()
            }
    }
}

Обратите внимание на пару деталей. snapshotFlow стартует при сборе — поэтому его оборачивают в LaunchedEffect, который и запускает сбор. А distinctUntilChanged() в цепочке не лишний: snapshotFlow отсекает повторяющиеся значения самого состояния, а после map одинаковые результаты появляются снова — и вот их уже отсекает оператор.

Когда выбирать

derivedStateOf — если производное значение нужно внутри Compose (видимость, текст, цвет). snapshotFlow — если состояние нужно передать в мир корутин: аналитика, debounce, сохранение при уходе с экрана, объединение с другими потоками через combine.

Разница хорошо видна на одном и том же примере прокрутки: для кнопки «наверх» нужен derivedStateOf (состояние UI), для аналитики — snapshotFlow (поток событий). Кандидат, который объясняет это различие своими словами, — проходит.

И ещё один сценарий, где snapshotFlow незаменим, — объединение с другими потоками. Состояние Compose само по себе изолировано, но через snapshotFlow оно становится обычным Flow и может участвовать в combine с потоками из репозитория или в операторе debounce для поиска с задержкой. Так связываются два мира, которые иначе пришлось бы синхронизировать вручную.

Частая путаница на собеседовании: «snapshotFlow — горячий поток, потому что состояние уже есть». Нет: поток холодный и стартует при сборе. Именно поэтому без LaunchedEffect (или аналогичного места сбора) блок snapshotFlow не выполнится ни разу.

Ошибки на собеседованиях

Как и с любыми API состояния, здесь ошибки делятся на «не знаю» и «злоупотребляю». Типичный набор с моих собеседований.

  • derivedStateOf для любого вычисления. «Сложил два состояния — завернул в derivedStateOf». Лишний ресурсоёмкий механизм там, где хватило обычной строки или выражения.
  • derivedStateOf вне remember. Без remember состояние пересоздаётся при каждой рекомпозиции — и вся экономия пропадает. Забыли обёртку — получили противоположный эффект.
  • snapshotFlow без LaunchedEffect. Поток холодный: без сбора ничего не происходит. Собирать нужно в эффекте, а не «где-то рядом».
  • Непонимание, что snapshotFlow отсекает дубликаты. Эмиссия происходит только при изменении значения — как у distinctUntilChanged(). После трансформаций операторами это свойство теряется, и фильтр нужен снова.
  • «snapshotFlow и derivedStateOf — одно и то же». Нет: один создаёт состояние для Compose, второй — поток для корутин. Разные миры, одна идея про частоту изменений.

Главный вопрос

«Чем derivedStateOf отличается от snapshotFlow?» — если кандидат отвечает «derivedStateOf — состояние для UI, snapshotFlow — мост в Flow», тема закрыта. Всё остальное — детали.

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

Пара слов обо мне: я Рустем Бикбулатов, senior Android-разработчик, провожу технические собеседования и менторю разработчиков, в том числе как ментор Яндекс Практикума. За плечами более 100 учеников и разборов — и состояние в Compose одна из тем, где кандидаты с опытом 1–2 года чаще всего плавают. Есть диагностика: разбор вашего уровня и план закрытия пробелов.

Хотите понять, какие пробелы мешают вам расти?

Бесплатная диагностика: разберу ваши знания Compose, Kotlin и корутин и составлю план роста под ваш уровень.

Итоги

Соберём тему в три тезиса:

  • derivedStateOf нужен, когда входные данные меняются чаще, чем требуется перекомпоновка; результат обновляется только при реальном изменении — как distinctUntilChanged().
  • snapshotFlow превращает состояние Compose в холодный Flow — дальше работают операторы корутин, от map до debounce.
  • Оба API не для всего подряд: если вход и выход меняются одинаково часто или поток не нужен — они только добавляют сложность.

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

Вопрос «что выбрать» решается за секунду: производное значение нужно UI — derivedStateOf; нужно корутинам — snapshotFlow; не нужно ни то ни другое — не используйте ничего.

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