29 KiB
title: StateFlow и SharedFlow description: Горячие потоки: хранение состояния, события, replay, stateIn и shareIn, конфляция и выбор между ними tags:
- kotlin
- coroutines
- flow
StateFlow и SharedFlow - горячие потоки: они существуют и работают независимо от подписчиков. Обычный Flow холодный - он запускается на каждый collect и умирает вместе с ним.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val state = MutableStateFlow(0)
val job = launch {
state.collect { println("подписчик: $it") }
}
delay(20)
state.value = 1
state.value = 2
delay(20)
job.cancel()
println("текущее значение без подписчиков: ${state.value}")
// подписчик: 0
// подписчик: 1
// подписчик: 2
// текущее значение без подписчиков: 2
}
Обратите внимание: подписчик сразу получил текущее значение 0, а после отмены сбора состояние осталось доступным через .value.
StateFlow: состояние
StateFlow - это наблюдаемое значение. У него всегда есть текущее состояние, и оно всегда одно.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class Counter {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow() // только чтение снаружи
fun increment() { _count.value++ }
fun add(n: Int) = _count.update { it + n } // атомарно
}
fun main() = runBlocking {
val counter = Counter()
val job = launch { counter.count.collect { println("счёт: $it") } }
delay(20)
counter.increment()
delay(20)
counter.add(10)
delay(20)
job.cancel()
// счёт: 0
// счёт: 1
// счёт: 11
}
Пара «приватный MutableStateFlow + публичный StateFlow» - основной шаблон: изменять состояние может только владелец, читать - все.
StateFlow конфлюирует и дедуплицирует
Два свойства, которые нужно знать наизусть.
1. Одинаковые подряд значения не доходят - StateFlow сравнивает через equals:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val state = MutableStateFlow("a")
val received = mutableListOf<String>()
val job = launch { state.collect { received += it } }
delay(20)
state.value = "a"; delay(20) // то же значение — не придёт
state.value = "b"; delay(20)
state.value = "b"; delay(20) // снова дубль
state.value = "a"; delay(20)
job.cancel()
println(received) // [a, b, a]
}
Задержки между присваиваниями здесь не для красоты: без них подписчик не успел бы обработать значения и сработало бы второе свойство - конфляция (см. ниже), из-за которой он увидел бы только a и финальное значение.
Это бесплатный distinctUntilChanged, но он же источник ошибок с изменяемыми объектами:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class MutableBox(val items: MutableList<Int>)
fun main() = runBlocking {
val box = MutableBox(mutableListOf(1))
val state = MutableStateFlow(box)
var updates = 0
val job = launch { state.collect { updates++ } }
delay(20)
box.items.add(2) // изменили содержимое
state.value = box // тот же объект — equals true
delay(20)
job.cancel()
println("обновлений: $updates") // обновлений: 1
}
Подписчик не узнал об изменении. Поэтому состояние в StateFlow должно быть неизменяемым - data class с val и неизменяемыми коллекциями. Подробнее: Data class
2. Медленный подписчик пропускает промежуточные значения:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val state = MutableStateFlow(0)
val received = mutableListOf<Int>()
val job = launch {
state.collect { delay(50); received += it }
}
delay(10)
repeat(10) { state.value = it + 1 }
delay(150)
job.cancel()
println("получено не всё: ${received.size < 10}")
println("последнее значение дошло: ${received.last() == 10}")
// получено не всё: true
// последнее значение дошло: true
}
StateFlow - про актуальное состояние, а не про историю. Последнее значение дойдёт всегда, промежуточные - нет.
MutableStateFlow.update
Присваивание value не атомарно при конкуренции:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val unsafe = MutableStateFlow(0)
withContext(Dispatchers.Default) {
List(100) { launch { repeat(100) { unsafe.value = unsafe.value + 1 } } }
.forEach { it.join() }
}
println("потеряны обновления: ${unsafe.value < 10_000}")
val safe = MutableStateFlow(0)
withContext(Dispatchers.Default) {
List(100) { launch { repeat(100) { safe.update { v -> v + 1 } } } }
.forEach { it.join() }
}
println("update атомарен: ${safe.value == 10_000}")
// потеряны обновления: true
// update атомарен: true
}
update выполняет CAS-цикл, поэтому при конкурентных изменениях нужен именно он. Подробнее: Разделяемое состояние
SharedFlow: события
SharedFlow не хранит «текущее значение» - он транслирует события всем подписчикам.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val events = MutableSharedFlow<String>()
val a = launch { events.collect { println("A: $it") } }
val b = launch { events.collect { println("B: $it") } }
delay(20) // дать подписчикам стартовать
events.emit("событие 1")
delay(20)
a.cancel(); b.cancel()
// A: событие 1
// B: событие 1
}
По умолчанию replay = 0: подписчик получает только то, что произошло после подписки. Событие, отправленное без подписчиков, исчезает:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val events = MutableSharedFlow<String>()
events.emit("в пустоту") // подписчиков нет
val received = mutableListOf<String>()
val job = launch { events.collect { received += it } }
delay(20)
events.emit("услышано")
delay(20)
job.cancel()
println(received) // [услышано]
}
replay
replay задаёт, сколько последних событий получит новый подписчик:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val events = MutableSharedFlow<Int>(replay = 2)
events.emit(1)
events.emit(2)
events.emit(3)
val received = mutableListOf<Int>()
val job = launch { events.collect { received += it } }
delay(20)
job.cancel()
println(received) // [2, 3]
}
StateFlow эквивалентен SharedFlow(replay = 1) с дедупликацией и обязательным начальным значением.
Буфер и стратегия переполнения
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val dropping = MutableSharedFlow<Int>(
replay = 0,
extraBufferCapacity = 2,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val received = mutableListOf<Int>()
val job = launch { dropping.collect { delay(50); received += it } }
delay(20)
repeat(10) { dropping.emit(it) } // emit не приостанавливается
delay(200)
job.cancel()
println("часть событий отброшена: ${received.size < 10}") // часть событий отброшена: true
}
| Параметр | Смысл |
|---|---|
replay |
сколько событий повторить новому подписчику |
extraBufferCapacity |
буфер сверх replay |
onBufferOverflow |
SUSPEND (по умолчанию), DROP_OLDEST, DROP_LATEST |
Важно: DROP_OLDEST/DROP_LATEST работают только при ненулевом буфере (replay + extraBufferCapacity > 0).
tryEmit - неприостанавливающаяся отправка:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val noBuffer = MutableSharedFlow<Int>()
println("без буфера и подписчиков: ${noBuffer.tryEmit(1)}") // без буфера и подписчиков: true
val withSubscriber = MutableSharedFlow<Int>()
val job = launch { withSubscriber.collect { delay(1000) } }
delay(20)
println("подписчик занят, буфера нет: ${withSubscriber.tryEmit(2)}") // подписчик занят, буфера нет: false
job.cancel()
}
Тонкость, которая удивляет: без подписчиков tryEmit возвращает true, хотя событие никто не получил - отправка «удалась», получателей просто не было.
stateIn и shareIn
Превращают холодный Flow в горячий:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
var starts = 0
val cold = flow {
starts++
repeat(3) { delay(20); emit(it) }
}
val hot = cold.stateIn(
scope = this,
started = SharingStarted.Eagerly,
initialValue = -1,
)
delay(30)
val a = launch { hot.take(1).collect { println("A видит: $it") } }
val b = launch { hot.take(1).collect { println("B видит: $it") } }
a.join(); b.join()
println("источник запущен раз: $starts")
// A видит: 0
// B видит: 0
// источник запущен раз: 1
}
Без stateIn два подписчика запустили бы источник дважды - два сетевых запроса вместо одного.
SharingStarted определяет жизненный цикл источника:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
var active = 0
val source = flow {
active++
try {
repeat(100) { delay(10); emit(it) }
} finally { active-- }
}
val shared = source.shareIn(
scope = this,
started = SharingStarted.WhileSubscribed(),
replay = 1,
)
val job = launch { shared.collect { } }
delay(30)
println("источник активен при подписчике: ${active == 1}")
job.cancelAndJoin()
delay(30)
println("источник остановлен без подписчиков: ${active == 0}")
coroutineContext.cancelChildren() // иначе runBlocking ждёт корутину shareIn
// источник активен при подписчике: true
// источник остановлен без подписчиков: true
}
SharingStarted |
Когда источник активен |
|---|---|
Eagerly |
сразу и до отмены scope |
Lazily |
с первого подписчика, затем навсегда |
WhileSubscribed() |
только пока есть подписчики |
WhileSubscribed(stopTimeoutMillis = 5000) |
плюс пауза перед остановкой |
WhileSubscribed(5000) - стандартный выбор для Android: при повороте экрана подписчик пересоздаётся за миллисекунды, и таймаут не даёт зря перезапускать запрос.
Как выбрать
graph TD
A["Нужен поток значений"] --> B{"Один подписчик,<br/>работа по запросу?"}
B -->|"да"| C["обычный Flow<br/>холодный"]
B -->|"нет, нужно делить"| D{"Есть текущее<br/>значение?"}
D -->|"да, это состояние"| E["StateFlow<br/>initialValue обязателен"]
D -->|"нет, это события"| F{"Нужна история<br/>для новых подписчиков?"}
F -->|"нет"| G["SharedFlow<br/>replay = 0"]
F -->|"да, n последних"| H["SharedFlow<br/>replay = n"]
StateFlow |
SharedFlow |
|
|---|---|---|
| начальное значение | обязательно | нет |
.value |
есть | нет |
| дедупликация | да, по equals |
нет |
replay |
всегда 1 | настраивается |
| потеря промежуточных | да, конфляция | зависит от буфера |
| типичное применение | состояние экрана, настройки | навигация, снекбары, ошибки |
Практическое правило: состояние (что сейчас на экране) - StateFlow; события (что произошло один раз) - SharedFlow. Показ сообщения об ошибке через StateFlow приводит к повторному показу при пересоздании подписчика.
Состояние экрана целиком
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
data class ScreenState(
val loading: Boolean = false,
val items: List<String> = emptyList(),
val error: String? = null,
)
class ViewModel(private val scope: CoroutineScope) {
private val _state = MutableStateFlow(ScreenState())
val state: StateFlow<ScreenState> = _state.asStateFlow()
fun load() {
scope.launch {
_state.update { it.copy(loading = true, error = null) }
delay(20)
_state.update { it.copy(loading = false, items = listOf("первый", "второй")) }
}
}
}
fun main() = runBlocking {
val vm = ViewModel(this)
val job = launch { vm.state.collect { println(it) } }
delay(10)
vm.load()
delay(60)
job.cancel()
// ScreenState(loading=false, items=[], error=null)
// ScreenState(loading=true, items=[], error=null)
// ScreenState(loading=false, items=[первый, второй], error=null)
}
Один StateFlow на всё состояние лучше трёх отдельных: подписчик видит согласованный снимок, а не промежуточные комбинации полей. copy + update делают изменение атомарным. Подробнее: Data class
Производные состояния
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val query = MutableStateFlow("")
val items = MutableStateFlow(listOf("кот", "кофе", "книга"))
val filtered = combine(query, items) { q, list ->
list.filter { it.startsWith(q) }
}.stateIn(this, SharingStarted.Eagerly, emptyList())
delay(20) // Eagerly запускает корутину, но не мгновенно
println(filtered.value) // [кот, кофе, книга]
query.value = "ко"
delay(20)
println(filtered.value) // [кот, кофе]
coroutineContext.cancelChildren()
}
combine + stateIn - способ получить вычисляемое состояние из нескольких источников. Важно вызвать cancelChildren() или ограничить scope: stateIn с Eagerly запускает корутину, которая живёт до отмены области.
subscriptionCount
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val events = MutableSharedFlow<Int>()
println("подписчиков: ${events.subscriptionCount.value}") // подписчиков: 0
val job = launch { events.collect { } }
delay(20)
println("после подписки: ${events.subscriptionCount.value}") // после подписки: 1
job.cancelAndJoin()
println("после отмены: ${events.subscriptionCount.value}") // после отмены: 0
}
Это StateFlow<Int>, поэтому за числом подписчиков можно наблюдать - именно так WhileSubscribed понимает, когда останавливать источник.
[!info] Отличия от Java
Java Kotlin Observable+Observer(паттерн)StateFlowPropertyChangeListenerStateFlowRxJava BehaviorSubjectStateFlowRxJava PublishSubjectSharedFlow(replay = 0)RxJava ReplaySubject(n)SharedFlow(replay = n)AtomicReference+ слушателиMutableStateFlowCopyOnWriteArrayList<Listener>встроенная многоадресность Паттерн «наблюдатель» вручную. В Java его пишут заново в каждом проекте: список слушателей, синхронизация при добавлении и удалении, защита от
ConcurrentModificationExceptionпри оповещении, снятие слушателей при закрытии компонента. Классическая реализация:private final List<Consumer<Integer>> listeners = new CopyOnWriteArrayList<>(); private volatile int value; public void setValue(int v) { this.value = v; for (var l : listeners) l.accept(v); } public void addListener(Consumer<Integer> l) { listeners.add(l); } public void removeListener(Consumer<Integer> l) { listeners.remove(l); }Здесь уже есть проблемы: оповещение идёт в потоке вызывающего, медленный слушатель задерживает остальных, забытый
removeListener- утечка.MutableStateFlowрешает всё это за одну строку, а отписка происходит автоматически при отмене корутины-подписчика.
BehaviorSubjectпротивStateFlow. Прямой аналог, но с двумя отличиями.BehaviorSubjectне дедуплицирует одинаковые значения - в Rx для этого добавляютdistinctUntilChanged()руками. ИSubjectможно «завершить» черезonComplete/onError, после чего он мёртв;StateFlowне завершается никогда - ошибки передают внутри состояния (полеerror), что для UI удобнее: экран всегда в каком-то состоянии.Утечки подписок. В Rx подписка возвращает
Disposable, который нужно хранить и вызыватьdispose(); для борьбы с забывчивостью придумалиCompositeDisposableиAutoDispose. В Kotlin подписчик - корутина, поэтому отмена scope отписывает всех:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val state = MutableStateFlow(0) val scope = CoroutineScope(Job()) scope.launch { state.collect { } } delay(20) println("подписчиков: ${state.subscriptionCount.value}") scope.cancel() delay(20) println("после cancel: ${state.subscriptionCount.value}") // подписчиков: 1 // после cancel: 0 }
volatileполе противStateFlow.volatile int valueдаёт видимость между потоками, но не даёт ни оповещения, ни атомарного обновления (value++всё равно гонка - нуженAtomicInteger).MutableStateFlowсовмещает три роли: потокобезопасное хранилище, атомарныйupdateи многоадресная рассылка.Нет аналога
WhileSubscribed. В Java управление жизненным циклом источника при появлении и исчезновении слушателей пишут вручную: счётчик подписчиков, запуск потока при первом, остановка при последнем, таймаут на повторное подключение.shareIn(started = WhileSubscribed(5000))- это одна строка.Сравнить: Многопоточность в Java, Паттерны проектирования
[!warning] Подводные камни
StateFlowне доставит одинаковое значение дважды:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val showError = MutableStateFlow("ошибка сети") var shown = 0 val job = launch { showError.collect { shown++ } } delay(20) showError.value = "ошибка сети" // то же самое — не придёт delay(20) job.cancel() println("показов: $shown") // показов: 1 }Для повторяющихся событий (две одинаковые ошибки подряд) нужен
SharedFlow.Изменяемое состояние внутри
StateFlowломает оповещение. Если вы положилиMutableListи меняете его содержимое,equalsвернётtrueи подписчики ничего не узнают. Храните неизменяемыеdata classи обновляйте черезcopy.
state.value = state.value + 1- гонка:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val state = MutableStateFlow(0) withContext(Dispatchers.Default) { List(50) { launch { repeat(200) { state.value = state.value + 1 } } } .forEach { it.join() } } println("обновления потеряны: ${state.value < 10_000}") // обновления потеряны: true }Используйте
update { }.
SharedFlowбез подписчиков теряет события.emitприreplay = 0и нулевом буфере просто завершится, не доставив ничего. Для «событий, которые нельзя потерять» нуженreplay ≥ 1илиChannel. Подробнее: Channels
emitвSharedFlowсSUSPENDприостанавливается на медленном подписчике:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlin.system.measureTimeMillis fun main() = runBlocking { val events = MutableSharedFlow<Int>() val job = launch { events.collect { delay(100) } } delay(20) val t = measureTimeMillis { events.emit(1); events.emit(2) } println("emit ждал подписчика: ${t >= 90}") // emit ждал подписчика: true job.cancel() }Один зависший подписчик блокирует отправку всем. Задавайте
extraBufferCapacityи стратегию переполнения.
tryEmitвозвращаетtrueбез подписчиков, хотя событие никто не получил. Проверкаif (tryEmit(x))не означает «доставлено».
DROP_OLDESTбез буфера не работает. Приreplay = 0иextraBufferCapacity = 0стратегия переполнения игнорируется, потому что буфера нет -emitбудет приостанавливаться.
SharingStarted.Eagerlyдержит источник навсегда:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { var active = 0 val source = flow { active++ try { repeat(1000) { delay(10); emit(it) } } finally { active-- } } val hot = source.stateIn(this, SharingStarted.Eagerly, -1) delay(30) println("работает без единого подписчика: ${active == 1}") coroutineContext.cancelChildren() // работает без единого подписчика: true }Если источник - опрос сервера, он будет работать и жечь батарею, пока жив scope. Для подписок UI нужен
WhileSubscribed().
stateIn/shareInтребуют scope, который кто-то отменит. ПередачаGlobalScopeили scope, живущего весь процесс, превращает их в утечку.
StateFlow.value- снимок, а не транзакция:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val a = MutableStateFlow(1) val b = MutableStateFlow(10) // между чтениями значения могли измениться val sum = a.value + b.value println("несогласованный снимок возможен: $sum") // несогласованный снимок возможен: 11 }Два связанных значения нужно держать в одном
StateFlowкак единый объект состояния.
collectнаStateFlowникогда не завершается. Это бесконечный поток:toList()на нём повиснет,onCompletionне вызовется сам. Нуженtake(n),first()или отмена корутины.Подписчик, созданный до
emit, может его пропустить:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val events = MutableSharedFlow<Int>() launch { events.collect { println("не увидит: $it") } } events.emit(1) // launch ещё не успел подписаться delay(20) coroutineContext.cancelChildren() // (ничего не напечатано) }
launchвозвращается до начала тела, поэтому подписка ещё не оформлена. В тестах для этого используютevents.subscriptionCount.first { it > 0 }, в продакшене -replay ≥ 1.
asStateFlow()не копирует состояние - это обёртка только для чтения над тем же объектом. Если внешний код получит доступ к исходномуMutableStateFlow, он сможет его менять.Ошибка в подписчике отменяет его подписку, а не поток. Исключение в теле
collectуходит в корутину-подписчик и может уронить её scope, при этомStateFlowпродолжит жить. Оборачивайте телоcollectвtry/catch, если ошибки ожидаемы.
Далее: Channels