Холодный Flow в Android: ловушка для Compose-разработчика

Kotlin Flow — мощный инструмент для работы с асинхронными потоками данных. Но у него есть особенность, которая регулярно ставит в тупик Android-разработчиков: холодный поток ленив. Он не генерирует данные, пока на него никто не подписан. Звучит разумно — зачем тратить ресурсы впустую? Но в связке с Jetpack Compose эта бережливость превращается в ловушку.

Эксперимент: репозиторий с холодным Flow

Представим приложение, которое показывает советы дня. Репозиторий отдаёт их через flow {}:

class TipRepository {
    private val tips = listOf(
        "Пейте воду натощак",
        "Делайте перерывы каждый час",
        "Гуляйте перед сном",
        "Читайте по 20 минут в день",
        "Ешьте овощи каждый день"
    )

    fun getTipsFlow(): Flow<String> = flow {
        for (tip in tips) {
            emit(tip)
            delay(4000)
        }
    }
}

Каждые четыре секунды поток выдаёт новый совет. ViewModel просто прокидывает его дальше:

class TipViewModel : ViewModel() {
    val tip: Flow<String> = TipRepository().getTipsFlow()
}

В Composable подписываемся через collectAsState():

@Composable
fun TipScreen(viewModel: TipViewModel = viewModel()) {
    val currentTip by viewModel.tip.collectAsState(initial = "Загрузка...")
    Text(text = currentTip, fontSize = 20.sp)
}

Запускаем. На экране появляются советы, сменяя друг друга. Всё работает.

А теперь поверните телефон.

Что пошло не так

После поворота экрана советы начинаются с первого — «Пейте воду натощак». Даже если вы дождались четвёртого совета, поворот экрана откатывает всё к началу.

Почему? Потому что холодный поток — это рецепт, а не блюдо. Каждый вызов collect() готовит его заново. При повороте экрана Compose пересоздаёт Composable-функцию, та вызывает collectAsState(), который внутри делает collect(), — и поток стартует с нуля.

Эту же проблему можно увидеть и без поворота экрана. Достаточно любой рекомпозиции, которая заставит Composable пересоздаться. Например, если TipScreen находится внутри условия:

if (showTips) {
    TipScreen()
}

Каждое переключение showTips из false в true перезапустит поток.

Корень проблемы

Дело не в баге. Холодный Flow спроектирован именно так. Он идеален для ситуаций, где каждая подписка должна начинать с чистого листа: разовый сетевой запрос, чтение файла, выборка из базы данных. Но для UI, который живёт и пересоздаётся, нужен источник данных, существующий независимо от подписчиков.

Если холодный поток — это новостной архив, который каждый читатель листает с первой страницы, то нам нужна прямая трансляция: поток, который вещает сам по себе, а подписчик подключается к нему в любой момент и получает последний выпуск.

В терминологии Kotlin Flow такая трансляция называется горячим потоком. Его представители — StateFlow и SharedFlow. Они хранят данные в памяти и не зависят от того, слушает их кто-то или нет.

Путь к решению

Обычно холодный поток создаётся в слое данных — репозитории. Это правильно: репозиторий не должен знать о жизненных циклах экранов. А вот ViewModel — мост между данными и интерфейсом — берёт на себя роль «подогревателя». Именно здесь холодный поток превращается в горячий с помощью оператора stateIn():

class TipViewModel : ViewModel() {
    val tip: StateFlow<String> = TipRepository()
        .getTipsFlow()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = "Загрузка..."
        )
}

Теперь tip — это StateFlow, который живёт в ViewModel. Поворот экрана уничтожает Activity, но не ViewModel. Composable пересоздаётся, заново подписывается на tip и мгновенно получает последнее значение — без перезапуска.

Подробный разбор stateIn() и стратегий SharingStarted — тема для отдельной статьи. Пока достаточно запомнить принцип: холодный поток создаёт данные, горячий — хранит их для UI.

Правило, которое спасёт нервы

Если вы пишете ViewModel и думаете, какой тип отдать в UI — Flow<T> или StateFlow<T>, — почти всегда выбирайте StateFlow<T>. Холодный Flow из ViewModel в Composable — это почти гарантированный источник багов, связанных с перезапуском данных.

Репозиторий пусть возвращает холодный Flow — это его право и обязанность. А ViewModel пусть подогреет поток перед тем, как отдать его интерфейсу. Разделение ответственности работает и здесь.