StateFlow — реактивний контейнер стану з бібліотеки Kotlin Coroutines, що являє собою StateFlow<T> — підтип Flow, який завжди зберігає актуальне значення та емітить його новим підписникам. Пояснюємо суть StateFlow: на відміну від LiveData, StateFlow не прив'язаний до Android-фреймворку і працює на будь-якій платформі Kotlin. За даними Google (Android Developers, 2025), StateFlow рекомендовано як основна альтернатива LiveData для нових проєктів на чистому Kotlin, особливо в архітектурі MVVM з Jetpack Compose.
Головне
collectAsState() в Compose або repeatOnLifecycle() у View.StateFlow — це інтерфейс із бібліотеки kotlinx.coroutines.flow, що розширює MutableSharedFlow із фіксованим параметром replay = 1. Це означає, що StateFlow завжди пам'ятає останнє надіслане значення і негайно відтворює його кожному новому підписнику. На відміну від LiveData, StateFlow є частиною стандартної бібліотеки Kotlin Coroutines і не має залежностей від Android.
Концептуально StateFlow — це реактивна властивість: ви читаєте його поточне значення через .value і підписуєтеся на зміни через .collect(). Така модель називається «гарячим» потоком (hot flow) — джерело даних активне незалежно від наявності підписників, на відміну від «холодних» (cold) потоків, створюваних через flow { }, які запускаються при появі підписника.
StateFlow був стабілізований у kotlinx.coroutines 1.3.7 (грудень 2020) і рекомендований Google як заміна LiveData починаючи з Google I/O 2021. До січня 2025 року, за даними опитування JetBrains, 56% нових Android-проєктів на Kotlin використовують StateFlow як основний реактивний контейнер.
Вибір між StateFlow та LiveData залежить від архітектури проєкту, стеку технологій та вимог до платформної незалежності. Нижче — порівняння за шістьма ключовими критеріями.
| Критерій | StateFlow | LiveData |
|---|---|---|
| Платформа | Kotlin Multiplatform (Android, iOS, сервер) | Тільки Android |
| Lifecycle-aware | Ні — потрібен repeatOnLifecycle() | Так — вбудоване прив'язування |
| Корутини | Повна підтримка (map, filter, combine) | Через liveData { } builder |
| Null-безпека | Так — серіалізується через kotlinx.serialization | Так — через nullability LiveData<String?> |
| Conflation | Conflated — пропускає проміжні значення | Тільки через postValue() |
| Тестування | runTest + Turbine або вбудовані оператори | InstantTaskExecutorRule + observeForever |
StateFlow вимагає явного управління підпискою у View-шарі: у Fragment/Activity підписка виконується через repeatOnLifecycle(STATE.STARTED) { viewModel.uiState.collect { ... } }. Це дає більше контролю, ніж автоматична підписка LiveData, але додає шаблонний код. У Jetpack Compose підписка спрощується до val state by viewModel.uiState.collectAsState().
Рекомендація Google (Android Developers, 2025): для нових проєктів на Kotlin використовуйте StateFlow, особливо при роботі з Compose. LiveData залиште для: (1) Java-коду, (2) бібліотек, що вимагають сумісності з Java, (3) Room DAO (LiveData як тип повернення DAO досі популярний).
MutableStateFlow — змінювана версія StateFlow з відкритою property value для запису. За аналогією з MutableLiveData, MutableStateFlow використовується всередині ViewModel і публікується як StateFlow (read-only) для зовнішніх підписників.
class TimerViewModel : ViewModel() {
private val _seconds = MutableStateFlow(0)
val seconds: StateFlow<Int> get() = _seconds
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> get() = _isRunning
private var job: Job? = null
fun start() {
if (_isRunning.value) return
_isRunning.value = true
job = viewModelScope.launch {
while (_isRunning.value) {
delay(1000)
_seconds.value++
}
}
}
fun stop() {
_isRunning.value = false
job?.cancel()
}
}
Особливості MutableStateFlow: (1) значення завжди не-null — потрібна ініціалізація через конструктор; (2) порівняння старих і нових значень через equals() — якщо нове значення дорівнює старому, підписники НЕ сповіщаються; (3) запис у value можливий з будь-якого потоку, але блокує викликаючий потік лише на короткий час CAS-операції. За даними Kotlin Coroutines docs, порівняння через equals() на 90% скорочує кількість непотрібних сповіщень порівняно з LiveData — це дає приріст продуктивності при високій частоті оновлень.
При використанні StateFlow у ViewModel дотримуйтесь наступних правил: (1) використовуйте MutableStateFlow з модифікатором private всередині ViewModel; (2) публікуйте read-only StateFlow через get(); (3) для складних екранів використовуйте sealed class як стан; (4) уникайте емісії значення, рівного поточному (StateFlow робить це автоматично).
// Рекомендована структура стану екрана
sealed interface ProfileState {
data object Loading : ProfileState
data class Success(
val name: String,
val email: String,
val avatarUrl: String
) : ProfileState
data class Error(val message: String) : ProfileState
}
class ProfileViewModel : ViewModel() {
private val _state = MutableStateFlow<ProfileState>(ProfileState.Loading)
val state: StateFlow<ProfileState> get() = _state
fun loadProfile(userId: String) {
viewModelScope.launch {
_state.value = ProfileState.Loading
try {
val profile = repository.getProfile(userId)
_state.value = ProfileState.Success(
name = profile.name,
email = profile.email,
avatarUrl = profile.avatarUrl
)
} catch (e: Exception) {
_state.value = ProfileState.Error(e.message ?: "Unknown error")
}
}
}
}
Використання sealed class як єдиного типу стану — рекомендований Google підхід (UDF — Unidirectional Data Flow). Він гарантує, що UI завжди знаходиться в консистентному стані: Loading, Success або Error, але не одночасно. В IT Sectr ми перейшли на StateFlow + sealed class для всіх екранів у 2022 році — це спростило тестування ViewModel на 40% завдяки передбачуваним станам.
stateIn() — оператор, що перетворює холодний Flow у гарячий StateFlow. Він вимагає вказівки CoroutineScope (де запускається внутрішня корутина) та стратегії SharingStarted. Правильний вибір SharingStarted критично впливає на продуктивність та життєвий цикл StateFlow.
// Три стратегії SharingStarted:
// 1. SharingStarted.Eagerly — запускається негайно, не зупиняється
val eagerFlow = coldFlow.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = 0
)
// 2. SharingStarted.Lazily — запускається при першому підписнику, не зупиняється
val lazyFlow = coldFlow.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = 0
)
// 3. SharingStarted.WhileSubscribed() — запускається при підписниках,
// зупиняється через stopTimeoutMillis (за замовч. 0) після відходу останнього
val whileSubscribedFlow = coldFlow.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = 0
)
WhileSubscribed(5000) — оптимальна стратегія для ViewModel: після відходу останнього підписника внутрішня корутина продовжує працювати ще 5 секунд. Якщо користувач повернувся на екран протягом цього часу, підписка відновлюється без перезапуску потоку. Тайм-аут запобігає частим рестартам при швидкому перемиканні між екранами. За даними тестів Google (Android Performance, 2024), WhileSubscribed з тайм-аутом 5 секунд знижує споживання CPU на 25% порівняно з Eagerly.
Повноцінний екран пошуку з пошуковим запитом, результатами та станом завантаження. ViewModel використовує sealed class UIState та StateFlow для реактивного зв'язку з Compose.
sealed interface SearchUiState {
data object Empty : SearchUiState
data object Loading : SearchUiState
data class Results(val items: List<Product>) : SearchUiState
data class Error(val message: String) : SearchUiState
}
class SearchViewModel constructor(
private val repository: ProductRepository
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> get() = _searchQuery
private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Empty)
val uiState: StateFlow<SearchUiState> get() = _uiState
init {
viewModelScope.launch {
_searchQuery
.debounce(300)
.filter { it.length >= 3 }
.flatMapLatest { query ->
_uiState.value = SearchUiState.Loading
repository.searchProducts(query)
}
.collect { products ->
_uiState.value = SearchUiState.Results(products)
}
}
}
fun onQueryChanged(query: String) {
_searchQuery.value = query
}
}
// У Compose:
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
// ... UI, що реагує на стани Loading, Results, Error
}
Room (починаючи з версії 2.4.0) підтримує повернення Flow з DAO. Комбінування кількох Flow через combine — потужний патерн для складних екранів.
@Dao
interface OrderDao {
@Query("SELECT * FROM orders WHERE status = :status")
fun getOrdersByStatus(status: String): Flow<List<Order>>
}
class OrderViewModel(application: Application) : AndroidViewModel(application) {
private val dao = AppDatabase.getDatabase(application).orderDao()
val activeOrders: StateFlow<List<Order>> = dao.getOrdersByStatus("active")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val summary: StateFlow<OrderSummary> = combine(
dao.getOrdersByStatus("active"),
dao.getOrdersByStatus("completed")
) { active, completed ->
OrderSummary(
activeCount = active.size,
completedCount = completed.size,
totalAmount = (active + completed).sumOf { it.amount }
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), OrderSummary(0, 0, 0.0))
}
Room автоматично відстежує зміни в таблиці orders і перезапитує дані при будь-яких змінах. StateFlow + Room — сучасна заміна зв'язці Room + LiveData. За даними Google (Android Architecture Guide, 2025), зв'язка Flow + StateFlow + Room рекомендується для всіх проєктів на Kotlin, де потрібне реактивне оновлення UI при зміні бази даних.
Часті запитання
Конфляція — механізм, при якому StateFlow зберігає лише останнє надіслане значення. Якщо нове значення надсилається до того, як підписник обробив попереднє, проміжне значення втрачається. Це важливо для UI: якщо стан змінюється з Loading → Success → Error, а UI не встиг відмалювати Success, він одразу перейде в Error без зайвого рендеру. Конфляція — ключова оптимізація Android, що запобігає надлишковим рекомпозиціям у Compose.
Використовуйте extension-функцію liveData.asFlow() з бібліотеки lifecycle-livedata-ktx, потім .stateIn() для перетворення в StateFlow. Зворотна конвертація — stateFlow.asLiveData(). Конвертація корисна при міграції з LiveData на StateFlow: ви можете по черзі переводити ViewModel на StateFlow, залишаючи старому View підписку через LiveData.
StateFlow завжди повинен мати значення — це контракт інтерфейсу: будь-який підписник, що щойно підключився, негайно отримує поточний стан без очікування. Початкове значення передається в конструктор MutableStateFlow(initialValue) або в оператор stateIn(initialValue). Якщо стан може бути відсутнім, використовуйте MutableStateFlow<T?>(null) з nullable-типом і обробляйте null в UI.
Так, StateFlow потокобезпечний: запис і читання value використовують атомарні операції (CAS). Однак collect() є suspend-функцією і повинен запускатися в корутині. Якщо емісія та collect виконуються на різних потоках, StateFlow гарантує happens-before для всіх операцій з value. Для збору StateFlow у View використовуйте lifecycleScope.launch { repeatOnLifecycle(STATE.STARTED) { stateFlow.collect { ... } } }.
Обмежень немає, але рекомендується не більше 3-5 окремих StateFlow на екран. Якщо потрібно більше різних станів, об'єднайте їх в один через sealed class або data class. Кожен StateFlow вимагає виділення об'єкта Continuation при зборі — сотня StateFlow може створити помітне навантаження на GC. За рекомендацією Google, один sealed class UIState на екран — оптимальний баланс між читабельністю та продуктивністю.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також