Connect with us

Разработка

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

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

Опубликовано

/

     
     

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Решение:

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

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

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

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

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

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

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

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

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

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

Вот ViewModel:

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

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

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

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

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

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

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

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

Заключение

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

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: