Как грузить данные во ViewModel?
Привет, Хабр!
Эта статья будет полезна для мобильных разработчиков, потому что в ней обсуждаются различные подходы к первоначальной загрузке данных во вьюмодели (Jetpack ViewModel) при ее использовании в проектах на Jetpack Compose (либо Compose Multiplatform).
Тема эта настолько холиварная, что один из самых влиятельных ютуберов в сфере Android-разработки Philip Lackner недавно посвятил ей отдельный как всегда очень качественный обзор. Эта статья — во многом ответ и дополнение к нему.
Итак, перейдем к сути.
Первый способ. LaunchedEffect
На своем карьерном пути я видел уже несколько проектов, где данные во вьюмодели грузятся следующим образом:
class MyViewModel: ViewModel() {
fun loadInitialData() {
viewModelScope.launch {
repository.loadData()
// ...
}
}
}
// В Composable коде
LaunchedEffect(Unit) {
viewModel.loadInitialData()
}
На первый взгляд, он позволяет добиться желаемого. Однако есть один нюанс: в этом случае загрузка данных привязана к жизненному циклу рекомпозиции, а не к жизненному циклу экрана. В случае с Android это, конечно, не одно и то же. Если ваше приложение поддерживает альбомную ориентацию, то LaunchedEffect
будет перезапущен при каждом повороте экрана. А это означает лишние запросы, а может быть и лишние элементы интерфейса (лоадеры, скелетоны, шиммеры) связанные с загрузкой данных. Звучит не очень, не правда ли?
(В скобках оговоримся, что, к огромному сожалению, далеко не все сегодняшние продовые Android-приложения поддерживают поворот экрана, но это на их совести. Спасибо Павлу Дурову за возможность вертеть Telegram и VK, как нам вздумается.)
Второй способ. Блок init
Итак, признаем, что LaunchedEffect
не лучшее решение. Другой и один из самых распространенных способов загрузки данных — init блок во ViewModel.
class MyViewModel: ViewModel() {
suspend fun loadInitialData() {
repository.loadData()
// ... дальнейшая логика
}
init {
viewModelScope.launch {
loadInitialData()
}
}
}
Этот способ отличается простотой и во многих случаях работает вполне правильно. Он не будет перезапущен при каждом повороте экрана, поскольку любой наследник ViewModel
живет в ViewModelStore
и переживает смерть Activity
при повороте экрана.
Однако есть случаи, когда данный метод будет не вполне удобен. Представим себе классическую ситуацию, когда нам надо отобразить какие-либо данные списком, а затем детальный экран с редактированием и/или удалением одного из элементов списка:
navigation(route = "contacts_flow", startDestination = "contacts_list") {
composable("contacts_list") {
val viewModel: MyViewModel = viewModel() // либо koinViewModel, hiltViewModel, смотря какой у вас DI.
// ...
}
composable("contact/{contactId}") {
// ...
}
}
В этом случае метод с init блоком не будет работать на экране списка контактов. Рассмотрим пошагово жизненный цикл загрузки:
Пользователь переходит на флоу
"contacts_flow"
. Наша вьюмодель создается и вызывается init блок, загружаются данные.Пользователь переходит на детальный экран контакта и, скажем, удаляет его.
Пользователь возвращается на экран
"contacts_list"
и видит удаленный контакт в списке, поскольку вьюмодель уже создана и init блок больше не отрабатывает.
Как решить эту проблему? Как загрузить данные при каждом появлении нашего экрана, но при этом игнорируя изменения конфигурации?
Чтобы ответить на этот вопрос, нужно вспомнить, в каком ViewModelStore
сохраняется наша вьюмодель по умолчанию при вызове функции viewModel
(то же самое будет справедливо и для koinViewModel
). По умолчанию это LocalViewModelStore.current
, то есть либо наше Activity, либо последний NavBackStackEntry в бэкстеке навигации.
Здесь и кроется наше решение: зная, что NavBackStackEntry обладает своим жизненным циклом, можно отследить его реальные появления на экране:
val LifecycleOwner?.isCreated: Boolean
get() = this?.lifecycle?.currentState == Lifecycle.State.CREATED
val LifecycleOwner?.isResumed: Boolean
get() = this?.lifecycle?.currentState == Lifecycle.State.RESUMED
val LifecycleOwner?.isStarted: Boolean
get() = this?.lifecycle?.currentState == Lifecycle.State.STARTED
@Composable
fun NavBackStackEntry.OnAppear(controller: NavController, action: () -> Unit) {
LaunchedEffect(Unit) {
controller.visibleEntries.collectLatest { entries ->
val leavingEntry = entries.firstOrNull { it.isCreated }
val appearingEntry = entries.firstOrNull { it.isStarted || it.isResumed }
leavingEntry?.let {
if (appearingEntry == this@OnAppear) action()
}
}
}
}
В этой небольшой функции мы отслеживаем, нет ли экрана, который «покидает» отображение (то есть находится в состоянии CREATED
), и если он есть, то вызываем лямбду action
для нашей NavBackStackEntry
.
Теперь можно использовать функцию для любого экрана, где нам нужна загрузка (или перезагрузка) данных при его появлении:
composable("contacts_list") { entry ->
val viewModel: MyViewModel = viewModel()
entry.OnAppear(navController) {
viewModel.loadInitialData()
}
// ... Дальше ваша Composable верстка и т. д.
ContactsListScreenUI(viewModel)
}
Вот и все. Мы нашли полностью устраивающий нас способ загрузки данных: он не перезапускается при повороте экрана и перезапускается при возврате пользователя к экрану с другого экрана.
Напоследок отмечу, что способ, описанный Philip Lackner, то есть Flow
, тоже имеет право на существование. В этом подходе используется пятисекундный таймаут коллектора (SharingStarted.WhileSubscribed(5000L)
), благодаря чему данные перезагружаются, если приложение было в фоновом режиме больше 5 секунд (правда с оговоркой, что вы используете collectAsStateWithLifecycle
). Тем не менее, с задачей автоперезагрузки данных после возвращения с другого экрана такой фокус не справится, если пользователь посетил другой экран на менее, чем пять секунд. Из других минусов подхода — он предполагает использование Flow
во вьюмодели (вдруг вы захотите все же использовать MutableState
или, не дай бог, LiveData
?), а также привязывает вас к collectAsStateWithLifecycle
, недоступного в KMP и Compose Multiplatform.