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

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

Сущность State в MVI

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

53ebb0583984e7f8341533d5fa3d75de.png

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
    ) : 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()
        }
    }

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

2a5ffbf798224d06ed4fe5502a8abed8.png

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

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

   private val _state = MutableLiveData()
    val state: LiveData
        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
    ) : ScreenState

    data class Error(
        val message: String
    ) : ScreenState
    
    data class BottomSheet(
        val title: String,
        val content: List
    ): 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 остаётся без изменений. А теперь посмотрим на схеме, как меняется интерфейс при пользовательских действиях:

69e77ff836128eef3b195cb6758121ee.png

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

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

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

bab76075fc374e380d2876e4404d95ae.png

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

    private var userData: List = 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)
            }
        }
    }

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

c34a8e2a462fc549d161cc90fde9e6f4.png

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

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

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

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

data class BottomSheetContent(
    val title: String,
    val content: List
)

Внесём изменения в функцию 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()
    val state: LiveData
        get() = _state

    private var userData: List = 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
        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? = null,
    val snackBar: SnackBar? = null
)

data class SnackBar(
    val title: String,
    val icon: Int
)

data class BottomSheetContent(
    val title: String,
    val content: List
)

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

 private val _state = MutableLiveData(ScreenState())
    val state: LiveData
        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? = null,
    ) : ContentState

    data class BottomSheetContent(
        val title: String,
        val content: List
    ) : ContentState

    data class SnackBar(
        val title: String,
        val icon: Int
    ) : ContentState
}

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

    private val _state = MutableLiveData(ScreenState())
    val state: LiveData
        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), который совмещает сохранение общего состояния экрана и описание масштабируемого состояние какой-то его части. Хотя при этом теряется предыдущее состояние этой самой части.  

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

© Habrahabr.ru