JavaRush /Курси /Kotlin SELF /Flow як cold‑потік: flow { emit }, collect і відмінність ...

Flow як cold‑потік: flow { emit }, collect і відмінність від Channel

Kotlin SELF
Рівень 54 , Лекція 2
Відкрита

1. Вступ

Коли ви вперше бачите Flow, мозок чесно запитує: «Навіщо ще одна штука, якщо в нас уже є Channel? Ми ж щойно навчилися надсилати, отримувати, закривати — навіть буфер вмикати!». Це нормальна реакція. Програмістам узагалі властиво думати, що нових сутностей у світі вже достатньо — особливо в понеділок зранку.

Різниця в тому, що саме ви моделюєте. Channel — це насамперед комунікація: корутини «перекидають» одна одній значення, як мʼячик. А Flow — це опис процесу отримання значень: «ось як значення зʼявлятимуться, і ось як їх можна забирати». У документації Kotlin це сформульовано дуже прямо: Flow виробляє значення лише тоді, коли його активно збирають, а Channel дозволяє корутинам надсилати й отримувати значення, причому кожне значення дістається рівно одному отримувачу.

Щоб відчути різницю, зручна побутова аналогія. Channel — це як поштова скринька (або черга в ЦНАП): хтось кладе листи, хтось забирає, листи накопичуються (якщо є місце), і важливо «закрити приймання», щоб усі зрозуміли: нових листів не буде. Flow — це радше як сценарій екскурсії: «спочатку ми покажемо зал № 1, потім зал № 2…», але екскурсія насправді почнеться лише тоді, коли прийдуть відвідувачі й скажуть: «Давайте, ведіть!».

2. Що таке Flow<T>: опис потоку, а не контейнер із даними

Коли ми кажемо val list = listOf(1, 2, 3), ми тримаємо в руках дані (контейнер уже заповнений). Коли ми кажемо val channel = Channel<Int>(), ми тримаємо в руках засіб комунікації: туди можуть щось надіслати, звідти можуть щось отримати. А от val f: Flow<Int> = ... — це радше домовленість: «ось звідки й як приходитимуть числа».

І тут важливо психологічно перемкнутися. Flow — це не «черга значень», у якій вони вже лежать і чекають на вас. Це більше схоже на функцію: ви описуєте, як вони зʼявлятимуться, а виконання почнеться пізніше.

У Flow є два ключові слова, які сьогодні потрібно «приручити»:

  • flow { ... }будівник (builder), який створює Flow.
  • collect { ... }збирач (термінальна операція), який запускає виконання потоку й приймає значення.

І ще один принцип, який варто тримати в голові від самого початку: без collect «нічого не відбувається». Це не метафора й не філософія — це буквальна поведінка Flow: доки ви не зібрали значення, потік не стартував.

3. Мінімальний Flow: flow { emit() } і collect { ... }

Зараз ми напишемо найменший приклад, який можна назвати «привіт, Flow». У ньому не буде жодних операторів перетворення (це вже наступна лекція) — лише джерело значень і збирач.

Зверніть увагу на структуру: collectsuspend, тож його потрібно викликати в корутинному контексті (наприклад, усередині runBlocking, який ми вже використовували в лекціях про корутини).


import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.collect

fun main() = runBlocking {
    val numbers = flow {
        emit(1)
        emit(2)
        emit(3)
    }

    numbers.collect { v ->
        println("v=$v")              // v=1, потім v=2, потім v=3
    }
}

Тут важливо побачити два «шари».

Усередині flow { ... } ми описуємо, як значення зʼявлятимуться: «видай 1, потім 2, потім 3».

А всередині collect { ... } ми описуємо, що робити з кожним значенням, коли воно зʼявиться: «надрукуй».

Жодного close() ви не бачите — тому що у Flow завершення виглядає інакше: потік закінчується, коли завершується код у flow { ... }. Тобто «кінець потоку» — це просто «ми дійшли до кінця блока».

4. Cold‑поведінка Flow на практиці

На попередніх прикладах може здатися, що Flow — це просто «ще один спосіб перебрати 1, 2, 3». Але справжня сила починається, коли значення зʼявляються не миттєво, а з часом. І тут Flow природно дружить із корутинами: ви можете робити delay між emit.

delay усередині flow: значення зʼявляються з часом

Це гарне місце, щоб відчути: Flow — це послідовність значень, розтягнута в часі. Водночас збирається вона послідовно (у межах одного collect).

import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.collect

fun main() = runBlocking {
    val ticks = flow {
        emit("тик")
        delay(200)
        emit("так")
        delay(200)
        emit("готово")
    }

    ticks.collect { word ->
        println(word)                // тик, потім так, потім готово
    }
}

Про це можна думати як про «маленький генератор»: він видає значення, потім чекає, потім видає ще одне. І все це відбувається лише тому, що хтось викликав collect.

Чому Flow називають cold‑потоком

Слово cold (холодний) звучить так, ніби потік образився на вас і відмовляється гріти батареї. Насправді сенс простіший: доки немає споживача — немає й виробництва. Якщо ніхто не збирає значення, flow { ... } навіть не почне виконуватися.

Покажемо це максимально чесно: додамо println усередину flow { ... }, щоб побачити момент запуску.

import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.collect

fun main() = runBlocking {
    val f = flow {
        println("Flow стартував")      // побачимо лише під час collect
        emit(10)
    }

    println("До collect")              // До collect
    f.collect { println("v=$it") }     // Flow стартував; v=10
}

Якщо ви запустите приклад, то побачите, що рядок "Flow стартував" зʼявиться після "До collect". Тобто справді під час collect, а не в момент створення f.

І ось це — центральна ідея. У Channel ви можете почати надсилати значення «в нікуди» (а потім хтось їх забере), якщо є буфер або якщо відправник чекатиме. А Flow — це не черга й не «місце зберігання». Сам по собі він нічого не «накопичує»: він виконується.

Два collect — два запуски

Тепер зробимо маленький експеримент: зберемо один і той самий Flow двічі. Якщо ви звикли до колекцій, може виникнути очікування: «Ну зібрали й зібрали, вдруге просто знову пройдемося тими самими даними». Але в cold‑потоку інша логіка: кожен collect запускає виконання заново.

import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.collect

fun main() = runBlocking {
    val f = flow {
        println("Виробляємо...")
        emit(1)
    }

    f.collect { println("перший collect: $it") }
    // Виробляємо...
    // перший collect: 1

    f.collect { println("другий collect: $it") }
    // Виробляємо...
    // другий collect: 1
}

Тут «Виробляємо…» виводиться двічі — у цьому й практичний сенс cold‑потоку.

Корисна думка для закріплення: Flow ближчий до «рецепта приготування» (його можна реалізувати багато разів), а Channel — до «каструлі супу» (туди налили — і далі хто встиг, той і зʼїв).

5. Flow і Channel: обираємо правильну модель

Зараз буде важливий шматок: ми не просто перелічимо відмінності, а привʼяжемо їх до того, яке завдання ви розвʼязуєте. Тут зручно тримати в голові «модель світу».

Channel моделює чергу повідомлень між корутинами: «хтось надіслав, хтось отримав». У документації Kotlin це сформульовано так: канал дозволяє корутинам надсилати й отримувати значення, і кожне значення доставляється рівно одній корутині. Це прямо натякає на розподіл роботи між кількома споживачами.

Flow моделює потік значень у часі: «джерело видає значення, а споживач їх збирає». І ключовий момент: Flow видає значення лише тоді, коли його збирають. Тому він природно виглядає як «джерело → збір».

Щоб було простіше порівнювати без внутрішнього болю, зведемо основні відмінності в таблицю:

Тема Channel Flow
Головна ідея Комунікація: передати повідомлення між корутинами Потік значень: описати, як значення зʼявляються в часі
Старт роботи Відправник може почати send одразу (але може чекати) Джерело запускається лише під час collect (cold)
«Кінець даних» Зазвичай потрібен close() як сигнал завершення Завершення = кінець блока flow { ... }
Кілька споживачів Одне значення отримає лише один отримувач Кожен collect запускає потік заново (у загальному випадку)
На що схоже Черга/пошта/стрічка задач Генератор/рецепт/сценарій видачі

І ось типовий практичний критерій вибору (без фанатизму). Якщо ви проєктуєте систему «є воркери, треба розподіляти завдання між ними», канал зазвичай відчувається природніше. Якщо ви проєктуєте систему «є джерело даних, я хочу послідовно отримати значення й обробити їх», Flow зазвичай читається простіше й «чесніше» описує намір.

Чому не можна робити send у Flow (і чому це нормально)

Іноді новачки намагаються використовувати Flow як канал: «А як мені тепер зробити send?». Відповідь проста: ніяк. І це не тому, що розробники Kotlin «забули додати метод», а тому, що це інший інструмент — із іншим призначенням.

Flow — це потік із джерела до споживача. У нього немає спільної «скриньки», куди можна з різних місць накидати повідомлення. Це свідоме обмеження: воно допомагає тримати архітектуру чистішою. Джерело описано в одному місці, збір — в іншому. І ви не отримуєте «випадкових» відправлень із різних кутків програми, які потім складно відстежити.

Якщо вам потрібна комунікація «багато корутин надсилають, багато корутин отримують» — це територія каналів. Якщо вам потрібна модель «значення приходять у часі, я їх збираю» — Flow звучить як рідний.

6. Міні‑монітор подій: Flow у консольному застосунку

Щоб приклади не жили окремим життям «у вакуумі», давайте продовжимо розвивати маленький застосунок, який ми вже подумки використовували з каналами: консольний монітор подій. Раніше ми могли б передавати події через Channel<String>, а тепер зробимо версію, у якій джерело подій — це Flow<String>.

Ми не робимо мережу, файли чи щось «зовнішнє» (сьогодні це зайве). Ми просто імітуємо події: «застосунок стартував», «зʼєднання встановлено», «працюємо», «завершення».

Функція, яка повертає Flow

Почнемо з маленької функції, яка створює потік. Тут важливо звикнути: на відміну від Channel, де ви створюєте обʼєкт і потім у нього «заштовхуєте» значення, у Flow ви частіше пишете функцію «як отримувати значення».

import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

fun buildStatusFlow(): Flow<String> = flow {
    emit("START")
    delay(150)
    emit("CONNECTED")
    delay(150)
    emit("RUNNING")
    delay(150)
    emit("STOP")
}

Тут ми акуратно написали «рецепт» статусів. Він нічого не друкує й нікого не запускає — він просто повертає Flow<String>.

collect у main: запускаємо й друкуємо

Тепер використаємо це в main. Так, знову runBlocking: ми в консольному застосунку, а collectsuspend, отже нам потрібен корутинний контекст.

import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.collect

fun main() = runBlocking {
    val statusFlow = buildStatusFlow()

    statusFlow.collect { status ->
        println("статус=$status")
        // статус=START, потім CONNECTED, RUNNING, STOP
    }
}

Зверніть увагу на важливу дисципліну: потік описує значення, а main вирішує, що з ними робити. Це робить код передбачуванішим: джерело не друкує потайки (якщо ви самі цього не захочете), і ви не втрачаєте контроль.

Демонстрація cold‑поведінки: зібрати один потік двічі

І щоб остаточно закарбувати в памʼяті cold‑природу, можна зробити маленький «режим повтору»: зібрати один і той самий потік двічі.

import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.collect

fun main() = runBlocking {
    val statusFlow = buildStatusFlow()

    println("Перший запуск:")
    statusFlow.collect { println(it) }     // START ... STOP

    println("Другий запуск:")
    statusFlow.collect { println(it) }     // START ... STOP (заново)
}

Якщо ви побачили, що статуси повторилися — вітаю, ви щойно відчули cold‑потік, а не просто прочитали визначення.

7. Типові помилки під час першого знайомства з Flow

Помилка № 1: чекати, що Flow почне працювати сам по собі.
Дуже часта пастка — написати val f = flow { emit(1) } і очікувати: «Ну він же створений, значить десь там 1 уже пішла». Ні: cold‑потік не почне виконуватися, доки ви явно не викличете collect. Якщо ви бачите, що «нічого не виводиться», насамперед перевірте, чи є збирач.

Помилка № 2: плутати Flow з чергою повідомлень і шукати send.
Якщо ви починаєте механічно «перекладати» свій код із Channel на Flow, ви швидко впираєтеся в питання: «А куди надсилати?». Відповідь: Flow не про «надсилати», а про «видавати». Зазвичай це означає, що замість «створили канал і роздали його всім» ви пишете функцію, яка повертає Flow, і описуєте виробництво значень усередині flow { ... }.

Помилка № 3: забувати, що collectsuspend, і намагатися викликати його зі звичайної функції.
Це виглядає як типова помилка компілятора: «Suspend function 'collect' should be called only from a coroutine or another suspend function». Лікується рівно тим, що ви вже вмієте: або викликаєте collect усередині runBlocking (у консольному main), або робите вашу функцію suspend і викликаєте її з корутини.

Помилка № 4: дивуватися, що два collect роблять роботу двічі.
Новачки часто сприймають Flow як «список, тільки асинхронний». Тому повторний collect здається «повторним читанням тих самих даних». Насправді це повторний запуск. Це корисно (можна повторно використовувати потік як рецепт), але якщо ви цього не очікували, то можете випадково виконати дорогу роботу двічі. На старті курсу достатньо просто памʼятати правило: cold‑потік зазвичай перезапускається на кожен збір.

Помилка № 5: змішувати відповідальність джерела та споживача.
Коли всередині flow { ... } починають друкувати в консоль, змінювати глобальні змінні й робити «пів застосунку», код швидко стає мутним: незрозуміло, хто керує виведенням і коли що відбувається. На перших кроках краще тримати звичну дисципліну: джерело робить emit, споживач робить collect і вирішує, що виводити та як реагувати. Так поведінка буде зрозумілішою — особливо коли ви пізніше почнете збирати довші конвеєри обробки.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ