Бизнес-логика – это сложно. Сложная бизнес-логика — ещё сложнее. А описать всё это в коде – просто жесть. Мы с вами каждый день реализуем тонну разных сценариев с огромным количеством веток развития. Каждую ветку нужно запрограммировать, потом суметь быстро поправить, а когда придёт продакт, еще и поменять ее логику. И если писать код просто как он пишется, можно оказаться в ситуации, когда простой фикс вместо 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)


  1. raven19
    21.04.2022 10:27
    +1

    "Охрененно" круто, но только почему-то во всех приличных книжках используют термин "конечный автомат", а не "“Finite-state machine”? Я имею в виду книжки на "русском" языке, даже если они и переведены с иноземного!


    1. alextsybulko Автор
      21.04.2022 14:55

      Да, спасибо за замечание. Мне искать информацию в оригинальных терминах проще. Добавил еще вариант с локализованным вариантом.


  1. 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{});
    }