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

 


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

Комментариев нет:

Отправить комментарий