Site icon AppTractor

Моделирование состояния ViewModel в Android: чистый, масштабируемый паттерн

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

Вот два наиболее популярных способа моделирования состояния ViewModel:

// Approach 1
data class ScreenState(
    val isLoading: Boolean,
    val isError: Boolean,
    val data: Data?
)
// Approach 2
sealed interface ScreenState {
    data object Loading : ScreenState
    data object Error : ScreenState
    data class Content(val data: Data) : ScreenState
}

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

Подход 1: обычный класс данных

data class ProductListScreenState(
    val isLoading: Boolean,
    val isError: Boolean,
    val productResults: ProductResults?
)

data class ProductResults(
    val products: List<Product>, 
    val canLoadMore: Boolean
)

Представьте себе страницу со списком товаров, на которой данные берутся из удаленного источника. Во время загрузки отображается спиннер, а при возникновении ошибки появляется представление ошибки с возможностью повтора. Это распространенный сценарий Loading-Content-Error.

// Use case
fun interface GetProductResults {
    suspend operator fun invoke(startIndex: Int): Result<ProductResults>
}

// ViewModel
class ProductListViewModel(
    private val getProductResults: GetProductResults
) : ViewModel() {

    private val _state = MutableStateFlow(
        ProductListScreenState(
            isLoading = true, 
            isError = false, 
            productResults = null
        )
    )
    val state = _state.asStateFlow()

    fun loadProducts() {
        _state.update {
            ProductListScreenState(
                isLoading = true, 
                isError = false, 
                productResults = null
            )
        }
        viewModelScope.launch {
            getProductResults(startIndex = 0).fold(
                onSuccess = { results ->
                    _state.update {
                        ProductListScreenState(
                            isLoading = false,
                            isError = false,
                            productResults = results
                        )
                    }
                },
                onFailure = {
                    _state.update {
                        ProductListScreenState(
                            isLoading = false,
                            isError = true,
                            productResults = null
                        )
                    }
                }
            )
        }
    }
}

У этого подхода есть несколько недостатков:

  1. Модель допускает конфликтующие состояния: и isLoading, и isError могут быть одновременно установлены в true, в результате чего пользовательский интерфейс будет одновременно показывать и загрузку, и ошибки. А теперь представьте, что на экран добавляются дополнительные состояния, например isRefreshing или isPaginating. Такая настройка может привести к 2⁴ возможным комбинациям булевых значений для экрана, который на самом деле имеет только пять различных состояний — остается девять недопустимых комбинаций.
  2. Каждый раз, когда ViewModel устанавливает состояние, мы должны убедиться, что все остальные булевы значения установлены в false, что влечет за собой большое количество шаблонного кода. Кроме того, при добавлении нового состояния нам нужно рефакторить весь код установки состояния, чтобы включить в него новое булево значение, что еще больше увеличивает накладные расходы на обслуживание.
  3. На уровне представлений (будь то Compose или стандартные представления Android) разработчики могут быть введены в заблуждение моделью и будут вынуждены перепроверять реализацию ViewModel, чтобы понять, какие состояния действительно возможны.

Почему бы не использовать перечисление?

data class ProductListScreenState(
    val displayState: DisplayState,
    val productResults: ProductResults?
)

enum class DisplayState {
    LOADING, CONTENT, ERROR
}
class ProductListViewModel(
    private val getProductResults: GetProductResults
) : ViewModel() {

    private val _state = MutableStateFlow(
        ProductListScreenState(
            displayState = DisplayState.LOADING, 
            productResults = null
        )
    )
    val state = _state.asStateFlow()

    fun loadProducts() {
        _state.update {
            ProductListScreenState(
                displayState = DisplayState.LOADING, 
                productResults = null
            )
        }
        viewModelScope.launch {
            getProductResults(startIndex = 0).fold(
                onSuccess = { results ->
                    _state.update {
                        ProductListScreenState(
                            displayState = DisplayState.CONTENT,
                            productResults = results
                        )
                    }
                },
                onFailure = {
                    _state.update {
                        ProductListScreenState(
                            displayState = DisplayState.ERROR,
                            productResults = null
                        )
                    }
                }
            )
        }
    }
}

Это, конечно, значительно улучшение!

Однако, хотя это и помогает предотвратить некоторые из конфликтующих состояний, это лишь частичное решение. Перечисление не гарантирует, что productResults соответствует displayState. Например, CONTENT должен подразумевать, что productResults не является null, но в данном случае это не гарантируется. Аналогично, мы можем случайно назначить состояние LOADING или ERROR с не-нулевым productResults.

Короче говоря, хотя перечисление позволяет избежать многих конфликтов, оно не полностью справляется с взаимоисключающими состояниями, особенно когда эти состояния должны содержать дополнительные данные.

Стоит ли писать модульные тесты?

Независимо от используемого подхода, полный набор тестов необходим для выявления любых проблем в логике ViewModel. Ваши модульные тесты должны проверять, что displayState корректно согласуется с наличием или отсутствием productResults. Тесты должны отлавливать случаи, когда состояние CONTENT случайно сопрягается с нулевым productResults, или когда состояния LOADING или ERROR несут данные, которых там быть не должно.

Однако даже при наличии юнит-тестов такая модель состояний может вводить в заблуждение при отрисовке пользовательского интерфейса. Разработчикам все равно придется часто перепроверять реализацию ViewModel, чтобы понять, как управляется состояние, что приведет к нарушению инкапсуляции и увеличению времени, затрачиваемого на чтение кода. Эта постоянная необходимость переключения контекста и проверки подрывает ясность и сопровождаемость кода, делая работу разработчиков в слое пользовательского интерфейса более неуверенной.

Подход 2: Sealed интерфейс

sealed interface ProductListScreenState {
    data object Loading : ProductListScreenState
    data object Error : ProductListScreenState
    data class Content(val productResults: ProductResults) : ProductListScreenState
}
class ProductListViewModel(
    private val getProductResults: GetProductResults
) : ViewModel() {

    private val _state = MutableStateFlow<ProductListScreenState>(
        ProductListScreenState.Loading
    )
    val state = _state.asStateFlow()

    fun loadProducts() {
        _state.update { ProductListScreenState.Loading }
        viewModelScope.launch {
            getProductResults(startIndex = 0).fold(
                onSuccess = { results ->
                    _state.update {
                        ProductListScreenState.Content(
                            productResults = results
                        )
                    }
                },
                onFailure = {
                    _state.update {
                        ProductListScreenState.Error
                    }
                }
            )
        }
    }
}

В этом подходе мы используем герметичные интерфейсы, чтобы гарантировать, что конфликтующие состояния невозможны. Каждое состояние — Loading, Error и Content — представлено в явном виде, что делает модель пользовательского интерфейса читаемой и простой для отображения в пользовательском интерфейсе.

Этот подход элегантно работает для типичного сценария Loading-Content-Error, но он вводит серьезное ограничение: совместное использование данных в разных состояниях.

Рассмотрим пример, в котором мы хотим отобразить в экшен баре приветственное сообщение для конкретного пользователя. Если пользователь вошел в систему, мы выводим Welcome back, {Full Name}; если нет, мы просто выводим Welcome back.

Это сообщение должно отображаться последовательно во всех состояниях — будь то загрузка, содержимое или ошибка.

Предположим, что мы получаем полное имя в следующем юз кейсе:

fun interface ObserveUserFullName {
    operator fun invoke(): StateFlow<String>
}

Этот сценарий использования возвращает StateFlow, который выдает полное имя, если пользователь вошел в систему, и пустую строку, если вышел, позволяя пользовательскому интерфейсу обновляться в реальном времени при изменении статуса аутентификации пользователя.

При нашем текущем подходе мы вынуждены обрабатывать эти данные отдельно и вручную объединять их с текущим состоянием:

sealed interface ProductListScreenState {
    val fullName: String

    data class Loading(override val fullName: String) : ProductListScreenState
    data class Error(override val fullName: String) : ProductListScreenState
    data class Content(
        override val fullName: String, 
        val productResults: ProductResults
    ) : ProductListScreenState
}
class ProductListViewModel(
    private val getProductResults: GetProductResults,
    private val observeUserFullName: ObserveUserFullName
) : ViewModel() {

    private val _state = MutableStateFlow<ProductListScreenState>(
        ProductListScreenState.Loading(observeUserFullName().value)
    )
    val state = _state.asStateFlow()
    
    init {
        observeUserFullName().onEach { fullName ->
            updateStateWithFullName(fullName)
        }.launchIn(viewModelScope)
    }
    
    private fun updateStateWithFullName(fullName: String) {
        _state.update {
            when (val currentState = _state.value) {
                is ProductListScreenState.Loading -> {
                    ProductListScreenState.Loading(fullName = fullName) 
                }
                is ProductListScreenState.Content -> {
                    ProductListScreenState.Content(
                        fullName = fullName,
                        productResults = currentState.productResults
                    )
                }
                is ProductListScreenState.Error -> {
                    ProductListScreenState.Error(fullName = fullName)
                }
            }
        }
    }

    fun loadProducts() {
        _state.update { 
            ProductListScreenState.Loading(fullName = observeUserFullName().value) 
        }
        viewModelScope.launch {
            getProductResults(startIndex = 0).fold(
                onSuccess = { results ->
                    _state.update {
                        ProductListScreenState.Content(
                            fullName = observeUserFullName().value,
                            productResults = results
                        )
                    }
                },
                onFailure = {
                    _state.update {
                        ProductListScreenState.Error(fullName = observeUserFullName().value)
                    }
                }
            )
        }
    }
}

Это требует значительно большего объема кода. Напротив, Подход 1 (использование класса данных) позволил нам использовать функцию copy() для эффективного обновления полей. Здесь, однако, мы должны явно проверять текущее состояние и затем обновлять его — это добавляет сложности, которая может стать громоздкой по мере добавления новых состояний на экране.

Подход 3: класс данных, обернутый sealed интерфейсом

У каждого подхода есть свои достоинства и недостатки:

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

Решение:

data class ProductListScreenState(
    val fullName: String,
    val displayState: DisplayState
)

sealed interface DisplayState {
    data object Loading : DisplayState
    data object Error : DisplayState
    data class Content(val productResults: ProductResults) : DisplayState
}

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

(Я назвал родительское состояние ScreenState, а дочернее — DisplayState, но могу поспорить, что вы сможете придумать более удачные имена).

С этой моделью использование ViewModel делается значительно проще и легче:

class ProductListViewModel(
    private val getProductResults: GetProductResults,
    private val observeUserFullName: ObserveUserFullName
) : ViewModel() {

    private val _state = MutableStateFlow(
        ProductListScreenState(
            fullName = observeUserFullName().value,
            displayState = DisplayState.Loading
        )
    )
    val state = _state.asStateFlow()

    init {
        observeUserFullName().onEach { fullName ->
            _state.update { it.copy(fullName = fullName) }
        }.launchIn(viewModelScope)
    }

    fun loadProducts() {
        _state.update {
            it.copy(displayState = DisplayState.Loading)
        }
        viewModelScope.launch {
            getProductResults(startIndex = 0).fold(
                onSuccess = { results ->
                    _state.update {
                        it.copy(displayState = DisplayState.Content(results))
                    }
                },
                onFailure = {
                    _state.update {
                        it.copy(displayState = DisplayState.Error)
                    }
                }
            )
        }
    }
}

Как видите, мы можем обновлять только те поля, которые необходимо изменить, благодаря методу copy из классов данных, и при этом предотвращать возможные конфликтующие состояния.

Как это можно масштабировать?

Давайте усложним задачу, чтобы проверить, действительно ли такой подход к моделированию данных справляется со своей задачей:

  1. Мы не хотим перезагружать данные при повороте устройства.
  2. Мы внедряем действие pull-to-refresh; при обновлении мы показываем текущие товары с вторичным индикатором загрузки в верхней части экрана. Если обновление не удается, мы показываем обычный полноэкранный вид ошибки.
  3. Мы добавляем пагинацию; при пагинации мы показываем текущие товары с вторичным индикатором загрузки в нижней части экрана. Если пагинация не работает, мы отображаем представление ошибки в нижней части списка, позволяя пользователю повторить попытку.

Обновленная модель состояния теперь выглядит следующим образом:

data class ProductListScreenState(
    val fullName: String,
    val displayState: DisplayState? = null
)

sealed interface DisplayState {
    data object Loading : DisplayState
    data object Error : DisplayState
    data class Content(
        val productResults: ProductResults,
        val contentDisplayState: ContentDisplayState? = null
    ) : DisplayState
}

sealed interface ContentDisplayState {
    data object Refreshing : ContentDisplayState
    data object Paginating : ContentDisplayState
    data object PaginationError : ContentDisplayState
}

Здесь displayState является nullable: когда он равен null, мы должны загрузить исходные данные; в противном случае все дополнительные запросы на загрузку должны быть проигнорированы.

Refreshing и Paginating — это подсостояния Content, то есть мы можем обновлять или пагинацию только тогда, когда на экране уже отображается список товаров. Естественно, любой запрос, сделанный, пока мы не находимся в состоянии Content, должен быть проигнорирован!

Вот ViewModel:

class ProductListViewModel(
    private val getProductResults: GetProductResults,
    observeUserFullName: ObserveUserFullName
) : ViewModel() {

    private val _state = MutableStateFlow(
        ProductListScreenState(
            fullName = observeUserFullName().value,
            displayState = DisplayState.Loading
        )
    )
    val state = _state.asStateFlow()

    init {
        observeUserFullName().onEach { fullName ->
            _state.update { it.copy(fullName = fullName) }
        }.launchIn(viewModelScope)
    }

    fun loadProducts() {
        if (_state.value.displayState == null) {
            _state.update {
                it.copy(displayState = DisplayState.Loading)
            }
            fetchProducts()
        }
    }

    fun refresh() {
        onContentDisplayState { content ->
            _state.update {
                it.copy(
                    displayState = content.copy(
                        contentDisplayState = ContentDisplayState.Refreshing
                    )
                )
            }
            fetchProducts()
        }
    }

    fun paginate() {
        onContentDisplayState { content ->
            val productResults = content.productResults
            if (productResults.canLoadMore) {
                _state.update {
                    it.copy(
                        displayState = content.copy(
                            contentDisplayState = ContentDisplayState.Paginating
                        )
                    )
                }
                paginateProducts(productResults.products.size)
            }
        }
    }

    private fun fetchProducts() {
        viewModelScope.launch {
            getProductResults(startIndex = 0).fold(
                onSuccess = { results ->
                    _state.update {
                        it.copy(displayState = DisplayState.Content(results))
                    }
                },
                onFailure = {
                    _state.update {
                        it.copy(displayState = DisplayState.Error)
                    }
                }
            )
        }
    }

    private fun paginateProducts(startIndex: Int) {
        viewModelScope.launch {
            getProductResults(startIndex).fold(
                onSuccess = { results ->
                    _state.update {
                        it.copy(displayState = DisplayState.Content(results))
                    }
                },
                onFailure = {
                    onContentDisplayState { content ->
                        _state.update {
                            it.copy(
                                displayState = content.copy(
                                    contentDisplayState = ContentDisplayState.PaginationError
                                )
                            )
                        }
                    }
                }
            )
        }
    }

    private fun onContentDisplayState(block: (content: DisplayState.Content) -> Unit) {
        val displayState = _state.value.displayState
        if (displayState is DisplayState.Content) {
            block(displayState)
        }
    }
}

Довольно просто! Давайте разберем все по шагам.

fun loadProducts() {
        if (_state.value.displayState == null) {
            _state.update {
                it.copy(displayState = DisplayState.Loading)
            }
            fetchProducts()
        }
    }

Функция loadProducts теперь проверяет, не имеет ли displayState значения null. Это может произойти только при первом открытии экрана. Когда мы повернем устройство, ViewModel проигнорирует запрос на повторную загрузку данных.

Я сделал displayState nullable, но я мог бы также ввести новое DisplayState, скажем Idle, и проверять его вместо этого.

Я выбрал вариант, который требует меньше кода.

fun refresh() {
        onContentDisplayState { content ->
            _state.update {
                it.copy(
                    displayState = content.copy(
                        contentDisplayState = ContentDisplayState.Refreshing
                    )
                )
            }
            fetchProducts()
        }
    }

Функция refresh проверяет, находимся ли мы в состоянии Content. Если да, она устанавливает displayState в Content/Refreshing и снова загружает данные; если нет, она игнорирует запрос.

fun paginate() {
        onContentDisplayState { content ->
            val productResults = content.productResults
            if (productResults.canLoadMore) {
                _state.update {
                    it.copy(
                        displayState = content.copy(
                            contentDisplayState = ContentDisplayState.Paginating
                        )
                    )
                }
                paginateProducts(productResults.products.size)
            }
        }
    }

Функция paginate проверяет, находимся ли мы в состоянии Content. Если да, она устанавливает displayState в Content/Paginating и загружает следующий набор продуктов; если нет, она игнорирует запрос. Если пагинация не удалась, устанавливается Content/PaginationError.

Состояния Refreshing, Paginating и PaginationError являются дочерними состояниями Content. Поэтому я добавил поле contentDisplayState внутри Content, чтобы сделать эти дочерние состояния взаимоисключающими, сохранив при этом общие данные productResults как часть класса данных Content. Эта же концепция применяется к ScreenState, которая теперь снова применяется к дочернему состоянию.

Я сделал этот contentDisplayState нулевым, чтобы можно было отделить состояние чистого контента от состояния пагинации/обновления. Опять же, я мог бы ввести еще один объект contentDisplayState, но вариант с nullable потребовал меньше кода.

Заключение

В этой статье мы рассмотрели различные подходы к моделированию состояния ViewModel в Android. Вместо того чтобы придерживаться какого-то одного паттерна, полезно использовать сильные стороны нескольких паттернов и смешивать их вместе. Такой смешанный подход не только улучшает сопровождаемость и читаемость, но и позволяет адаптировать управление состоянием к будущим требованиям.

Источник

Exit mobile version