Управление событиями в ViewModel с помощью StateFlow, SharedFlow и Channel
Сообщество Android-разработчиков уже долгое время ведёт жаркие споры о том, что лучше использовать в ViewModel
для представления событий: StateFlow, SharedFlow
или Channel
. В этой статье мы разберёмся в различиях между этими тремя подходами и определим, какой из них наиболее подходит для ваших нужд.
StateFlow
Начнем с того, что для большинства элементов пользовательского интерфейса StateFlow
, несомненно, является лучшим выбором. Можно предположить, что он является лучшим для всех элементов нашего «состояния». StateFlow
предоставляет простой и безопасный способ управления состоянием, который легко интегрируется с Compose, что делает его предпочтительным выбором для многих разработчиков.
// Example of using StateFlow for state
private val _uiState = MutableStateFlow(SortAndFilterUiState())
val uiState: StateFlow get() = _uiState
Однако это может быть сложно, когда мы используем его для представления событий, например, отображения Toast, навигации или выполнения действия. Здесь у нас есть проблема, так как StateFlow не предназначен для этой цели.
Давайте рассмотрим использование MutableStateFlow со значением, допускающим значение null. Оно равно null в начале и после обработки события, поэтому ненулевое значение можно рассматривать как событие для обработки.
// ViewModel
private val _snackbar = MutableStateFlow(null)
val snackbar: StateFlow get() = _snackbar
// In case of error
_snackbar.value = getMesssageFromError(error)
// Fragment
viewModel.snackbar.collect { message ->
message?.let { showSnackbar(message) }
viewModel.clearSnackbar()
}
// ViewModel function
fun clearSnackbar() {
_snackbar.value = null
}
Однако есть некоторые проблемы:
Очистка состояния: Нам нужно помнить об очистке состояния после обработки события, что немного усложняет этот шаблон.
Потеря событий: Если две coroutines отправляют два разных события за короткий промежуток времени, одно из этих событий может быть потеряно. Особенно если у них одинаковое значение, потому что MutableStateFlow игнорирует обновления с одинаковым значением. Но даже разные значения могут быть потеряны, если второе событие отправлено до обработки первого, потому что StateFlow объединен. Так что, по сути, есть несколько способов, которыми события могут быть потеряны.
Теперь давайте рассмотрим некоторые другие варианты.
SharedFlow
В общем, лучшей практикой представления событий является использование SharedFlow. Это гораздо более простая абстракция, которая всегда выдает значение всем своим текущим подпищикам.
// ViewModel
private val _showSnackbar = MutableSharedFlow()
val showSnackbar: SharedFlow get() = _snackbar
// In case of error
_showSnackbar.emit(getMesssageFromError(error))
// Fragment
viewModel.showSnackbar.collect { message ->
showSnackbar(message)
}
SharedFlow также имеет свои проблемы, особенно с ViewModels, где пользовательский интерфейс может изменяться, а наблюдателя в это время нет. Например, если пользователь поворачивает экран, событие, отправленное в этот момент, может быть потеряно. Некоторые разработчики используют обходной путь: ждут появления первого наблюдателя, а затем отправляют событие. Хотя это решение может сработать, его надежность нельзя гарантировать, поэтому я бы не рекомендовал его для критически важных событий.
// ViewModel
private val _showSnackbar = MutableSharedFlow()
val showSnackbar: SharedFlow get() = _snackbar
// In case of error
subscriptionCount.first { it > 0 }
_showSnackbar.emit(getMesssageFromError(error))
// Fragment
viewModel.showSnackbar.collect { message ->
showSnackbar(message)
}
Преимущество SharedFlow в том, что это простое и единственное решение, которое может свободно использоваться более чем одним наблюдателем.
Channel
Естественным решением вышеупомянутой проблемы является использование Channel с неограниченной емкостью. Channel можно рассматривать как очередь событий, которые получают коллекторы. Его можно преобразовать в Flow с помощью функции receiveAsFlow. Если событие отправляется в момент, когда нет наблюдателя, оно будет получено следующим наблюдателем.
// ViewModel
private val _showSnackbar = Channel(Channel.UNLIMITED)
val showSnackbar = _showSnackbar.receiveAsFlow()
// In case of error
_showSnackbar.send(getMesssageFromError(error))
// Fragment
viewModel.showSnackbar.collect { message ->
showSnackbar(message)
}
Использование Channel также имеет свои проблемы. Существует вероятность, что событие будет потеряно в редком случае отмены после отправки события, но до выполнения его действия (подробнее тут).
Самый важный аргумент против использования Channel заключается в том, что он не гарантирует доставку события. Это не должно быть проблемой, если мы и отправляем, и получаем события в Dispatchers.Main.immediate, что довольно часто случается, так как он используется как в viewModelScope, так и в lifecycleScope. Однако всегда гарантировать это — хрупкое решение. Вот почему мы должны избегать использования Channel для событий, важных для пользовательского опыта, таких как результат транзакции. Такие события лучше представлять с помощью StateFlow, который гарантирует доставку события наблюдателю.
Представление событий в виде состояния и использование StateFlow
Пришло время рассмотреть вариант, который я чаще всего слышу от Google и который они рекомендуют. Это превращение событий в состояние и использование StateFlow для их представления. Это единственный вариант, в надежности которого я уверен, но проблема в том, что он не самый простой.
Давайте рассмотрим пример отображения снэк-бара. Чтобы представить его как состояние, нам нужно будет использовать список сообщений, показать только первое и удалить его после показа.
// ViewModel
private val _snackbarQueue = MutableStateFlow>(emptyList())
val snackbarQueue: StateFlow> get() = _snackbar
// In case of error
_snackbarQueue.update { it + getMesssageFromError(error) }
// Fragment
viewModel.snackbar.collect { message ->
if (message.isNotEmpty()) {
val first = message.first()
showSnackbar(first)
viewModel.removeSnackbarMessage(first)
}
}
// ViewModel function
fun removeSnackbarMessage(message: String) {
_snackbarQueue.update { it - message }
}
Вероятно, одно и то же сообщение будет показано дважды, но не потеряется, что предпочтительнее для важных событий.
Хороший аргумент в пользу этого решения заключается в том, что нам не нужно изучать тонкости SharedFlow или Channel, мы можем просто использовать StateFlow для всего и разрабатывать шаблоны его использования для разных целей.
Однако, с другой стороны, есть хороший аргумент, что события должны быть представлены как события, а не как состояние (событие произошло, состояние есть), и для этого SharedFlow или Channel являются лучшими решениями.
Jetpack Compose
Стоит отметить, что потребность в событиях не была редкостью в классической разработке Android. Однако в Jetpack Compose она встречается довольно редко. Многие вещи, например диалоги, являются не событиями, которые нужно отправлять, а состоянием, которое определяет, что теперь отображается на экране. Для представления такого состояния StateFlow гораздо более подходит. Просто не забудьте использовать его соответствующим образом и подумайте, что должно произойти, если нужно показать другой диалог до того, как предыдущий будет скрыт.
// ViewModel
private val _dialogQueue = MutableStateFlow>(emptyList())
val dialogQueue: StateFlow> get() = _dialog
// In case of error
_dialogQueue.update { it + getDialogFromError(error) }
// Jetpack Compose
val dialogQueue = viewModel.dialogQueue.collectAsStateWithLifecycle()
if (dialogQueue.isNotEmpty()) {
val dialog = dialogQueue.first()
AlertDialog(onDismissRequest = { viewModel.onDialogDismissed(dialog) }) {
// ...
}
}
// ViewModel function
fun onDialogDismissed(dialog: DialogData) {
_dialogQueue.update { it - dialog }
}
Резюме
У меня нет однозначного ответа на вопрос, какой из подходов лучше, так как в большинстве случаев это не имеет решающего значения. Однако для важных событий безопаснее всего использовать StateFlow. Несмотря на это, я вижу преимущества в использовании SharedFlow, так как он позволяет иметь более одного наблюдателя, или Channel, благодаря его простоте. Также стоит отметить, что стандартизация использования StateFlow может быть полезной и безопасной для тех, кто не знаком с SharedFlow или Channel.
При использовании Jetpack Compose представление всего в виде состояния, которое должно быть отображено на экране, является наиболее подходящим решением.