33 KiB
title: Flow description: Холодный асинхронный поток значений: построение, операторы, контекст, обработка ошибок и backpressure tags:
- kotlin
- coroutines
- flow
Flow<T> - асинхронная последовательность значений. Если suspend-функция возвращает одно значение, то Flow - много, причём каждое может приходить с задержкой.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun temperatures(): Flow<Int> = flow {
for (t in listOf(20, 22, 25)) {
delay(20) // внутри flow можно приостанавливаться
emit(t)
}
}
fun main() = runBlocking {
temperatures().collect { println("температура: $it") }
// температура: 20
// температура: 22
// температура: 25
}
Сравните с List: список нужно вычислить целиком, прежде чем вернуть. Flow отдаёт значения по мере готовности.
Холодный поток
Flow ничего не делает, пока его не начнут собирать. Каждый вызов collect запускает тело заново.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
var starts = 0
val f = flow {
starts++
emit(1)
emit(2)
}
println("создан, запусков: $starts") // создан, запусков: 0
f.collect { }
f.collect { }
println("после двух collect: $starts") // после двух collect: 2
}
Это главное свойство: Flow - описание вычисления, а не запущенный процесс. Горячие потоки, которые работают независимо от подписчиков, - это StateFlow и SharedFlow. Подробнее: StateFlow и SharedFlow
Способы создать Flow
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flowOf(1, 2, 3).collect { print("$it ") }
println() // 1 2 3
listOf("a", "b").asFlow().collect { print("$it ") }
println() // a b
(1..3).asFlow().collect { print("$it ") }
println() // 1 2 3
flow {
emit("из билдера")
emitAll(flowOf("и", "вложенного"))
}.collect { print("$it ") }
println() // из билдера и вложенного
}
Для колбэчных источников есть callbackFlow - он же мост от слушателей к Flow:
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
class Sensor {
private var listener: ((Int) -> Unit)? = null
fun subscribe(l: (Int) -> Unit) { listener = l }
fun unsubscribe() { listener = null; println("слушатель снят") }
fun fire(value: Int) = listener?.invoke(value)
}
fun Sensor.readings(): Flow<Int> = callbackFlow {
subscribe { value -> trySend(value) }
awaitClose { unsubscribe() } // вызовется при отмене collect
}
fun main() = runBlocking {
val sensor = Sensor()
val job = launch {
sensor.readings().collect { println("показание: $it") }
}
delay(20)
sensor.fire(42)
sensor.fire(43)
delay(20)
job.cancelAndJoin()
// показание: 42
// показание: 43
// слушатель снят
}
awaitClose обязателен: без него callbackFlow бросит IllegalStateException, потому что иначе слушатель остался бы висеть.
Промежуточные операторы
Операторы ленивые: они только оборачивают поток, не запуская его.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val result = (1..10).asFlow()
.filter { it % 2 == 0 }
.map { it * it }
.take(3)
.toList()
println(result) // [4, 16, 36]
}
Главное отличие от операторов коллекций - внутри можно приостанавливаться:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
suspend fun enrich(id: Int): String { delay(10); return "запись $id" }
fun main() = runBlocking {
flowOf(1, 2, 3)
.map { enrich(it) } // suspend внутри map
.collect { println(it) }
// запись 1
// запись 2
// запись 3
}
| Оператор | Что делает |
|---|---|
map, filter |
как у коллекций, но с suspend внутри |
transform |
произвольное число emit на элемент |
take, drop, takeWhile |
ограничение по количеству |
onEach |
побочный эффект без изменения значений |
withIndex, runningFold, scan |
накопление |
distinctUntilChanged |
пропуск подряд идущих дублей |
debounce, sample |
фильтрация по времени |
flatMapConcat, flatMapMerge, flatMapLatest |
поток из потоков |
zip, combine |
объединение двух потоков |
onStart, onCompletion, catch |
обработка жизненного цикла |
transform - самый общий:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flowOf(1, 2, 3)
.transform { value ->
emit("начало $value")
emit("конец $value")
}
.collect { print("$it; ") }
println()
// начало 1; конец 1; начало 2; конец 2; начало 3; конец 3;
}
Терминальные операторы
Только они запускают поток, и все они suspend:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val f = flowOf(1, 2, 3, 4)
println(f.toList()) // [1, 2, 3, 4]
println(f.first()) // 1
println(f.firstOrNull { it > 10 }) // null
println(f.lastOrNull()) // 4
println(f.count()) // 4
println(f.reduce { a, b -> a + b }) // 10
println(f.fold(100) { a, b -> a + b }) // 110
println(f.toSet()) // [1, 2, 3, 4]
println(f.filter { it == 2 }.single()) // 2
}
first() отменяет поток сразу после первого значения - этого достаточно, чтобы остановить бесконечный источник:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
var emitted = 0
val infinite = flow {
var i = 0
while (true) {
emitted++
emit(i++)
}
}
println(infinite.first()) // 0
println("излучено значений: $emitted") // излучено значений: 1
}
Контекст и flowOn
По умолчанию Flow выполняется в контексте того, кто его собирает - это называется context preservation:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flow {
println("flow на: ${Thread.currentThread().name}")
emit(1)
}.collect {
println("collect на: ${Thread.currentThread().name}")
}
// flow на: main
// collect на: main
}
Сменить контекст производителя можно только через flowOn:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flow {
val onPool = Thread.currentThread().name.contains("DefaultDispatcher")
println("flow в пуле: $onPool")
emit(1)
}
.flowOn(Dispatchers.IO)
.collect {
println("collect на: ${Thread.currentThread().name}")
}
// flow в пуле: true
// collect на: main
}
flowOn действует на всё, что выше него по цепочке:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flowOf(1, 2)
.map { it * 10 } // в IO
.flowOn(Dispatchers.IO)
.map { it + 1 } // в main
.collect { print("$it ") }
println() // 11 21
}
Менять контекст внутри flow { } через withContext запрещено:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val broken = flow {
withContext(Dispatchers.IO) { emit(1) } // нарушение контракта
}
val error = runCatching { broken.collect { } }.exceptionOrNull()
println(error is IllegalStateException) // true
println(error?.message?.contains("Flow invariant is violated")) // true
}
Причина запрета - предсказуемость: собирающий код должен быть уверен, что collect вызывается в его контексте (например, в UI-потоке). Для смены контекста есть только flowOn.
Обработка ошибок
try/catch работает, но правильнее оператор catch:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flow {
emit(1)
throw IllegalStateException("источник упал")
}
.catch { e -> println("перехвачено: ${e.message}") }
.collect { println("значение: $it") }
// значение: 1
// перехвачено: источник упал
}
catch ловит ошибки только выше себя - это называется прозрачность исключений:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val error = runCatching {
flowOf(1)
.catch { println("не сработает") }
.collect { throw IllegalStateException("ошибка в collect") }
}.exceptionOrNull()
println("ошибка collect прошла мимо catch: ${error?.message}")
// ошибка collect прошла мимо catch: ошибка в collect
}
Чтобы ловить и ошибки потребителя, нужен onEach + catch в таком порядке:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flowOf(1, 2)
.onEach { if (it == 2) error("обработка упала на $it") }
.catch { e -> println("перехвачено: ${e.message}") }
.collect { println("обработано: $it") }
// обработано: 1
// перехвачено: обработка упала на 2
}
catch может подменить поток значениями по умолчанию:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flow<String> { error("сеть недоступна") }
.catch { emit("данные из кеша") }
.collect { println(it) }
// данные из кеша
}
retry и повторные попытки
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
var attempt = 0
flow {
attempt++
if (attempt < 3) error("попытка $attempt не удалась")
emit("успех на попытке $attempt")
}
.retry(3) { e ->
println("ретрай после: ${e.message}")
delay(10)
true
}
.collect { println(it) }
// ретрай после: попытка 1 не удалась
// ретрай после: попытка 2 не удалась
// успех на попытке 3
}
retryWhen даёт доступ к номеру попытки - это нужно для экспоненциальной задержки:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
var calls = 0
flow {
calls++
if (calls < 3) error("сбой")
emit("готово")
}
.retryWhen { _, attempt ->
if (attempt >= 3) false
else {
delay(10L * (attempt + 1)) // 10, 20, 30 мс
println("попытка ${attempt + 1}")
true
}
}
.collect { println(it) }
// попытка 1
// попытка 2
// готово
}
Жизненный цикл: onStart, onCompletion
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flowOf(1, 2)
.onStart { println("начали") }
.onEach { println("значение $it") }
.onCompletion { cause -> println("завершили, ошибка: $cause") }
.collect { }
// начали
// значение 1
// значение 2
// завершили, ошибка: null
}
onCompletion вызывается всегда - при успехе, ошибке и отмене, причём получает причину:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flow {
emit(1)
error("упало")
}
.onCompletion { cause -> println("причина: ${cause?.message}") }
.catch { }
.collect { }
// причина: упало
}
onStart может излучать значения - это способ показать «загрузка...» до первого реального элемента:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
flow { delay(20); emit("данные") }
.onStart { emit("загрузка...") }
.collect { println(it) }
// загрузка...
// данные
}
buffer: производитель и потребитель параллельно
По умолчанию производитель ждёт потребителя:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.system.measureTimeMillis
fun source() = flow {
repeat(3) {
delay(20) // производство
emit(it)
}
}
fun main() = runBlocking {
val sequential = measureTimeMillis {
source().collect { delay(20) } // потребление
}
val buffered = measureTimeMillis {
source().buffer().collect { delay(20) }
}
println("без буфера около 120 мс: ${sequential >= 110}")
println("с буфером быстрее: ${buffered < sequential}")
// без буфера около 120 мс: true
// с буфером быстрее: true
}
Без буфера шаги складываются: 3 × (20 + 20) = 120 мс. С буфером производство идёт параллельно с потреблением, и общее время приближается к максимуму из двух конвейеров.
conflate и collectLatest
Когда важны только свежие значения, промежуточные можно отбрасывать:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun fast() = flow {
repeat(5) {
delay(10)
emit(it)
}
}
fun main() = runBlocking {
val all = mutableListOf<Int>()
fast().collect { delay(40); all += it }
val conflated = mutableListOf<Int>()
fast().conflate().collect { delay(40); conflated += it }
println("без conflate получено всё: ${all.size == 5}")
println("с conflate пропущены промежуточные: ${conflated.size < 5}")
println("последнее значение всегда доходит: ${conflated.last() == 4}")
// без conflate получено всё: true
// с conflate пропущены промежуточные: true
// последнее значение всегда доходит: true
}
collectLatest работает иначе: он не пропускает значения, а отменяет обработку предыдущего:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val completed = mutableListOf<Int>()
flow {
repeat(3) { delay(10); emit(it) }
}.collectLatest { value ->
delay(25) // не успеет для первых двух
completed += value
}
println("доработал только последний: $completed") // доработал только последний: [2]
}
| Стратегия | Поведение при быстром производителе |
|---|---|
| по умолчанию | производитель ждёт потребителя |
buffer(n) |
очередь на n элементов, затем ожидание |
buffer(onBufferOverflow = DROP_OLDEST) |
выбрасывает старые |
conflate() |
хранит только последнее непрочитанное |
collectLatest |
отменяет обработку устаревшего значения |
debounce(ms) |
пропускает только после паузы |
sample(ms) |
берёт последнее раз в интервал |
Объединение потоков
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val letters = flowOf("a", "b", "c")
val numbers = flowOf(1, 2, 3)
println(letters.zip(numbers) { l, n -> "$l$n" }.toList()) // [a1, b2, c3]
val slow = flow { emit(1); delay(30); emit(2) }
val fast2 = flow { emit("x"); delay(10); emit("y") }
println(slow.combine(fast2) { a, b -> "$a$b" }.toList())
// [1x, 1y, 2y]
}
zip ждёт пару - элементов будет столько, сколько в коротком потоке. combine излучает при каждом изменении любого из потоков, используя последние известные значения другого. Для UI (например, «форма валидна, если валидны все поля») нужен combine.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val concatenated = flowOf(1, 2).flatMapConcat { n ->
flow { emit("$n-a"); delay(10); emit("$n-b") }
}.toList()
println(concatenated) // [1-a, 1-b, 2-a, 2-b]
val latest = flowOf(1, 2, 3).flatMapLatest { n ->
flow { delay(20); emit("результат $n") }
}.toList()
println(latest) // [результат 3]
}
flatMapLatest - основной оператор для поиска по вводу: новый запрос отменяет предыдущий.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
suspend fun search(query: String): List<String> {
delay(20)
return listOf("результат для «$query»")
}
fun main() = runBlocking {
val input = flow {
emit("к"); delay(5)
emit("ко"); delay(5)
emit("кот"); delay(100) // пользователь остановился
emit("кота")
}
input
.debounce(30) // ждём паузу в наборе
.filter { it.length >= 2 }
.flatMapLatest { search(it).asFlow() }
.collect { println(it) }
// результат для «кот»
// результат для «кота»
}
Это каноничный конвейер: debounce отсекает промежуточные нажатия, flatMapLatest отменяет устаревший запрос.
graph LR
A["ввод: к, ко, кот"] --> B["debounce(30)"]
B --> C["только 'кот'<br/>после паузы"]
C --> D["flatMapLatest"]
D --> E["запрос к API"]
E --> F["collect: результаты"]
D -.->|"пришёл новый ввод"| G["отмена<br/>предыдущего запроса"]
Flow как возвращаемый тип
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class OrderRepository {
// не suspend: Flow холодный, вызов ничего не запускает
fun observeOrders(): Flow<String> = flow {
repeat(3) { i ->
delay(10)
emit("заказ $i")
}
}.flowOn(Dispatchers.IO) // производитель всегда в IO
}
fun main() = runBlocking {
OrderRepository().observeOrders().collect { println(it) }
// заказ 0
// заказ 1
// заказ 2
}
Правила проектирования: функция, возвращающая Flow, не должна быть suspend (создание потока мгновенно), а flowOn ставится внутри - вызывающий не обязан знать, где работает источник.
[!info] Отличия от Java
Java Kotlin Stream<T>(синхронный, одноразовый)Sequence<T>Flowиз Reactive StreamsFlow<T>RxJava Observable/FlowableFlow<T>Project Reactor Flux<T>Flow<T>Subscriber.request(n)приостановка emitsubscribeOn/observeOnflowOnonErrorResumecatchBlockingQueue+ потокChannel
java.util.stream.Stream- это не аналог.Streamсинхронный:stream.map(f)не может ждать ответа сети, а терминальная операция блокирует поток до конца обработки. Прямой аналогStreamв Kotlin -Sequence.Flowотличается тем, что внутри каждого оператора разрешена приостановка. Подробнее: SequenceReactive Streams решают ту же задачу, но сложнее. RxJava и Reactor построены на коллбэках (
onNext,onError,onComplete) и требуют явного управления давлением черезrequest(n). ВFlowbackpressure получается бесплатно:emit- этоsuspend-функция, поэтому если потребитель не готов, производитель просто приостанавливается:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { var produced = 0 flow { repeat(100) { produced++; emit(it) } }.take(3).collect { } println("произведено ровно столько, сколько взяли: $produced") // произведено ровно столько, сколько взяли: 3 }Никакого
request(n)- producer сам останавливается наemit.Стектрейсы и отладка. В RxJava стектрейс ошибки состоит из кадров библиотеки, а место в бизнес-логике приходится искать по
onAssembly-хукам.Flow- обычныеsuspend-функции, поэтому исключение приходит с кадрами вашего кода, аtry/catch,forиreturnработают внутри операторов естественно.Количество операторов. В RxJava их несколько сотен (
flatMapIterable,concatMapDelayError,switchIfEmpty...), потому что каждый сценарий требует отдельного оператора - внутри цепочки нельзя писать обычный код. ВFlowоператоров несколько десятков: остальное выражается черезtransformи обычные конструкции языка.Отмена подписки. В Rx нужно хранить
Disposableи не забыть вызватьdispose()- классический источник утечек, для борьбы с которым придумалиCompositeDisposable.Flowсобирается внутри корутины, поэтому отмена области автоматически прекращает сбор:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val job = launch { flow { try { repeat(100) { delay(10); emit(it) } } finally { println("источник закрыт") } }.collect { } } delay(30) job.cancelAndJoin() // источник закрыт }Context preservation - то, чего нет в Rx. В RxJava
observeOnможно поставить в любом месте, и понять, на каком потоке выполнитсяsubscribe, можно только прочитав всю цепочку.Flowгарантирует, чтоcollectвсегда выполняется в контексте собирающего, аwithContextвнутриflow { }запрещён на уровне рантайма.Сравнить: Многопоточность в Java, Функции в Java
[!warning] Подводные камни
Flowне запустится без терминального оператора:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { var side = 0 flowOf(1, 2, 3).onEach { side++ } // collect не вызван delay(20) println("побочных эффектов: $side") // побочных эффектов: 0 }Компилятор предупреждает о неиспользованном результате, но не всегда - например, если значение присвоено переменной.
withContextвнутриflow { }бросает исключение в рантайме:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val error = runCatching { flow { withContext(Dispatchers.IO) { emit(1) } }.collect { } }.exceptionOrNull() println(error?.message?.contains("Flow invariant is violated")) // true }Это ошибка времени выполнения, а не компиляции - её легко пропустить в редкой ветке кода. Используйте
flowOn.
flowOnдействует только выше по цепочке.flow.map { a }.flowOn(IO).map { b }выполнитaв IO, аb- в контексте сборщика. Порядок операторов меняет смысл.
catchне ловит ошибкиcollect:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val error = runCatching { flowOf(1).catch { println("не вызовется") }.collect { error("из collect") } }.exceptionOrNull() println("ошибка прошла мимо: ${error?.message}") // ошибка прошла мимо: из collect }Перенесите логику в
onEachпередcatch.
emitиз другой корутины запрещён:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val broken = flow { coroutineScope { launch { emit(1) } // emit в другой корутине } } val error = runCatching { broken.collect { } }.exceptionOrNull() println(error != null) // true }Сообщение - про нарушение инварианта. Для излучения из нескольких источников нужен
channelFlowилиcallbackFlow.
callbackFlowбезawaitCloseпадает:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val broken = callbackFlow<Int> { trySend(1) // нет awaitClose } val error = runCatching { broken.collect { } }.exceptionOrNull() println(error is IllegalStateException) // true }Это защита от утечки слушателя: без
awaitCloseбиблиотека не знает, когда отписываться.
trySendмолча теряет значения при полном буфере. УcallbackFlowбуфер по умолчанию 64 элемента;trySendвозвращаетChannelResult, который обычно игнорируют. Если источник быстрее потребителя, часть событий исчезнет - проверяйте результат или задавайтеbuffer(Channel.UNLIMITED)осознанно.Повторный
collectзапускает поток заново. ЕслиFlowделает сетевой запрос, дваcollect- это два запроса. Для разделения результата между подписчиками нуженshareIn/stateIn. Подробнее: StateFlow и SharedFlow
Flowбез буфера последователен, а не параллелен:import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlin.system.measureTimeMillis fun main() = runBlocking { val t = measureTimeMillis { flowOf(1, 2, 3).map { delay(30); it }.collect { } } println("последовательно, около 90 мс: ${t >= 85}") // последовательно, около 90 мс: true }
mapсsuspendвнутри обрабатывает элементы по одному. Для параллельной обработки нуженflatMapMergeсconcurrencyилиmap { async { } }поверх списка.
conflateиcollectLatestтеряют данные - это их цель. Для UI-состояния это правильно, для обработки платежей или записи в журнал - нет. Убедитесь, что потеря промежуточных значений допустима.
debounceзадерживает даже последнее значение. Пользователь перестал печатать - результат появится только через указанный интервал. Слишком большое значение ощущается как лаг интерфейса.Бесконечный
FlowвtoList()повиснет. Терминальные операторы, собирающие всё (toList,count,fold), на бесконечном источнике не завершатся никогда. Нуженtake(n)илиfirst().
SharingStarted.EagerlyвstateInдержит источник активным всегда - типичная утечка при переносе холодногоFlowв горячий. Подробнее: StateFlow и SharedFlowИсключение в
onCompletionне перехватывается предыдущимcatch.onCompletion- неfinally: он не может подавить ошибку, а своя ошибка внутри него уйдёт потребителю.
first()отменяет источник. ДляcallbackFlowэто значит, что слушатель будет снят сразу после первого значения - если вы ожидали продолжения подписки, её нет.
Далее: StateFlow и SharedFlow