Бизнес-логика – это сложно. Сложная бизнес-логика — ещё сложнее. А описать всё это в коде – просто жесть. Мы с вами каждый день реализуем тонну разных сценариев с огромным количеством веток развития. Каждую ветку нужно запрограммировать, потом суметь быстро поправить, а когда придёт продакт, еще и поменять ее логику. И если писать код просто как он пишется, можно оказаться в ситуации, когда простой фикс вместо 20 минут занимает 6 дней. Это проблема.
В этой статье мы поговорим про паттерн проектирования, который сможет собрать осколки логики в одно красивое полотно, а затем будет долго и успешно поддерживать его целостность. Поехали!
Deus ex state-machina
Вспомните любой описанный вами экран – там могут отображаться загрузка, ошибки, какие-то данные, в целом всё довольно типично. Но вот процесс перехода из одного состояния в другое уже может вызывать вопросы. Если на экране происходит много разных событий и нужно учитывать множество факторов, делать тонну запросов – то нам необходимо удерживать в голове довольно много разной информации, чтобы сделать всё правильно и не забыть обработать какой-нибудь сценарий. Вот тут на помощь и приходят стейт-машины.
Идея стейт-машины очень проста и построена на ограничениях. Мы ограничиваем количество возможных сочетаний данных и четко прописываем из какого набора в какой мы перейти можем, а в какой нет. Вся прелесть этого принципа в том, что его можно использовать при работе с любыми наборами данных, без каких-либо дополнительных фреймворков и в любой архитектуре.
В дикой природе существует множество типов стейт-машин, я не стану углубляться в их академическое описание или четкие UML-нотации. Сегодня я хочу поговорить про практическое применение, а для этого буду использовать максимально упрощенные диаграммы состояний. Если кому-то будет интересно копнуть поглубже, то можно для начала поискать информацию об автоматах Мили и Мура – просто погуглите “Finite-state machine”, "Конечный автомат" или черканите в наш чат в телеге. Кстати пишите в комментариях, если хотите отдельную статью про все это.
Типичная стейт-машина представляет собой систему, в которую мы отправляем управляющие сигналы. Она их обрабатывает, и на выходе мы получаем новое состояние – тот набор данных, который мы храним продолжительное время. Кроме того, при необходимости стейт-машина может отправлять исходящий сигнал.
Представьте себе автомат для парковки. При выезде, в паркомате вы вводите номер машины и система предлагает оплатить за парковку 300 рублей. Вы вставляете 500 рублей и система для вашей машины меняет состояние на “Оплачено”. Вы можете свободно уезжать. Но вы заплатили больше, чем надо и система отправит сигнал, выдать сдачу в 200 рублей. Сдача не имеет никакого отношения к системе парковки и может не храниться в стейте, но это будет событие.
При использовании паттерна стейт-машины можно получить очень большой профит, когда из одного состояния системы нельзя просто взять и сразу перейти в другое. Обязательно нужно пройти все промежуточные точки.
Это очень удобно, потому что нам надо описать только один “правильный” сценарий работы с системой. А “неправильными” автоматически становятся все остальные, их мы можем обрабатывать все вместе. Когнитивная нагрузка снижается, нам становится проще работать – профит!
А сейчас самое интересное. Рассмотрим конкретный пример, который хорошо иллюстрирует как можно выстроить работу со стейтами.
Стейт-машина в деле: проектирование
Пусть у нас появилась бизнес-задача: после звонка из приложения нужно собирать фидбек у пользователей и предлагать им некоторые быстрые действия. Для этого надо знать, что пользователь позвонил по номеру и что звонок завершился. Но есть проблема: отследить факт звонка в iOS не так-то просто.
Разберемся, что происходит во время звонка. Никакого системного API для работы со звонками не существует. Есть просто URL ссылка с номером телефона – и всё. Именно такую ссылку мы открываем при нажатии на номер телефона в приложении. После открытия этой ссылки система запрашивает подтверждение. И тут важно понимать, что алерт, который мы видим на экране не принадлежит нашему приложению. В момент его появления срабатывает системное событие willResignActive.
После выбора любой опции происходит возврат в приложение, и если пользователь нажал на номер телефона, то начинается звонок. Звонок может длиться некоторое время и после его завершения мы опять попадаем в наше приложение. Звучит не очень понятно, но что если представить всё это в виде схемы состояний и переходов?
Для начала выделим все состояния, которые мы могли наблюдать во время звонка. Их можно легко описать с помощью простого перечисления. Важно понимать, что в нашем трекере мы не можем перейти сразу из состояния Start в состояние CallInProgress. Нам обязательно нужно пройти через все промежуточные точки. Именно работу с такими четкими ограничениями нам и позволяет проводить стейт-машина.
Следующим шагом я выделил события жизненного цикла приложения. Это наш единственный способ отследить действия системы.
Еще нам понадобятся два наших собственных события. Одно, чтобы отслеживать факт начала звонка, а второе – для понимания, что пользователь во всплывающем окне нажал “Отмена”.
Сейчас поясню. Наш трекер всегда слушает системные события. И чтобы понять, что событие willRisignActive произошло из-за звонка, а не потому что мы просто свернули приложение, нам нужен специальный триггер. Именно этим триггером выступает событие trackOutgoingCall. Оно отправляется в трекер непосредственно перед открытием URL схемы, и наша стейт-машина переходит в состояние Start.
После этого ждем, когда iOS перехватит контроль и покажет пользователю алерт. Если пользователь подтверждает звонок, мы возвращаемся в приложение и после этого почти сразу опять из него уходим.
На схеме видно, что мы разбили процесс начала звонка на два этапа. В начале, по willResignActive мы переводим звонок в CallStarting. А потом, после события didEnterBackground мы считаем, что звонок начался.
После разговора, происходит тоже самое, но в обратном порядке. По событию willEnterForeground отмечаем, что звонок завершается. А после didBecomeActive, когда приложение снова целиком наше, мы считаем, что звонок полностью завершен и система готова к следующему. Мы не знаем как прошел звонок пользователя. Поговорил ли он, был ли абонент занят или просто не снял трубку. Мы отмечаем, только факт попытки.
Давайте вернемся немного назад, к негативному сценарию. Когда пользователь в алерте нажал “Отмена”. В такой ситуации мы просто вернемся в приложение и больше ничего не произойдет. А наш трекер через секунду перейдет в состояние None.
Стейт-машина в деле: реализация
С проектированием разобрались, переходим к реализации. Я не буду полностью показывать код всего трекера. Остановлюсь только на одном интересном моменте: некоторые события жизненного цикла в нашей схеме приходят дважды. Например, didBecomeActive. Первый раз, после отображения алерта:
И второй после окончания звонка:
Когда мы находимся в состоянии SystemAlert, по приходу didBecomeActive запускается таймер, и мы переходим в состояние Waiting. А из состоянии CallEnded мы переходим в None и отправляем сигнал, что звонок завершен. Если в любом другом стейте нам приходит состояние didBecomeActive, мы сбрасываем наш трекер в None. Значит, что-то пошло не так.
Как видите, сам паттерн стейт-машины можно применять без каких-либо дополнительных библиотек. На его основе можно легко собрать enum или структуру UIState для описания компонентов экрана и для их изменения. В CallTracker мы использовали только enum – для описания состояния, и методы – для описания сигналов-переходов.
Еще одно удобство стейт-машины – все переходы очень легко логировать и тестировать. А поскольку стейт-машина – это своеобразный черный ящик, нам часто бывает достаточно просто протестировать преобразование входного сигнала в выходной, с учетом начального состояния. В случае с CallTracker мы, допустим, находимся в состоянии "Звонок в процессе", и вдруг приходит сигнал, что звонок окончен – значит мы должны перейти в новое состояние.
Где еще пригодится стейт-машина
Примеров можно привести очень много. Этот паттерн можно применить везде, где есть несколько конкретных состояний системы или где следующее состояние зависит от текущего.
Возьмем что-то совсем из другой области, например, музыкальный плеер. В его стейт войдут: состояние плеера, играет ли сейчас музыка или нет, плейлист, метаданные выбранной композиции и возможно какая-нибудь временная метка. Входящими событиями будут: нажатия на кнопки play, pause, stop, переход к следующему треку и так далее.Мы сможем легко обрабатывать неочевидные действия пользователя. Нажатия несколько раз на stop будут игнорироваться. А нажатия несколько раз на play будут запускать или останавливать музыку, в зависимости от текущего стейта.
Еще один пример из мира программирования – описание конечных автоматов для разбора регулярных выражений. По сути это та же стейт-машина, где описываются переходы из одних правильных состояний в другие. Такие автоматы часто используются для лексического анализа разных программ.
Но есть и противоположная ситуация. Если в вашем сервисе нет хранимых состояний, а есть только моментальные события, то использование стейт-машины может быть излишним. Ещё этот паттерн может неоправданно усложнить код, если состояний мало и они редко меняются. Например: провайдеры данных, различные хранилища и так далее. Также нецелесообразно будет строить свой вариант NotificationCenter или базы-данных на стейт-машине.
Если мы будем говорить про системный подход для описания бизнес-логики, то использовать стейт-машину в чистом виде не очень удобно, желательно использовать какую-то конкретную ее реализацию. На GitHub довольно много разных библиотек, даже у Apple есть свой вариант реализации стейт-машины, как часть фреймворка GameKit. Но без кучи расширений ее использовать не очень удобно, я пробовал.
Что в итоге
Стейт-машина – это полезный паттерн, который поможет вам запрограммировать множество состояний и переходов между ними когда это необходимо. Паттерн не привязан к каким-либо фреймворкам и библиотекам, поэтому вы можете реализовать его на обычных enum-ах или классах. Однако, повсеместное использование стейт-машины может слишком усложнить ваш код, поэтому правильно оценивайте необходимость ее внедрения.
Комментарии (3)
tarekd
22.04.2022 00:35Спасибо за обзор! Ожидал увидеть IOS фреймворк для описания конечных автоматов, a увидел switch'и.
Недавно изучал возможности C++17 и накидал следующее, позволяет полностью сфокусироваться на определении состояний и переходов конечного автомата.fsm.cpp
#include <variant> // Finite State Machine template <typename... STATES> class FSM { public: using State = std::variant<STATES...>; template <class Event> auto handle(Event e) { state_ = std::visit(e, state_); } private: State state_; }; // Usage example #include <chrono> #include <iostream> #include <thread> namespace stopwatch { namespace state { struct Reset { Reset() { std::cout << "Reset" << std::endl; } }; struct Running { Running() { std::cout << "Started" << std::endl; start = std::chrono::steady_clock::now(); } explicit Running(std::chrono::steady_clock::duration duration) { std::cout << "Started from elapsed " << std::chrono::duration_cast<std::chrono::seconds>(duration).count() << std::endl; start = std::chrono::steady_clock::now() - duration; } std::chrono::steady_clock::time_point start; }; struct Paused { explicit Paused(std::chrono::steady_clock::time_point start) { duration = std::chrono::steady_clock::now() - start; std::cout << "Pause, elapsed " << std::chrono::duration_cast<std::chrono::seconds>(duration).count() << std::endl; } std::chrono::steady_clock::duration duration; }; } // namespace state namespace event { struct RunEvent { template <typename T> state::Running operator()(T) const { throw; } state::Running operator()(state::Reset) const { return {}; } state::Running operator()(state::Paused p) const { return state::Running(p.duration); } }; struct PauseEvent { template <typename T> state::Paused operator()(T) const { throw; } state::Paused operator()(state::Running r) const { return state::Paused(r.start); } }; struct ResetEvent { template <typename T> state::Reset operator()(T) const { throw; } state::Reset operator()(state::Running) const { return {}; } state::Reset operator()(state::Paused) const { return {}; } }; } // namespace event using StopWatch = FSM<state::Reset, state::Running, state::Paused>; } // namespace stopwatch int main() { using namespace stopwatch; StopWatch sw; sw.handle(event::RunEvent{}); std::this_thread::sleep_for(std::chrono::seconds(1)); sw.handle(event::PauseEvent{}); sw.handle(event::RunEvent{}); std::this_thread::sleep_for(std::chrono::seconds(1)); sw.handle(event::PauseEvent{}); sw.handle(event::ResetEvent{}); }
raven19
"Охрененно" круто, но только почему-то во всех приличных книжках используют термин "конечный автомат", а не "“Finite-state machine”? Я имею в виду книжки на "русском" языке, даже если они и переведены с иноземного!
alextsybulko Автор
Да, спасибо за замечание. Мне искать информацию в оригинальных терминах проще. Добавил еще вариант с локализованным вариантом.