Files
obsidian/Kotlin/Корутины/StateFlow и SharedFlow.md
T

29 KiB
Raw Blame History


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 (паттерн) StateFlow
PropertyChangeListener StateFlow
RxJava BehaviorSubject StateFlow
RxJava PublishSubject SharedFlow(replay = 0)
RxJava ReplaySubject(n) SharedFlow(replay = n)
AtomicReference + слушатели MutableStateFlow
CopyOnWriteArrayList<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