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 пусть подогреет поток перед тем, как отдать его интерфейсу. Разделение ответственности работает и здесь.