В Jetpack Compose всё крутится вокруг состояния. Текст в поле ввода — состояние. Индикатор загрузки — состояние. Список товаров в корзине — тоже состояние. Каждый раз, когда состояние меняется, Compose автоматически перерисовывает те части интерфейса, которые от него зависят.
Внутри Composable-функций состояние хранится через remember { mutableStateOf(...) }. Но если данные приходят из ViewModel — а в архитектуре MVVM так происходит почти всегда, — нужен другой механизм. И лучше всего на эту роль подходит StateFlow.
Что такое StateFlow
StateFlow Внутри Composable-функций состояние хранится через remember { mutableStateOf(...) }. Но если данные приходят из ViewModel — а в архитектуре MVVM так происходит почти всегда, — нужен другой механизм. И лучше всего на эту роль подходит StateFlow — горячий поток, который всегда хранит ровно одно значение: текущее. Можно думать о нём как о табло в аэропорту: оно показывает актуальную информацию в любой момент, не заставляя пассажира ждать следующего обновления. Подписался — сразу получил текущее значение. Значение обновилось — все подписчики узнали об этом мгновенно.
В отличие от холодного Flow, здесь нет функции emit(). Вместо неё — свойство value. Присвоил новое значение — подписчики получили обновление.
Каноничный паттерн: приватный Mutable, публичный Read-only
В ViewModel принято хранить StateFlow в двух экземплярах — изменяемом и неизменяемом:
class ProfileViewModel : ViewModel() {
private val _name = MutableStateFlow("Аноним")
val name: StateFlow<String> = _name.asStateFlow()
fun updateName(newName: String) {
_name.value = newName
}
}
Зачем два свойства для одного объекта? Всё дело в инкапсуляции. Приватное свойство _name — это MutableStateFlow, в который ViewModel может записывать новые значения. Публичное свойство name — это StateFlow, доступный только для чтения. UI видит name, может подписаться на него, но не может изменить его напрямую. Только ViewModel решает, когда и какие данные попадут в поток.
На самом деле в памяти существует один объект MutableStateFlow. Два свойства — это две ссылки разного типа на один и тот же объект. Поэтому и val, а не var: меняется содержимое объекта, а не ссылка на него.
Метод asStateFlow() создаёт обёртку, которая скрывает метод записи. Можно обойтись и без него, просто указав тип:
val name: StateFlow<String> = _name
Результат тот же — UI не сможет присвоить name.value напрямую. Но asStateFlow() надёжнее: даже если кто-то попробует сделать приведение типа к MutableStateFlow, у него ничего не выйдет.
Пример: экран с рейтингом
Допустим, мы пишем экран, на котором пользователь ставит оценку блюду: от одной до пяти звёзд. ViewModel хранит выбранную оценку и среднее значение по всем оценкам:
class RatingViewModel : ViewModel() {
private val _selectedRating = MutableStateFlow(0)
val selectedRating: StateFlow<Int> = _selectedRating.asStateFlow()
private val _averageRating = MutableStateFlow(4.2f)
val averageRating: StateFlow<Float> = _averageRating.asStateFlow()
fun selectRating(stars: Int) {
_selectedRating.value = stars
}
fun submitRating() {
val rating = _selectedRating.value
if (rating > 0) {
// Здесь был бы сетевой запрос
_averageRating.value = (averageRating.value + rating) / 2
_selectedRating.value = 0
}
}
}
В Composable подписываемся через collectAsStateWithLifecycle():
@Composable
fun RatingScreen(viewModel: RatingViewModel = viewModel()) {
val selected by viewModel.selectedRating.collectAsStateWithLifecycle()
val average by viewModel.averageRating.collectAsStateWithLifecycle()
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Средняя оценка: ${"%.1f".format(average)}", fontSize = 20.sp)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (star in 1..5) {
IconButton(onClick = { viewModel.selectRating(star) }) {
Icon(
imageVector = if (star <= selected)
Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = "Звезда $star"
)
}
}
}
Spacer(Modifier.height(16.dp))
Button(onClick = { viewModel.submitRating() }) {
Text("Отправить")
}
}
}
Обратите внимание на collectAsStateWithLifecycle(). Эта функция делает то же, что collectAsState(), но дополнительно привязывает сбор потока к жизненному циклу экрана. Когда пользователь сворачивает приложение или переходит на другой экран, сбор приостанавливается — и возобновляется при возвращении. Для реальных проектов это предпочтительный вариант.
StateFlow и distinctUntilChanged
У StateFlow есть встроенная защита от дубликатов. Если присвоить value то же значение, что уже хранится, — подписчики не получат обновления. Это важно для производительности: Compose не будет перерисовывать экран, если ничего не изменилось.
_selectedRating.value = 3 // подписчики получат обновление
_selectedRating.value = 3 // ничего не произойдёт
_selectedRating.value = 4 // подписчики получат обновление
Фактически StateFlow ведёт себя так, будто к нему постоянно применён оператор distinctUntilChanged().
Когда StateFlow недостаточно
StateFlow хранит состояние — нечто, что в каждый момент времени имеет актуальное значение. Но не все данные в приложении — состояние. Есть ещё события: показать уведомление, перейти на другой экран, воспроизвести звук. Событие — это разовый сигнал, который не нужно хранить. Если его никто не услышал — он пропал.
Для событий существует SharedFlow. Он не хранит последнее значение по умолчанию и рассылает данные всем активным подписчикам одновременно. Но это уже другая история.
Паттерн private MutableStateFlow + public StateFlow — рабочая лошадка MVVM-архитектуры в Android. Он прост, надёжен и покрывает большинство сценариев. Запомните его — он будет встречаться практически в каждом ViewModel, который вы напишете.