Разработка
Моделирование состояния ViewModel в Android: чистый, масштабируемый паттерн
В этой статье мы рассмотрели различные подходы к моделированию состояния 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 ) } } ) } } }
У этого подхода есть несколько недостатков:
- Модель допускает конфликтующие состояния: и
isLoading
, иisError
могут быть одновременно установлены вtrue
, в результате чего пользовательский интерфейс будет одновременно показывать и загрузку, и ошибки. А теперь представьте, что на экран добавляются дополнительные состояния, напримерisRefreshing
илиisPaginating
. Такая настройка может привести к 2⁴ возможным комбинациям булевых значений для экрана, который на самом деле имеет только пять различных состояний — остается девять недопустимых комбинаций. - Каждый раз, когда ViewModel устанавливает состояние, мы должны убедиться, что все остальные булевы значения установлены в
false
, что влечет за собой большое количество шаблонного кода. Кроме того, при добавлении нового состояния нам нужно рефакторить весь код установки состояния, чтобы включить в него новое булево значение, что еще больше увеличивает накладные расходы на обслуживание. - На уровне представлений (будь то 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 интерфейсом
У каждого подхода есть свои достоинства и недостатки:
- Подход с использованием простого класса данных страдает от проблем с читабельностью и риска конфликтующих состояний.
- Подход с 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
из классов данных, и при этом предотвращать возможные конфликтующие состояния.
Как это можно масштабировать?
Давайте усложним задачу, чтобы проверить, действительно ли такой подход к моделированию данных справляется со своей задачей:
- Мы не хотим перезагружать данные при повороте устройства.
- Мы внедряем действие pull-to-refresh; при обновлении мы показываем текущие товары с вторичным индикатором загрузки в верхней части экрана. Если обновление не удается, мы показываем обычный полноэкранный вид ошибки.
- Мы добавляем пагинацию; при пагинации мы показываем текущие товары с вторичным индикатором загрузки в нижней части экрана. Если пагинация не работает, мы отображаем представление ошибки в нижней части списка, позволяя пользователю повторить попытку.
Обновленная модель состояния теперь выглядит следующим образом:
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. Вместо того чтобы придерживаться какого-то одного паттерна, полезно использовать сильные стороны нескольких паттернов и смешивать их вместе. Такой смешанный подход не только улучшает сопровождаемость и читаемость, но и позволяет адаптировать управление состоянием к будущим требованиям.