Плохо спроектированные модели создают каскад проблем для каждого компонента, который от них зависит. В случае моделей представления, если они не соответствуют реальным потребностям экрана, другие компоненты (например, ViewModel) вынуждены обходить эти ограничения, что приводит к раздутым, трудным в сопровождении классам, заполненным хаками и обходными путями. Это несоответствие вносит неясность и путаницу, делая код неочевидным и подверженным ошибкам, что увеличивает затраты на его поддержку.
Рассмотрим два популярных способа моделирования состояния ViewModel:
//🎯 Подход 1
data class ScreenState(
val isLoading: Boolean,
val isError: Boolean,
val data: Data?
)
//🎯 Подход 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
)
Представьте страницу со списком товаров, где данные загружаются из удалённого источника. Во время загрузки отображается спиннер, в случае ошибки показывается экран ошибки с возможностью повторной попытки. Это стандартный сценарий «Загрузка-Контент-Ошибка».
Минусы этого подхода:
- Модель допускает противоречивые состояния: например,
isLoading
и isError
могут быть true
одновременно, что приведёт к отображению двух экранов сразу. - Если добавить дополнительные состояния (например,
isRefreshing
или isPaginating
), можно получить 2⁴ возможных комбинаций булевых значений, тогда как реально экран поддерживает только 5 состояний. - Каждый раз, когда мы обновляем состояние, приходится сбрасывать все остальные флаги, создавая лишний код.
- Разработчики, работающие с UI, могут запутаться, какие состояния действительно возможны, и будут вынуждены заглядывать в код ViewModel для уточнения.
Улучшение через enum
data class ProductListScreenState(
val displayState: DisplayState,
val productResults: ProductResults?
)
enum class DisplayState {
LOADING, CONTENT, ERROR
}
Этот подход значительно улучшает ситуацию, но всё ещё имеет недостатки. Например, CONTENT
подразумевает, что productResults
не должен быть null
, но это никак не контролируется.
Подход 2: sealed interface
sealed interface ProductListScreenState {
data object Loading : ProductListScreenState
data object Error : ProductListScreenState
data class Content(val productResults: ProductResults) : ProductListScreenState
}
Этот вариант решает проблему противоречивых состояний, так как каждое состояние явно представлено. Однако он затрудняет совместное использование данных между состояниями.
Проблема: если нам нужно отображать приветственное сообщение с именем пользователя, нам придётся хранить это значение отдельно и вручную синхронизировать его со всеми состояниями.
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
}
Такой код становится громоздким и сложным в обслуживании.
Финальный подход: Объединение data class
и sealed interface
Используем дата-класс для данных, присутствующих во всех состояниях, и sealed interface
для состояний, которые взаимоисключаемы.
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
}
Масштабируемость модели
Допустим, нам нужно добавить поддержку:
- Перезагрузки данных при повороте экрана.
- 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
}
Теперь состояния чётко структурированы и масштабируемы, а код ViewModel остаётся простым и лаконичным.
Вывод:
Этот гибридный подход объединяет лучшие качества data class
и sealed interface
, обеспечивая чистоту, масштабируемость и удобочитаемость кода. 🎯