Всем привет. В этой статье я предлагаю рассмотреть, как в 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
), который совмещает сохранение общего состояния экрана и описание масштабируемого состояние какой-то его части. Хотя при этом теряется предыдущее состояние этой самой части.
То есть выбор способа зависит от конкретной задачи и входных данных.