Android Studio: Kotlin урок по ViewModel и StateFlow

Когда вы впервые сталкиваетесь с разработкой под Android, то обычно весь код пишете прямо в Activity или в Composable-функциях. Это работает, пока приложение маленькое, но как только экранов становится больше, такой подход приводит к путанице. Данные теряются при повороте экрана, логика перемешивается с отображением, а тестировать такой код почти невозможно. В этом уроке из цикла Android Studio Kotlin уроки мы познакомимся с ViewModel — инструментом, который решает эти проблемы, и научимся использовать StateFlow для реактивного обновления интерфейса.


Зачем нужен ViewModel

ViewModel — это специальный класс из библиотеки Jetpack, предназначенный для хранения и управления данными, связанными с пользовательским интерфейсом. Его главное преимущество: ViewModel живёт дольше, чем Activity или Fragment. Когда вы поворачиваете экран, система пересоздаёт Activity, но ваш ViewModel остаётся неизменным, и все данные, которые он хранит, не пропадают. Это избавляет от необходимости вручную сохранять состояние в Bundle и восстанавливать его.

Кроме того, ViewModel хорошо разделяет обязанности: Activity или Composable занимаются только отображением, а ViewModel — всей логикой и данными. Это делает код чище и удобнее для тестирования.


Добавляем зависимости

Для работы с ViewModel в проекте на Kotlin необходимо подключить библиотеку lifecycle-viewmodel-compose. Она содержит всё необходимое для использования ViewModel в Jetpack Compose. Откройте файл build.gradle.kts уровня модуля и добавьте строку:

dependencies {
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
}

После синхронизации Gradle библиотека будет готова к работе. Никаких дополнительных настроек не требуется.


Создаём первое ViewModel

Создадим простой экран-счётчик. Пользователь нажимает на кнопку, и число увеличивается. Без ViewModel это выглядело бы как переменная внутри Composable-функции. Но при повороте экрана счётчик сбрасывался бы. Мы же сделаем по-другому.

Сначала создадим класс, наследник от ViewModel. Внутри будем хранить состояние счётчика. Можно было бы использовать простой Int, но для реактивности применяем MutableStateFlow. StateFlow — это поток данных, на который можно подписаться из интерфейса. Когда значение меняется, подписчики автоматически обновляются.

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count = _count.asStateFlow()

    fun increment() {
        _count.value++
    }
}

Обратите внимание: _count — приватная изменяемая переменная, а count — публичная неизменяемая версия. Так другие части программы могут только читать значение, но не менять его в обход ViewModel.


Подключаем ViewModel к экрану Compose

Теперь напишем интерфейс. В Jetpack Compose получить экземпляр ViewModel можно с помощью функции viewModel(). Она автоматически свяжет ViewModel с текущим жизненным циклом Activity или Fragment.

import androidx.compose.runtime.*
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun CounterScreen() {
    val viewModel: CounterViewModel = viewModel()
    val count by viewModel.count.collectAsState()

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(text = "Счётчик: $count", style = MaterialTheme.typography.headlineMedium)
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = { viewModel.increment() }) {
            Text("Увеличить")
        }
    }
}

Разберём ключевой момент: collectAsState() — это специальная Compose-функция, которая подписывается на StateFlow и превращает его в состояние Compose. При изменении данных в StateFlow Compose автоматически перерисует нужные части экрана.

Теперь запустите приложение, нажмите несколько раз кнопку, а затем поверните устройство. Счётчик останется неизменным — ViewModel сохранил его.


Передаём параметры в ViewModel

Часто ViewModel нужно стартовое значение, например идентификатор товара, переданный с предыдущего экрана. Для этого существует ViewModelProvider.Factory. Вручную писать фабрику на каждый класс утомительно, поэтому используют viewModel() с дополнительными параметрами через библиотеку lifecycle-viewmodel-compose — она умеет принимать параметры в ключ-значении.

Но для нашего урока сделаем проще: пока просто будем задавать начальное значение нулём, а в реальном проекте можно воспользоваться SavedStateHandle (он доступен внутри ViewModel и автоматически сохраняет данные в Bundle).


Реакция на события и загрузка данных

Расширим пример. Пусть при нажатии кнопки «Загрузить» счётчик устанавливается в случайное значение от 1 до 100. Для имитации задержки используем корутину внутри ViewModel. Для этого ViewModel предоставляет viewModelScope — специальный скоуп корутин, который автоматически отменяется, когда ViewModel уничтожается.

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count = _count.asStateFlow()

    fun increment() {
        _count.value++
    }

    fun loadRandom() {
        viewModelScope.launch {
            delay(500) // имитация сетевого запроса
            _count.value = (1..100).random()
        }
    }
}

В интерфейсе добавим ещё одну кнопку и вызовем viewModel.loadRandom(). Теперь у нас два действия: одно синхронное, другое асинхронное с задержкой. Обратите внимание, что интерфейс остаётся отзывчивым на всё время выполнения загрузки.


Обработка ошибок и состояние загрузки

Хорошим тоном считается отображать процесс загрузки и ошибки. Для этого в StateFlow можно хранить не просто число, а специальный sealed-класс состояния. Создадим такой:

sealed class UiState {
    data object Loading : UiState()
    data class Success(val value: Int) : UiState()
    data class Error(val message: String) : UiState()
}

Переделаем ViewModel под использование этого состояния:

class CounterViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Success(0))
    val uiState = _uiState.asStateFlow()

    fun increment() {
        val current = (_uiState.value as? UiState.Success)?.value ?: 0
        _uiState.value = UiState.Success(current + 1)
    }

    fun loadRandom() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                delay(500)
                _uiState.value = UiState.Success((1..100).random())
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Ошибка")
            }
        }
    }
}

А в Compose будем обрабатывать состояние через when:

val uiState by viewModel.uiState.collectAsState()
when (val state = uiState) {
    is UiState.Loading -> Text("Загрузка...")
    is UiState.Success -> Text("Счётчик: ${state.value}")
    is UiState.Error -> Text("Ошибка: ${state.message}")
}

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


Краткий конспект: что мы изучили

  • ViewModel хранит данные и переживает поворот экрана.
  • StateFlow позволяет реактивно обновлять интерфейс при изменении данных.
  • collectAsState() превращает Flow в состояние Compose.
  • viewModelScope предназначен для безопасного выполнения корутин в ViewModel.
  • Паттерн с sealed-классом для состояния помогает обрабатывать загрузку и ошибки.

Этот урок из цикла Android Studio Kotlin уроки дал вам фундамент для построения надёжных и поддерживаемых приложений. Теперь вы можете вынести логику из Activity в ViewModel, а обновление интерфейса сделать реактивным. Попробуйте самостоятельно добавить сброс счётчика и кнопку «Назад» — это закрепит навык.