Как стать автором
Поиск
Написать публикацию
Обновить

Как грузить данные во ViewModel?

Уровень сложностиПростой
Время на прочтение4 мин
Количество просмотров4.1K

Привет, Хабр!

Эта статья будет полезна для мобильных разработчиков, потому что в ней обсуждаются различные подходы к первоначальной загрузке данных во вьюмодели (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 = "favourites_flow", startDestination = "contacts_list") {
  composable("favourites_list") {
    val viewModel: MyViewModel = viewModel() // либо koinViewModel, hiltViewModel, смотря какой у вас DI.
    // ...
  }

  composable("favourites/{favouriteId}") {
    // ...
  }
}

В этом случае метод с init блоком не будет работать на экране списка контактов. Рассмотрим пошагово жизненный цикл загрузки:

  1. Пользователь переходит на флоу "favourites_flow". Наша вьюмодель создается и вызывается init блок, загружаются данные.

  2. Пользователь переходит на детальный экран контакта и, скажем, удаляет его.

  3. Пользователь возвращается на экран "favourites_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("favourites_list") { entry ->
    val viewModel: MyViewModel = viewModel() 
    entry.OnAppear(navController) {
      viewModel.loadInitialData()
    }
    // ... Дальше ваша Composable верстка и т. д.
    ContactsListScreenUI(viewModel)
}

Вот и все. Мы нашли полностью устраивающий нас способ загрузки данных: он не перезапускается при повороте экрана и перезапускается при возврате пользователя к экрану с другого экрана.

Напоследок отмечу, что способ, описанный Philip Lackner, то есть Flow<T>.onStart { loadInitialData() } , тоже имеет право на существование. В этом подходе используется пятисекундный таймаут коллектора (SharingStarted.WhileSubscribed(5000L) ), благодаря чему данные перезагружаются, если приложение было в фоновом режиме больше 5 секунд (правда с оговоркой, что вы используете collectAsStateWithLifecycle). Тем не менее, с задачей автоперезагрузки данных после возвращения с другого экрана такой фокус не справится, если пользователь посетил другой экран на менее, чем пять секунд. Из других минусов подхода - он предполагает использование Flow во вьюмодели (вдруг вы захотите все же использовать MutableState или, не дай бог, LiveData?), а также привязывает вас к collectAsStateWithLifecycle , недоступного в KMP и Compose Multiplatform.

Теги:
Хабы:
Всего голосов 2: ↑2 и ↓0+4
Комментарии3

Публикации

Ближайшие события