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

Эта статья будет полезна для мобильных разработчиков, потому что в ней обсуждаются различные подходы к первоначальной загрузке данных во вьюмодели (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)


  1. vafeen
    09.10.2024 00:57

    Грузить данные во ViewModel нужно через AssistedInject (Hilt) или parametersOf (koin) и т.п. в подобных фреймворках в первичный конструктор ViewModel, а создание модели без данных, и загрузка их после создания - костыль, за который нужно бить по рукам)

    Далее: 1 способ: loadInitialData имеет внутри launch - coroutineScope, и LaunchedEffect аналогично. Профит от launch { launch {} } буквально?

    Далее: Грузить контакты, "Пользователь возвращается на экран "contacts_list" и видит удаленный контакт в списке, поскольку вьюмодель уже создана и init блок больше не отрабатывает." - Для таких ситуация существует Flow. Нужно брать из него данные в экране списка контактов и тогда там всегда будет актуальная информация. Конакты сохраняются в бд: если это Room, в нем можно возвращать тип Flow<List<T>>, а во viewModel и\или на экране подписываться с помощью Flow<List<T>>.collect {}. Тогда операции insert\delete\update будут выполняться штатно и без каких-либо дополнительных костылей


    1. glider_skobb Автор
      09.10.2024 00:57

      Забавно, вы как будто вообще не поняли, о чем статья) Речь идёт о загрузке данных с сервера, загрузке именно по сети. Вы будете использовать для этого parametersOf? Хотел бы на это посмотреть)

      Далее, если вы используете KMP, лучше не выставлять наружу suspend методы вьюмодели, иначе в Свифте придется оборачивать в try await или работать с коллбэками. Синхронный метод для бо́льшего удобства.

      Далее, если вы используете загрузку данных с сервера, можно, конечно, их закешировать в локальную БД, но тогда придется решать вопрос, когда инвалидировать этот кэш, как синхронизировать его с удаленным источником данных и так далее. Кроме того, если вы используете оптимистические апдейты, а сервер за вами "не поспевает" или запрос упал с ошибкой - опять же, возникнет рассинхрон с сервером.

      Поэтому на практике часто "списочный" экран напрямую обновляется свежими данными с серва.