🧨 SharedFlow для Side Effects - это ошибка. Разбираем официально лучший подходВ MVI/MVVM есть вечная проблема: разовые события (Side Effects). Показать
Snackbar, перейти на другой экран, проиграть звук. Это не стейт, это событие, которое должно произойти ровно один раз.Долгое время комьюнити пыталось решить это через
SharedFlow. Выглядело это так:
private val _effects = MutableSharedFlow<Effect>()
val effects = _effects.asSharedFlow()
Почему это выстрел в ногу?
По дефолту
SharedFlow не имеет буфера (replay = 0). Если ViewModel пушит событие (_effects.emit(...)), когда UI находится в фоне (пользователь свернул приложение или открыл другой экран), у потока в этот момент нет активных подписчиков.Результат? Событие просто дропается. Улетает в пустоту. Юзер разворачивает апп - а перехода или сообщения об ошибке нет.
Как это обычно пытаются костылить?
Начинают играться с буфером:
replay = 1. Событие перестает теряться, но появляется новая проблема: оно кэшируется! Пользователь видит ошибку, поворачивает экран, UI переподписывается на
SharedFlow, считывает кэш, и старый Snackbar показывается второй раз.✅ Как правильно? (Рекомендация Google)
Использовать
Channel. Каналы созданы именно для паттерна Producer-Consumer и имеют встроенный буфер из коробки.
class ProfileViewModel : ViewModel() {
// 1. Создаем канал
private val _effects = Channel<Effect>()
// 2. Отдаем наружу как холодный Flow
val effects = _effects.receiveAsFlow()
fun saveProfile() {
viewModelScope.launch {
repository.save()
// 3. Отправляем событие. Если UI в фоне — оно ложится в буфер.
_effects.send(Effect.ShowSnackbar("Сохранено"))
}
}
}
Почему
Channel - это идеальное решение:1. Guarantee Delivery: Если UI ушел в фон (подписчиков нет),
Channel просто ставит событие в буфер ожидания.2. Exactly-Once: Как только UI возвращается (например,
Fragment снова в onStart) и делает collect, он забирает событие из очереди. Событие доставляется ровно один раз и удаляется из канала.3. Никаких дублей: При повороте экрана новый
collect просто начнет слушать пустой канал. Старое событие уже было "съедено".Единственный нюанс: на стороне UI нужно собирать такие события правильно, учитывая жизненный цикл (например, через
Lifecycle.repeatOnLifecycle или кастомные Compose-эффекты).✍️ @kotlin_lib