Всем привет. В этой статье я предлагаю рассмотреть, как в 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/
Добавить комментарий