Обзор решений описания и обновления state экрана в Сompose

от автора

Всем привет. В этой статье я предлагаю рассмотреть, как в Compose можно описать, обновить и масштабировать состояние экрана с помощью паттерна MVI.

Сущность State в MVI

MVI — это архитектурный паттерн, который входит в семейство паттернов UDF. В отличие от MVVM, MVI подразумевает только один источник данных. Визуальное представление паттерна представлено на рисунке ниже:

MVI содержит три компонента: слой логики и данных (Model); UI‑слой, отображающий состояние (View); и намерения (Intent) — действия, поступающие от пользователя при взаимодействии с View.

State — это сущность, описывающая текущее состояние, которое отображается пользователю через View. Есть множество вариантов описания состояния экрана, ниже мы рассмотрим различные способы, их достоинства и недостатки.

Как было до Compose

Рассмотрим экран, свёрстанный в XML. Для описания состояния раньше чаще всего использовали sealed interface:

sealed interface ScreenState {     data object Loading : ScreenState     data class Content(         val items: List<User>     ) : ScreenState     data class Error(         val message: String     ) : ScreenState }

Этот state содержит три состояния: 

  • загрузка — показываем индикатор прогресса;

  • контент — отображаем список элементов;

  • ошибка — показываем ошибку загрузки данных.

Пример view выглядит так:

override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)         viewModel.state.observe(this, ::render)         viewModel.action(ScreenAction.LoadData)     }      override fun onCreateView(         inflater: LayoutInflater, container: ViewGroup?,         savedInstanceState: Bundle?     ): View? {         return inflater.inflate(R.layout.fragment_screen, container, false)     }      private fun render(state: ScreenState) {         when (state) {             is ScreenState.Content -> showContent(data = state)             is ScreenState.Error -> showError(data = state)             ScreenState.Loading -> showProgressBar()         }     }

Теперь рассмотрим на схеме, как меняется интерфейс при пользовательских действиях: 

Вначале загружаем данные, потом отображение контента, далее действия пользователя, при которых необходимо обновить данные: загружаем на фоне контента, который был загружен ранее, а потом обновляем отображение экрана. 

Обновления состояния во viewModel представлены ниже:

   private val _state = MutableLiveData<ScreenState>()     val state: LiveData<ScreenState>         get() = _state      fun action(action: ScreenAction) {         when (action) {             ScreenAction.LoadData -> {                 _state.value = ScreenState.Loading                 viewModelScope.launch {                     try {                         _state.value = ScreenState.Content(loadData())                     } catch (e: Exception) {                         _state.value = ScreenState.Error(handlerError(e))                     }                 }             }         }     }

Как можно заметить, предыдущее состояние не хранится. Мы устанавливаем значение состояния в начале загрузки, потом, в случае успеха, выполняем Content, иначе — Error

Рассмотрим масштабирование этого подхода. Добавим новое состояние показа шторки с контентом. Состояние теперь выглядит вот так:

sealed interface ScreenState {      data object Loading : ScreenState      data class Content(         val items: List<User>     ) : ScreenState      data class Error(         val message: String     ) : ScreenState          data class BottomSheet(         val title: String,         val content: List<String>     ): ScreenState }

Изменения во view соответствующие:

override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)         viewModel.state.observe(this, ::render)         viewModel.action(ScreenAction.LoadData)     }      override fun onCreateView(         inflater: LayoutInflater, container: ViewGroup?,         savedInstanceState: Bundle?     ): View? {         // Inflate the layout for this fragment         return inflater.inflate(R.layout.fragment_screen, container, false)     }      private fun render(state: ScreenState) {         when (state) {             is ScreenState.Content -> showContent(data = state)             is ScreenState.Error -> showError(data = state)             ScreenState.Loading -> showProgressBar()             is ScreenState.BottomSheet -> showBottomSheet(data = state)         }     }

Как видите, это решение легко расширяется. Теперь рассмотрим его применение для экрана с Compose.

Использование sealed class в Compose

View экрана выглядит так: 

@Composable fun Screen(     state: ScreenState ) {     when (state) {         is ScreenState.BottomSheet -> BottomSheetContent(state.title, state.content)         is ScreenState.Content -> Content(state.items)         is ScreenState.Error -> Error(state.message)         ScreenState.Loading -> ProgressBar()     } }

ViewModel остаётся без изменений. А теперь посмотрим на схеме, как меняется интерфейс при пользовательских действиях:

Вначале загружаем данные, потом в случае успеха — отображение. Затем пользователь что‑то делает, и в соответствии с этим нужно обновить данные, показывается индикатор прогресса, но теперь уже на фоне пустого экрана. А после загрузки показываем обновлённый контент.

Пустой экран появляется в связи с тем, что в Compose меняется состояние при отображении загрузки, при котором скрывается показ контента и мы не храним состояние state предыдущих данных.

Предположим, что при обновлении данных (повторной загрузке) произошла ошибка. На нашей схеме это будет выглядеть следующим образом:

После закрытия ошибки пользователь увидит пустой экран. Как видите, для Compose этот способ работает неправильно. Решить проблему можно, например, так:

    private var userData: List<User> = emptyList()          fun action(action: ScreenAction) {         when (action) {             ScreenAction.LoadData -> {                 _state.value = ScreenState.Loading                 viewModelScope.launch {                     try {                         userData = loadData()                         _state.value = ScreenState.Content(userData)                     } catch (e: Exception) {                         _state.value = ScreenState.Error(handlerError(e))                     }                 }             }              ScreenAction.CloseError -> {                 _state.value = ScreenState.Content(userData)             }         }     }

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

Теперь рассмотрим другой подход: описание состояния экрана в Compose. 

Использование data class 

Опишем состояние, которое ранее описывали с помощью sealed interface, но на этот раз c помощью data class

data class ScreenState(     val isLoading: Boolean = false,     val content: List<User>? =null,     val error: String? = null,     val bottomSheet: BottomSheetContent? = null )  data class BottomSheetContent(     val title: String,     val content: List<String> )

Внесём изменения в функцию Compose следующим образом: 

@Composable fun Screen(     state: ScreenState ) {     state.content?.let { data ->         Content(data)     }      state.bottomSheet?.let { data ->         BottomSheetContent(data.title, data.content)     }      state.error?.let { data ->         Error(data)     }      if (state.isLoading) {         ProgressBar()     }  }

ViewModel будет выглядеть так:

private val _state = MutableLiveData<ScreenState>()     val state: LiveData<ScreenState>         get() = _state      private var userData: List<User> = emptyList()      fun action(action: ScreenAction) {         when (action) {             ScreenAction.LoadData -> {                 _state.value = ScreenState(isLoading = true)                 viewModelScope.launch {                     try {                         userData = loadData()                         _state.value = ScreenState(                             isLoading = false,                             content = userData                         )                     } catch (e: Exception) {                         _state.value = ScreenState(                             isLoading = false,                             error = handlerError(e)                         )                     }                 }             }              ScreenAction.CloseError -> {                 _state.value = ScreenState(                     error = null,                     content = userData                 )             }         }     }

Как видите, особой выгоды в использовании data class для описания состояния нет: нам так же нужно сохранять предыдущее состояние, а в функции Compose теперь даже менее удобно стало проверять поля data class на пустоту. Но давайте вспомним метод copy, который доступен в data class, и вместо создания нового экземпляра state будем его обновлять, ниже приведен код во viewModel

private val _state = MutableLiveData(ScreenState())     val state: LiveData<ScreenState>         get() = _state      fun action(action: ScreenAction) {         when (action) {             ScreenAction.LoadData -> {                 _state.value = _state.value?.copy(isLoading = true)                 viewModelScope.launch {                     try {                         _state.value = _state.value?.copy(                             isLoading = false,                             content = loadData()                         )                     } catch (e: Exception) {                         _state.value = _state.value?.copy(                             isLoading = false,                             error = handlerError(e)                         )                     }                 }             }              ScreenAction.CloseError -> {                 _state.value = _state.value?.copy(                     error = null                 )             }         }     }

Нам больше не нужно сохранять данные в памяти отдельно, и мы сохраняем состояние на предыдущем шаге. Пользователь увидит то же самое поведение, что и до использования Compose.  

В нашем примере довольно простое состояние state, рассмотрим случай более сложного экрана: 

data class ScreenState(     val isLoading: Boolean = false,     val content: Data? = null,     val error: String? = null,     val bottomSheet: BottomSheetContent? = null )  data class Data(     val content: List<User>? = null,     val snackBar: SnackBar? = null )  data class SnackBar(     val title: String,     val icon: Int )  data class BottomSheetContent(     val title: String,     val content: List<String> )

Контент стал сложнее, может содержать данные для отображение и snackBar. Обновление состояние внутри viewModel будет выглядеть так: 

 private val _state = MutableLiveData(ScreenState())     val state: LiveData<ScreenState>         get() = _state      fun action(action: ScreenAction) {         when (action) {             ScreenAction.LoadData -> {                 _state.value = _state.value?.copy(isLoading = true)                 viewModelScope.launch {                     try {                         _state.value = _state.value?.copy(                             isLoading = false,                             content = Data(                                 content = loadData(),                                 snackBar = null                             )                         )                     } catch (e: Exception) {                         _state.value = _state.value?.copy(                             isLoading = false,                             error = handlerError(e)                         )                     }                 }             }              ScreenAction.CloseError -> {                 _state.value = _state.value?.copy(                     error = null                 )             }              ScreenAction.ShowSnackBar -> {                 _state.value = _state.value?.copy(                     error = null,                     content = _state.value?.content?.copy(                         snackBar = SnackBar(                             title = "title",                             icon = 12                         )                     )                 )             }         }     }

Сложно уже обновлять состояние, большая вложенность, и во view сложнее обработка. Рассмотрим решение, которое объединяет оба способа.

Использование data class вместе с sealed class

Внесём изменения в наше состояние: 

data class ScreenState(     val isLoading: Boolean = false,     val content: ContentState = ContentState.Shimmer,     val error: String? = null, )  sealed interface ContentState {      data object Shimmer : ContentState      data class Data(         val content: List<User>? = null,     ) : ContentState      data class BottomSheetContent(         val title: String,         val content: List<String>     ) : ContentState      data class SnackBar(         val title: String,         val icon: Int     ) : ContentState }

Контент описываем через интерфейс sealed, а сам state экрана остаётся через data class. ViewModel будет выглядеть так: 

    private val _state = MutableLiveData(ScreenState())     val state: LiveData<ScreenState>         get() = _state      fun action(action: ScreenAction) {         when (action) {             ScreenAction.LoadData -> {                 _state.value = _state.value?.copy(isLoading = true)                 viewModelScope.launch {                     try {                         _state.value = _state.value?.copy(                             isLoading = false,                             content = ContentState.Data(                                 content = loadData(),                             )                         )                     } catch (e: Exception) {                         _state.value = _state.value?.copy(                             isLoading = false,                             error = handlerError(e)                         )                     }                 }             }              ScreenAction.CloseError -> {                 _state.value = _state.value?.copy(                     error = null                 )             }              ScreenAction.ShowSnackBar -> {                 _state.value = _state.value?.copy(                     content = ContentState.SnackBar(                         title = "title",                         icon = 12                     )                 )             }              ScreenAction.ShowBottomSheet -> {                 _state.value = _state.value?.copy(                     content = ContentState.BottomSheetContent(                         title = "title",                         content = loadContent()                     )                 )             }         }     }

В результате глобальное состояние сохраняет способ копирования через data class, а контент легко масштабируется через sealed interface. Но стоит помнить, что в этом решении для контента отсутствует сохранение состояния и state устанавливается новый.

Резюме

Мы рассмотрели три разных решения для описания состояния экрана: с помощью sealed interface, data class и гибридный способ, который совмещает первые два. У каждого из описанных способов есть свои достоинства и недостатки. 

Способ с sealed interface/class позволяет легко масштабировать, но он не сохраняет предыдущее состояние. Лучше его использовать, когда не нужно сохранять предыдущее значение state

Способ с использованием data class позволяет легко сохранять предыдущее состояние state с помощью метода copy. Но если состояние экрана сложное (с большой вложенностью), возрастает сложность кода. Зато метод удобен, когда экран сложный и нет необходимости во множественной вложенности. В противном случае стоит задуматься о применении гибридного метода (data class совмещён с sealed interface), который совмещает сохранение общего состояния экрана и описание масштабируемого состояние какой-то его части. Хотя при этом теряется предыдущее состояние этой самой части.  

То есть выбор способа зависит от конкретной задачи и входных данных.


ссылка на оригинал статьи https://habr.com/ru/articles/856544/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *