Применение FSM в управлении изменениями состояний пользовательского интерфейса

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

«Задание: переведите на геополитический, убрав элементы hate speech. Зачитайте с лицемерной улыбкой» Виктор Пелевин, «Зенитные кодексы Аль-Эфесби»

В этой статье собраны мои размышления по поводу обустройства состояния View в Android. Так, одна View имеет несколько состояний. Вот так сложилось. View одна, а показывает разное. Например, мы получаем данные из сети — кто этого не делал, бросьте в меня дизлайком — и имеем такую цепочку состояний View:

Состояние

Состояние View

читаем данные из источника (DataSource)

отображается лоадер LoadDataState отображается ошибка LoadErrorState

отображение списка данных

отображается список ListShowState отображение экрана пустого списка ListEmptyState

Пример логики фрагмента из приложения (тест слуха, работа со списком пройденных тестов)

Для такого простого варианта использования у нас уже есть целых четыре состояния.

А теперь немного страшилок правды жизни.

Представьте, сюда добавляется новая фича. Например: вдруг понадобилось получить список отсортированных/отфильтрованных результатов на том же экране. Или, если ваше приложение было в режиме без зарегистрированного пользователя, то ему нужно/не нужно показывать маркетинг-баннер, а теперь этот пользователь вошел в приложение, и вам необходимо показать еще что-то. Ну и заодно нужно получить из локальной базы немного других данных, проверить соответствие с данными зарегистрированного пользователя, при этом показать состояние проверки. А как насчет загрузить из сети еще немного данных, при необходимости разбивая их на страницы?

Эти состояния со временем множатся по мере роста и совершенствования сценариев и требований. Каждый раз показывать результаты жизнедеятельности приложения нужно хоть немного, но по-разному. Да, и чаще всего этот праздник жизни происходит асинхронно (независимо).

И кстати, если вы не пишете тесты для проверки правильности логики рендеринга состояния, то из-за увеличения количества состояний вероятность ошибки возрастает, а искать такие ошибки становится труднее.

Тесты также сэкономят ваше время на ручное тестирования каждого сценария, при проведении регресса, при добавлении нового состояния, тестировании разных комбинаций данных/отображения. Особенно, когда переход к конкретному виду экрана скрыт за несколькими шагами. Или наблюдается при определенном стечении обстоятельств: ретроградный меркурий, определенная комбинация данных и состояния системы. Бывает еще состояние гонки, оно же “гейзенбаг”.

Насколько неудобно работать с непокрытым тестами кодом можно посмотреть, выполнив чекаут на коммит.

Переход по состояниям там происходит с вероятностью 50%. Попробуйте найти ошибку в коде, проверяя результат выполнения — почувствуйте себя в казино.

Соответственно, нужно же как-то все это обобщить, применить паттерн (а то и два). Да и вообще — оставить такое безобразие без концепции будет неправильно.

Итак, вашему вниманию предлагается: пример построения единого состояния для View — уровня представления, который представляет все данные, необходимые для выбора способа визуализации пользовательского интерфейса. Я продемонстрирую, как можно абстрагироваться от логики состояния отображения данных с помощью интерфейсов, а также эту логику протестировать. Данное решение построено с учетом асинхронности процессов, происходящих с данными. Для уменьшения вероятности “гейзенбага” (полностью убрать сложно, ибо лапки) и для увеличения количества баззвордов над кодом произнесено заклинание SST (single source of truth).

И сразу disclaimer: это не идеальное решение для каждого приложения и каждого View. Свое мнение по этому вопросу приглашаю высказать в комментариях.

На берегу обозначим условия, которым должно отвечать решение (сценарии):

  1. Объект состояния должен иметь однозначное сопоставление с состоянием пользовательского интерфейса.

  2. Цепочка вызовов для созданного состояния не должна изменяться со временем.

  3. Добавление новых состояний не должно влиять на существующие состояния.

  4. При добавлении нового объекта состояния должна быть предусмотрена защита от случайного “несопоставления” с состоянием пользовательского интерфейса.

  5. Логика выбора состояния должна быть покрыта тестами

  6. Метод отображения состояния должен быть единственным способом перенастроить View, т.е быть “единственным источником изменений”

Этот концептуальный перформанс будет происходить с использованием Android Architecture Components — ViewModel и, соответственно, мы будем придерживаться терминологии, принятой в паттерне MVVM. Так как существует терминологическая разница с определениями, принятыми в MVI и MVVM, потом мы рассмотрим, чем же то, что получили, похоже на MVI.

Немного теории: Что такое MVVM паттерн?

MVVM — это архитектурный паттерн, расшифровывается как Model-View-ViewModel. Произошел от MVP, описанного в 1990году.

Правда, если расположить буквы в порядке их взаимодействия, то получится View-ViewModel-Model, потому что в реальности ViewModel и находится именно посередине, соединяя View и Model.

Для интересующихся приведу историческую справку о развитии легалайза появлении трех- и четырехбуквенных “заклинаний”. А также дам описание их отличий:

  • MVC: описан в 1979 году. Доступ к данным (Model) есть и у View, и у контроллера. То есть View может перерисовываться самостоятельно лишь на основании изменения данных в Model или взаимодействия пользователя с View (нажатие на экран), или сама изменить данные в Model и только сообщить контроллеру об этом. Controller может изменять состояние View на основании своих источников данных (например, внешние запросы, нажатие на кнопку) и также может менять Model.

  • MVP: описан в 1990году. Presenter является посредником между View и Model. View и Model меняются данными через установленное API. Отображение во View зависит только от данных, которые установил Presenter.

  • MVVM: представлен в 2005 году. ViewModel также является посредником между View и Model. Но ViewModel не может напрямую воздействовать на View, а лишь является источником данных и имеет возможность через функции вызова передавать актуальные данные. То, что отображать, определяется на уровне View.

Подробнее — в этой статье.

View — это абстракция для Activity, Fragment или любой другой кастомной View (Android Custom View). View должна максимально абстрагироваться от реализации и данных, мы не должны писать какую-либо логику в неё. Также View не должна знать ничего о других частях программы. Она должна хранить ссылку на экземпляр ViewModel, и все данные, которые нужны View, должны приходить оттуда.

ViewModel (или в терминах clean — Interactor). Отвечает за прием ввода от View и отдаче обработанных и подготовленных данных для отображения в View.

Model (или в терминах clean — Use Case). Одна или несколько моделей преобразуется и взаимодействуют с ViewModel. Обрабатывают специфические операции с данными и логику для других фундаментальных событий системы.

Подводя итог:

View — отвечает за вид и отображение данных, взаимодействие с пользователем. ViewModel — за обработку взаимодействия с пользователем, которые содержат данные и логику о том, когда эти данные должны быть получены и когда показаны. Model — содержит логику обработки специфических операций с данными и логику для других фундаментальных событий системы.

Немного теории: состояния и ДКА (FSM)

Мы услышали в начале о состояниях View. И чего-то куда-то переходит. Хм, все-таки, универ — это сила, а ее мало не бывает. На парах я что-то такое слышал. Да это же о конечном автомате или коротко — о КА (SM или State Machine)!

Что такое конечный автомат? Все — от простых поведенческих шаблонов до распределенных систем — содержит их.

Давайте разберем подробнее. В коде регулярно встречаются переключательные функции. Это относительно примитивная абстракция без собственной памяти: на вход аргумент, на выходе некое значение. Выходное значение зависит только от входного. Пример: switch или if-else.

Но частенько в реальном мире приложение может находиться в одном из нескольких состояний, которые сменяют друг друга. И на одинаковые воздействия реагировать по-разному. Т.е нам необходимо, чтобы последующее значение функции зависело от предыдущего. А ведь иногда нужно учитывать нескольких предыдущих.

Тут уже приходим к некой абстракции с собственной памятью. Это и называется автомат. Значение на выходе автомата зависят от значения на входе и текущего состояния автомата. Если у этого автомата количество значений на выходе конечно,то это конечный автомат (SM). Вот и первая аббревиатура из 2х букв. Но ДКА (FSM) вроде трехбуквенная?

Простейший SM, в котором может быть одно состояние в текущий момент времени, обладает детерминированностью. Детерминированность означает, что для всех состояний имеется максимум и минимум одно правило для любого возможного входного символа, то есть, например, для “состояния 1” не может быть двух переходов с одной и той же входной последовательностью.

Для полноты описания вспомним о существованиии недетерминированных конечных автоматов (НКА или же NFA, Nondeterministic Finite Automaton). Выражаясь проще, в NFA добавлен синтаксический сахар в виде свободных переходов, недетерминированности и множеств состояний. NFA может быть представлен некоторой структурой из FSM. Например, построение FSM эквивалентного NFA по алгоритму Томпсона.

Преимущества FSM:

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

  • Нет фатального недостатка.

  • Возможность сохранения состояния.

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

Как строим FSM?

Берем большое сложное облако состояний задачи и как палку салями разрезаем на ломтики — на маленькие дискретные состояния и размечаем граф связей (переходов) между ними Для нашей задачи подходит FSM с жестко заданными (в классе MainFragmentUiStatesModel) состояниями. Модифицируемый во время выполнения автомат тоже реализован. Пример находится в том же репозитории и, возможно, будет рассмотрен в спин-оффе этой статьи.

Немного теории: Что такое MVI?

Как написал автор, MVI это попытка переосмысления и возврата к истокам, к истинному MVC.

MVI представлен в 2015 году. Добавлено и изменено по сравнению с MVVM:

  • Intent — функция, которая принимает входные данные от пользователя (например, события пользовательского интерфейса, такие как события click) и переводит в то, что будет передано как параметр функции model(). Это может быть простая строка для установки значения модели или более сложная структура данных, например, объект.

  • Model — функция, которая использует выходные данные из функции intent() в качестве входных данных для работы с моделью. Результат работы этой функции — новая модель (с измененным состоянием). При этом нужно, чтобы данные были неизменяемыми. По сути, модель осуществляет вызов бизнес-логики приложения (будь-то Interactor, UseCase, Repository) и в результате возвращает новый объект модели.

  • View — функция, которая получает на входе модель и просто отображает ее. Обычно эта функция выглядит как view.render(model).

Подробнее про группу архитектур, объединенную идеей “однонаправленного потока данных”, см. здесь. Подробно и с картинками — здесь.

Немного теории: Дополняем MVVM

Состояния

Описаны в классе MainFragmentUiStatesModel. Он объявлен в виде sealed class. Так сделано потому что каждая из реализаций sealed class сама по себе является полноценным классом. Это означает, что каждый из них может иметь свои собственные наборы свойств независимо друг от друга. Заодно этим синтаксическим сахаром мы посыпаем условие 4 из наших сценариев: “При добавлении нового объекта состояния должна быть предусмотрена защита от случайного “несопоставления” с состоянием пользовательского интерфейса”.

Граф переходов

Держать во ViewModel не только состояние, но и перечень состояний и логику вызова функций переключения настроек View при переходе из состояния в состояние как-то не очень удобно. Их неплохо бы вынести отдельно, чтобы избежать дублирования и соблюсти принцип единственной ответственности.

Для этого у View должен быть какой-то контракт, согласно которому View будет подстраиваться для правильного отображения данных, учитывая состояния из ViewModel. Соответственно, ViewModel не заботит, как именно View отображается в данный момент. После изменения состояния, View, согласно контракта, изменяет настройки своих элементов, чтобы правильно отображать данные из ViewModel. Итак, граф переходов и события (как перестраивать View) помещаем в Contract к View.

Repository

В Android-сообществе распространено определение Repository как объекта, предоставляющего доступ к данным.

Источник иллюстрации

А теперь проговорим все вместе

У View появляется контракт, который отвечает за состояния и логику их отображения. Метод для переключения настроек View для различных состояний определен в контракте и функции переключения настроек описаны в View, которая реализует MainFragmentViewStatesRenderContract. Состояния находятся в MainFragmentUiStatesModel.

ViewModel — это абстрактное имя для класса, содержащего данные и логику их подготовки к отображению; логику, когда эти данные должны быть получены и как показаны. Также ViewModel хранит текущее состояние. В примере это mViewState со значениями типа класса MainFragmentUiStatesModel. Когда ViewModel изменяет mViewState, View, которая получает уведомление об этом изменении состояния, использует контракт, чтобы определить, как при данном состоянии показывать себя, вызывая функцию настройки под состояние.

Также ViewModel хранит ссылку на одну или несколько DataModel. В нашем случае это ExampleRepository. Все данные ViewModel получает от них.

Благодаря этому ViewModel не знает, к примеру, откуда получает данные Repository — из базы данных или из сервера. Кроме того, ViewModel ничего не знает о View и знать не должна.

Итого

Слой ViewModel генерирует события. View, согласно контракта, подстраивается под состояние. Данные берутся из репозитория (Repository).

То, что получилось после дополнения “каноничного” MVVM, очень напоминает другое трехбуквенное слово — MVI (Model-View-Intent). В общем-то, разница во многом терминологическая, идеологически это тоже попадает в тренд «однонаправленного потока данных», т.к. похожие начальные условия приводят к похожести реализаций. Подробнее мы это рассмотрим во второй части, когда будем рассматривать реализацию ViewModel.

В такой реализации мы выполняем условие с 1 по 3 и 6 из наших сценариев: 1. Объект состояния должен иметь однозначное сопоставление с состоянием пользовательского интерфейса. 2. Цепочка вызовов для созданного состояния не должна изменяться со временем. 3. Добавление новых состояний не должно влиять на существующие состояния. 6. Метод отображения состояния должен быть единственным способом перенастроить View т.е быть “единственным источником изменений”

Начнем. Написание тестов

Shu Ha Ri, три желания... TDD также не миновала чаша сия — тут тоже присутствует магия трех:

  1. Написание теста, дающего сбой, для небольшого фрагмента функционала.

  2. Реализация функционала, которая приводит к успешному прохождению теста.

  3. Рефакторинг старого и нового кода для того, чтобы поддерживать его в хорошо структурированном и читабельном состоянии.

TDD еще называют циклом «красный, зеленый, рефакторинг» («Red, Green, Refactoring»).

Приготовимся

Начнем с написания тестов на контракт View.

Напоминаю: класс, в котором находятся View состояния,это MainFragmentUiStatesModel. Соответственно контракт MainFragmentViewStatesRenderContract по умолчанию содержит:

  • метод render(viewState: MainFragmentUiStatesModel);

  • и по одному методу переключения настроек View к соответствующему состоянию:

  • showIni() к IniState

  • showLoadCounterPercentData к LoadCounterPercentDataState

  • и т.д

Хозяйке на заметку: в Android Studio есть комбинация клавиш Ctrl+Shift+T (⇧⌘T) на которой висит меню автогенерации теста.

Используем заклинание автогенерации теста на MainFragment и выбираем для теста все методы из MainFragmentViewStatesRenderContract:

Состояние кода после автогенерации теста, см. коммит

Настройка тестового класса

Указываем перед наименованием класса, что мы будем использовать Mockito: @RunWith(MockitoJUnitRunner::class)

Мы проверяем contract и нам нужно создать его как пременную реализующего интерфейс MainFragmentViewStatesRenderContract. Поскольку Mockito не может проверять вызовы методов через интерфейс, нам придется реализовать его в пустом классе. Назовем его MockForTestMainFragmentViewStatesRenderContract, обьявим как open class и позаботимся о том, чтобы аннотировать его с помощью @Spy.

Красный. Первый тест: функция testRenderInitState()

Хозяйке на заметку: Хорошим тоном считается разбивать тесты на три части: Настройка (Setup), Действие (Act) и Проверка (Assert).

Настройка (Setup): Эта часть пуста, так как особо нечего инициализировать.

Действие (Act): Обычно это вызов функции, которую мы тестируем. В этом случае мы проверяем, может ли функция render() вызвать метод переключения настроек View IniState.

Проверка (Assert): Проверяем, что правильная функция у contract вызывается после вызова render(), а другие не вызываются: // Assert verify(contract).showIni() verify(contract, never()).showLoadCounterPercentData(any()) verify(contract, never()).showLoadError(any()) verify(contract, never()).showListEmpty() verify(contract, never()).showListShow(any())

Запускаем тест-класс на проверку:

Результат выполнения:

Чего и следовало ожидать. Состояние кода на этот момент: см. коммит.

Зеленый

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

fun render(viewState: MainFragmentUiStatesModel) { showIni() }

Достаточно одной строчки :)

Состояние кода на этот момент: см. коммит.

Следующая итерация

Красный

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

@Test fun testRenderLoadCounterPercentData() { // Act contract.render(MainFragmentUiStatesModel.LoadCounterPercentDataState(50)) // Assert verify(contract, never()).showIni() verify(contract).showLoadCounterPercentData(50) verify(contract, never()).showLoadError(any()) verify(contract, never()).showListEmpty() verify(contract, never()).showListShow(any()) }

@Test fun testRenderLoadError() { // Act contract.render(MainFragmentUiStatesModel.LoadErrorState("Error")) // Assert verify(contract, never()).showIni() verify(contract, never()).showLoadCounterPercentData(any()) verify(contract).showLoadError("Error") verify(contract, never()).showListEmpty() verify(contract, never()).showListShow(any()) }

@Test fun testRenderListEmpty() { // Act contract.render(MainFragmentUiStatesModel.ListEmptyState) // Assert verify(contract, never()).showIni() verify(contract, never()).showLoadCounterPercentData(any()) verify(contract, never()).showLoadError(any()) verify(contract).showListEmpty() verify(contract, never()).showListShow(any()) }

@Test fun testRenderListShow() { // Act contract.render(MainFragmentUiStatesModel.ListShowState(ArrayList())) // Assert verify(contract, never()).showIni() verify(contract, never()).showLoadCounterPercentData(any()) verify(contract, never()).showLoadError(any()) verify(contract, never()).showListEmpty() verify(contract).showListShow(any()) } Свистим — таракан не бежит Результат выполнения:

Состояние кода на этот момент: см. коммит.

Зеленый

Добавим в MainFragmentViewStatesRenderContract немного кода — только чтобы тест прошел:

fun render(viewState: MainFragmentUiStatesModel) { when (viewState) { is MainFragmentUiStatesModel.IniState -> { showIni() } is MainFragmentUiStatesModel.LoadCounterPercentDataState -> { showLoadCounterPercentData(viewState.percent) } is MainFragmentUiStatesModel.LoadErrorState -> { showLoadError(viewState.errorCode) } is MainFragmentUiStatesModel.ListEmptyState -> { showListEmpty() } } }

Я пропустил покрытие ListShowState состояния и компилятор мне напомнил об этом:

Стоит отметить удобство синтаксического сахара — MainFragmentUiStatesModel объявлен как sealed class, и теперь компилятор контролирует полноту покрытия всех состояний методами переключения настроек View в директиве типа when (viewState). Хотя мы и предусмотрели отсутствие возможности дублирования кода для этого выбора и покрыли его тестами, но все равно это удобное и полезное свойство.

Добавляю покрытие состояния ListShowState:

is MainFragmentUiStatesModel.ListShowState -> { showListShow(viewState.listItem) }

Запускаю тесты:

Вывод

Теперь в нашем View нет кода, отвечающего за переключение настроек. Реализация всех методов отображения переключения состояний контролируется нашим контрактом. Код, отвечающий за переключение, находится там же. Его легко задействовать для поддержки схожих состояний в других View проекта.

Также мы выполняем условие 5 из наших сценариев: “Логика выбора состояния должна быть покрыта тестами”.

И еще один из плюсов использования TDD для написания кода — просмотр последовательности коммитов облегчит понимание логики построения кода.

Код примеров, рассматриваемых в статье, находится здесь.

Буду рад общению по теме (и не по теме тоже :)) в комментариях.

Графоманские Творческие планы

В следующей части статьи мы приступим к реализации ViewModel и Repository. В них мы будем использовать Flows, элементы Functional Programming (по ссылке, кстати, замечательная статья о вопросе функционального программирования на Kotlin), используя библиотеку от Arrow. Используем функциональное программирование в гомеопатических дозах, а именно — тип Either для работы с источниками данных и обработки ошибок в функциональном стиле и в парадигме «однонаправленного потока данных». И, конечно, пойдем по дороге с облаками с TDD.

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

Написать автору, что функциональщина это не “дорога с облаками”, а Lucy In The Sky With Diamonds всегда можно в комментариях.

Источник: https://habr.com/ru/company/epam_systems/blog/549524/


Интересные статьи

Интересные статьи

Тема проведения Man in the Middle атаки в контексте Captive Portal стара как мир. Как правило ведется речь о поднятии фейковой беспроводной точки доступа с собственным Captive порта...
Disclaimer: Я не имею никакого отношения к JetBrains, не получаю денег от продвижения Kotlin или от написания данной статьи. Весь материал — это лишь отражение моего личного опыта. В...
Однажды, в понедельник, мне пришла в голову мысль — "а покопаюсь ка я в новом ядре" (новым относительно, но об этом позже). Мысль не появилась на ровном месте, а предпосылками для нее стали: ...
В статье пойдет речь об опыте разработки программы для составления эффективного портфеля облигаций с точки зрения минимизации его дюрации. Возможно, я не буду оригинален и для всех, кто инвестиру...