Стейт-машини. Частина 1

  1. Частина 1
  2. Частина 2

Скінченні автомати можуть викликати моторошні флешбеки про незалік з теорії алгоритмів, від них може віяти смутком чорно-білих сторінок даташитів якихось мікрочипів, і коли я питаю на співбесідах iOS-розробників про їх улюблені шаблони – скінченні автомати є точно не найбільш частою відповіддю. Вони точно не можуть конкурувати в хайповості з усіляким модними патернами світу iOS-розробки на кшталт VIPER, RIBs, Redux чи MVVM. Однак, скінченні автомати, або Finite State Machines, – це абсолютно незаслужено забутий шаблон проєктування, що робить життя iOS-розробника простішим і зрозумілішим. Сьогодні ми з вами спробуємо поглянути на нього по-новому, і знайти у своїх проєктах місце для цієї простої та універсальної абстракції.

Про скінченні автомати

Перш ніж почати, домовимось називати скінченні автомати просто й по-народному: стейт-машинами. Це не зовсім коректний переклад цього терміну, але в коридорах наших ІТ-компаній ви, найімовірніше, почуєте саме цей варіант, адже лютий англо-український техносурж – це і є той правдивий варіант української, котрим спілкуються інженери за межами книжок про програмування.

Отож, почнемо. Що таке стейт-машина? Це спосіб описати роботу якогось пристрою. Уявімо собі, що в нас є холодильник. Він працює дуже просто: якийсь час він охолоджує свою камеру, а потім якийсь час чекає, доки камера нагріється до максимально допустимої температури. Фактично, в нього є два режими роботи: охолодження та очікування. Ці режими ми називатимемо станами. Холодильник перемикається між своїми станами лише у випадку настання певних подій, зокрема:

  • температура знизилась нижче мінімуму
  • температура піднялась вище максимуму

Ці події змушують холодильник переходити з одного стану в інший:

graph LR; cooling([Охолодження]) --температура знизилась--> heading([Очікування]); heading --температура піднялась--> cooling;

Таке зображення роботи холодильника ми й називаємо стейт-машиною, або машиною зі скінченними станами. Будь-який пристрій, який в різний час поводиться по-різному, можна описати за допомогою стейт-машини. Перевага цієї форми в тому, що вона є інтуїтивно зрозумілою. Наприклад, зовсім не складно зрозуміти алгоритм роботи якоїсь програми, коли його записано так:

graph LR; Idle([Idle]) --launched--> Loading; Loading([Loading]) --loaded--> Waiting; Waiting([Waiting for input]) --input-->Processing; Processing([Processing]) --failed-->Error; Processing --cancel-->Waiting; Processing --success-->Result; Result([Result]) --close--> Waiting; Error([Error])--restart-->Idle;

Окрім того, що ця форма є зрозумілою, вона є повною. Зі схеми вище зрозуміло, що операцію скасування (cancel) можна виконати тоді, коли ця програма знаходиться у стані Processing, але не можна виконати тоді, коли програма знаходиться у стані Loading. Лише уявіть, якби ваш замовник описував вам вимоги до вашої програми, послуговуючись мовою стейт-машин, скільки би безцінних годин життя ви могли б зекономити?

Простота й універсальність цієї форми представлення інформації ставить її в один ряд з діаграмами Венна та діаграмами Ґанта.

Мова програмування Swift дозволяє дуже просто реалізовувати стейт-машини. Спробуймо описати роботу холодильника:

class FridgeStateMachine {
    enum State {
        case cooling
        case waiting
    }
  
    enum Event {
        case minTemperatureReached
        case maxTemperatureReached
    }
  
    private(set) var state: State = .cooling
  
    func transition(with event: Event) {
        switch (state, event) {
            case (.cooling, .minTemperatureReached):
                state = .waiting
            case (.cooling, .maxTemperatureReached):
                break
          
            case (.waiting, .maxTemperatureReached):
                state = .cooling
            case (.waiting, .minTemperatureReached):
                break
        }
    }
}

Це лише один зі способів представити стейт-машину мовою Swift, існує багато інших, часто нічим не гірших, а іноді навіть і чимось кращих за даний. Перевагою даного запису стейт-машини є використання вичерпності інструкції switch у Swift. Якщо завтра нам доведеться реалізовувати новий стан, компілятор змусить нас подумати про те, як слід реагувати на кожну з уже наявних подій в цьому новому стані:

class FridgeStateMachine {
    enum State {
        case cooling
        case waiting
        case fastCooling			// <- новий стан
    }

    /* ... */

    func transition(with event: Event) {
        switch (state, event) {
            case (.cooling, .minTemperatureReached):
                state = .waiting
            case (.cooling, .maxTemperatureReached):
                break

            case (.waiting, .maxTemperatureReached):
                state = .cooling
            case (.waiting, .minTemperatureReached):
                break
          // Помилка компіляції: switch більше не є вичерпним. 
          // Слід додати обробку усіх подій у стані fastCooling.
        }
    }
}

Те ж саме стосується й нових подій. Коли ми додаємо якусь нову подію, компілятор змушує нас подумати: в яких станах доречно реагувати на нову подію, а в яких ні?

class FridgeStateMachine {

    /* ... */

    enum Event {
        case minTemperatureReached
        case maxTemperatureReached
        case doorOpened				// <- нова подія
    }

    func transition(with event: Event) {
        switch (state, event) {
            case (.cooling, .minTemperatureReached):
                state = .waiting
            case (.cooling, .maxTemperatureReached):
                break

            case (.waiting, .maxTemperatureReached):
                state = .cooling
            case (.waiting, .minTemperatureReached):
                break
          // Помилка компіляції: switch більше не є вичерпним.
          // Слід додати обробку події doorOpened у кожному із станів.
        }
    }
}

Проєктуємо, використовуючи стейт-машини

Ясна річ, стейт-машини можна застосовувати не лише при проєктуванні холодильників. Ми iOS інженери: ми не будуємо холодильники, ми проєктуємо інтерфейси та скролимо списки. Спробуймо втулити стейт-машину куди-небудь в якийсь код, що вирішуватиме реальну задачу. Уявімо собі, що нам потрібно реалізувати кнопки “Shift” на мобільній клавіатурі. Вона відрізняється від кнопок на фізичній клавіатурі тим, що фактично поєднує логіку кнопок Caps Lock та Shift в одній кнопці, тому вона має три стани: Off, On, та CapsLock. В залежності від швидкості й кількості натискань на кнопку, її стан змінюються наступним чином:

Shift state machine

Спробувавши лаконічно пояснити, як повинна працювати кнопка, ми, власне, й побудували стейт-машину, і тепер лишилось перетворити її на код.

class ShiftStateMachine {
    enum State {
        case off
        case on(enterDate: Date)
        case capsLock
    }

    enum Event {
        case shiftPressed(date: Date)
        case characterTyped
    }

    private(set) var state: State = .off
    private let capsLockThreshold: TimeInterval = 0.5

    func transition(with event: Event) {
        switch (state, event) {

        case let (.off, .shiftPressed(date)):
            state = .on(enterDate: date)
        case (.off, _):
            break

        case let (.on(enterDate), .shiftPressed(date))
                where date.timeIntervalSince(enterDate) < capsLockThreshold:
            state = .capsLock
        case (.on, .shiftPressed),
            (.on, .characterTyped):
            state = .off

        case (.capsLock, .shiftPressed):
            state = .off
        case (.capsLock, _):
            break
        }
    }
}

Реалізація стейт-машини нескладна, і не вимагає додаткових коментарів. Користувач цієї стейт-машини повинен робити дві речі: надсилати події, та міняти UI в залежності від стану цієї стейт-машини. Слід, утім, звернути увагу на те, чого в цьому коді немає. Тут немає роботи з конкретними UI-елементами. Тут взагалі немає жодних залежностей. Логіка кнопки живе окремо, а UI – окрема. Ми, наприклад, можемо переписати UI з UIKit на SwiftUI, при цьому логіка не зміниться. Зручно, чи не так?

Приклад з таблицею

Розглянемо більш життєвий приклад. Нехай ми будуємо додаток, в якому користувач може відмічати різні пам’ятки та цікаві місця, котрі він відвідав, і нам потрібно зобразити список цих пам’яток. Та що там робити, UITableViewController чи List зі SwiftUI, 15 хвилин — і готово.

Скріншот додатку зі списком чекінів

Щоправда, якщо ми хочемо, щоб цим списком користувались живі люди, і щоб їм було зручно, то нам варто подумати про багато нюансів. Наприклад, коли список порожній — користувач побачить просто білий екран. І в нашого користувача будуть питання: де я? Чому екран порожній? Це якась помилка? Чи просто поки що немає даних? Тому, якщо ми любимо свого користувача, ми покажемо йому якесь пояснення:

Як не варто показувати порожні списки: Як слід показувати порожні списки:
Скріншот додатку з неправильним порожнім списком Скріншот додатку з правильним порожнім списком

Якщо ми зберігатимемо дані користувачів на сервері, то користувач побачить свої чекіни не одразу, йому слід буде почекати, допоки не завантажаться дані. І знову ж таки, якщо ми любимо свого користувача, ми не полінимось пояснити йому, що ж відбувається, показавши екран із завантаженням:

Скріншот додатку з завантаженням списку

Врешті, не всяке завантаження даних із мережі закінчується успішно, виникнення помилки в такому випадку є цілком штатною ситуацією. Шибати UIAlertController в обличчя користувача щоразу, коли він зайшов у підземний перехід і в нього пропало інтернет-з’єднання – це насправді трохи навіть грубо. Доречніше буде вбудувати повідомлення про помилку в сам екран, турботливо поклавши до нього кнопочку “Спробуй ще” (як колись писали під кришечками акційної Фанти).

Скріншот додатку з помилкою завантаження

Коли проєктуєш модель даних для такого списку, дуже допомагає вчасне усвідомлення, що модель не повинна бути структурою, вона повинна бути перечисленням. Дійсно: ми показуємо або список, або екран із завантаженням, або екран з помилкою, або екран з повідоменням про те, що немає даних:

enum CheckinListViewState {
    case empty
    case error(CheckinDataSourceError)
    case loading
    case data([Checkin])
}

Така модель в першу чергу зручна тим, що вона 1:1 відповідає тому, що відбувається на екрані, і її легко інтерпретувати:

private func updateUI() {
    switch data {
        case .empty:
            showEmptyListPlaceholder()
        case .loading:
            showLoading()
        case let .error(error):
            showError(error)
        case let .data(items):
            showCheckinList(items)
    }
}

Однак, такою моделлю даних потрібно якось керувати. А ще треба якось реагувати на зміни у даній моделі, не ускладнюючи код. Уявімо собі, що ми керуємо даною моделлю напряму у в’ю-контролері:

protocol CheckinListProvider {
    func downloadCheckins(_ completion: (Result<[Checkin], CheckinDataSourceError>) -> Void)
}

class CheckinListViewController: UIViewController {

    var state: CheckinListViewState = .empty { didSet { updateUI() }}
    var listProvider: CheckinListProvider = // Some dependency injection

    override func viewDidLoad() {
        super.viewDidLoad()

        downloadCheckins()
    }

    private func downloadCheckins() {
        state = .loading

        listProvider.downloadCheckins { [weak self] result in
            guard let strongSelf = self else { return }
            switch result {
            case let .success(checkins):
                strongSelf.state = checkins.isEmpty ? .empty : .data(checkins)
            case let .failure(error):
                strongSelf.state = .error(error)
            }
        }
    }

    private func updateUI() { ... }

    ...

Як бачимо, у прикладі вище, у простих випадках керувати такою моделлю нескладно, однак при збільшенні кількості елементів у перечисленні CheckinListViewState, що служить нашою моделлю, збільшується і складність нашого коду. Уявімо собі, що ми хочемо реалізувати принцип “відкладеного логіну”, коли користувач може послуговуватись значною частиною функціональності додатку не аутентифікувавшись, і змушений логінитись лише в момент, коли це дійсно необхідно. В такому додатку на екран зі списком пам’яток може потрапити неавтентифікований користувач, і це є штатною ситуацією:

Скріншот додатку з необхідністю залогінитись

Для реалізації такої поведінки, нам потрібно додати новий стан до нашої моделі:

enum CheckinListViewState {
    ...
    case loggedOut
}

А раз у нас є можливість логінитись, має бути й можливість логаутитись. Спробуємо додати цю нову логіку у наш в’ю-контролер:

protocol AuthService {
    func logout()
}

class CheckinListViewController: UIViewController {
    var state: CheckinListViewState = .empty { didSet { updateUI() }}
    var listProvider: CheckinListProvider
    var authService: AuthService

    override func viewDidLoad() {
        super.viewDidLoad()

        downloadCheckins()
    }

    @IBAction private func logoutPressed() {
        guard state != .loading else { return }
        state = .loggedOut
        authService.logout()
    }

    private func downloadCheckins() {
        guard state != .loggedOut else { return }

        state = .loading
        listProvider.downloadCheckins { [weak self] result in
            guard let strongSelf = self else { return }
            switch result {
            case let .success(checkins):
                strongSelf.state = checkins.isEmpty ? .empty : .data(checkins)
            case let .failure(error):
                strongSelf.state = .error(error)
            }
        }
    }

    private func updateUI() { ... }
}

Бачимо, що тепер нам потрібно міняти нашу модель у різних місцях, з різних причин, і при цьому тримати її консистентною. Саме з цих причин у нас з’являються інструкції guard на початку методів logoutPressed() та downloadCheckins(). З кожним розширенням цей код буде дедалі ставати складнішим: при додаванні кожного нового методу, у котрому ми змінюємо модель, слід проінспектувати кожен наявний метод, що змінює модель, і перевірити його коректність. Тому з масштабуванням така реалізація буде все більш і більш заплутаною, і у ній все частіше з’являтимуться баги. Не кажучи вже про неминуче переростання у Massive View Controller.

Знаю, знаю, ви вже проходили через ці граблі, і вже давно обрали для себе MVVM/VIPER/Redux/RIBs, а дехто вже давно втопив усі ті кляті язичницькі в’ю-контролери у річці, прийнявши хрещення Української Автокефальної SwiftUI-церкви. Однак, наступний спосіб “кудись втулити стейт-машину” жодним чином не має суперечити вашим релігійним переконанням, скоріше допоможе їм ствердитись.

Ліпимо однонаправлені архітектури зі стейт-машинами

Ідея проста: виокремити з в’ю-контролера певні відповідальності в окремі компоненти, і об’єднати їх однонаправленим зв’язком наступним чином:

graph LR; viewController([ViewController]) --action--> interactor; interactor([Interactor]) --event--> stateMachine; interactor --> services; services([App Services]) --> interactor; stateMachine([State Machine]) --state changed--> viewController;
  • ViewController тепер відповідатиме лише за зображення даних, і передаватиме згенеровані користувачем дії до інтерактора.
  • Interactor знає про різноманітні сервіси нашого додатку і реалізовує взаємодію (тобто інтеракцію) з ними. У нашому випадку – це сервіс з завантаження чекінів та автентифікаційний сервіс.
  • State Machine реагує на події, котрі приходять з інтерактора, та реалізовує логіку зміни стану в залежності від події. ViewController спостерігає за станом стейт-машини та зображує його.

Фактично, все, що зображує ViewController, залежить виключно від стану State Machine, іншими словами, UI цього в’ю-контролера є чистою функцією стану. Чуєте, як запахло хайпом? Та це ж головний принцип SwiftUI! Дійсно, даний підхід є абсолютно природнім і для SwiftUI:

graph LR; view([View]) --action--> interactor; interactor([Interactor]) --event--> stateMachine; interactor --> services; services([App Services]) --> interactor; stateMachine([State Machine]) --state changed--> view;

Давайте переробимо все на однонаправлену архітектуру зі стейт-машинами. Тільки спершу ми відійдемо на крок назад, і реалізуємо версію без логінів-логаутів. Це дозволить нам перевірити наш підхід на гнучкість: ми реалізовуємо просту задачу, а потім в рамках цієї реалізації намагаємось додавати нову функціональність, як ми це робили вище.

Спершу ми створимо стейт-машину, котра опише логіку переходів. При цьому одразу ж реалізуємо спостереження за станом цієї стейт-машини:


protocol CheckinListStateMachineObserver: AnyObject {
    func checkinListStateMachine(_ stateMachine: CheckinListStateMachine,
                                 didEnter state: CheckinListStateMachine.State)
}

class CheckinListStateMachine {
    enum State: Equatable {
        case idle
        case loading
        case error(CheckinDataSourceError)
        case empty
        case list([Checkin])
    }

    enum Event {
        case loadingStarted
        case loadingFailed(CheckinDataSourceError)
        case loadingFinished([Checkin])
    }

    weak var observer: CheckinListStateMachineObserver?

    private(set) var state: State = .idle {
        didSet {
            guard oldValue != state else { return }
            observer?.checkinListStateMachine(self, didEnter: state)
        }
    }

    func transition(with event: Event) {
        switch (state, event) {
        case (.idle, .loadingStarted):
            state = .loading
        case (.idle, _):
            break

        case let (.loading, .loadingFinished(items)) where items.isEmpty:
            state = .empty
        case let (.loading, .loadingFinished(items)):
            state = .list(items)
        case (.loading, _):
            break

        case (.error, .loadingStarted):
            state = .loading
        case (.error, _):
            break

        case (.empty, .loadingStarted):
            state = .loading
        case (.empty, _):
            break

        case (.list, .loadingStarted):
            state = .loading
        case (.list, _):
            break
        }
    }
}

Тепер створимо інтерактор, котрий володітиме даною стейт-машиною. Інтерактор повинен спілкуватись із сервісами рівня додатку:

class CheckinListInteractorImpl: CheckinListInteractor {
    private(set) var stateMachine = CheckinListStateMachine()
    private let listProvider: CheckinListProvider

    init(listProvider: CheckinListProvider) {
        self.listProvider = listProviders
    }
    
    func loadCheckins() {
        stateMachine.transition(with: .loadingStarted)
        listProvider.downloadCheckins {  [weak self] result in
            guard let strongSelf = self else { return }
            switch result {
            case let .success(list):
                strongSelf.stateMachine.transition(with: .loadingFinished(list))
            case let .failure(error):
                strongSelf.stateMachine.transition(with: .loadingFailed(error))
            }
        }
    }
}

Тепер в’ю-контролер стає простим, як дошка-сороківка:

class CheckinListViewController: UIViewController {

    lazy var checkinListInteractor: CheckinListInteractor = // Some DI
    var stateMachine: CheckinListStateMachine { checkinListInteractor.stateMachine }

    // MARK: - UIViewController

    override func viewDidLoad() {
        super.viewDidLoad()

        stateMachine.observer = self
        checkinListInteractor.loadCheckins()
    }
}

extension CheckinListViewController: CheckinListStateMachineObserver {
    func checkinListStateMachine(_ stateMachine: CheckinListStateMachine,
                                 didEnter state: CheckinListStateMachine.State) {
        switch state {
        case .idle:
            break
        case .loading:
            showLoading()
        case let .error(error):
            showError(error)
        case .empty:
            showEmptyListPlaceholder()
        case let .list(items):
            showCheckinList(items)
        }
    }
}

Як буде мінятись наша нова реалізація, якщо додати в неї стан .loggedOut та кнопку logOut? У першу чергу зміниться стейт-машина: у ній з’явиться новий стан та нова подія. При цьому логіка всередині неї не стане складнішою, вона просто стане довшою. Це і є тією чарівною особливістю стейт-машин, за яку я так їх люблю: вони перетворюють нелінійний код на лінійний, і тому, при ускладненні задачі, масштабуються лінійно. and i think thats beautiful:

class CheckinListStateMachine {
    enum State: Equatable {
        ... // Old states
        case loggedOut
    }

    enum Event {
        ... // Old events
        case logout
    }

    ... 

    func transition(with event: Event) {
        switch (state, event) {
        case (.idle, .loadingStarted):
            state = .loading
        case (.idle, _):
            break

        case let (.loading, .loadingFailed(error)) where error == .noAuth:
            state = .loggedOut
        case let (.loading, .loadingFinished(items)) where items.isEmpty:
            state = .empty
        case let (.loading, .loadingFinished(items)):
            state = .list(items)
        case (.loading, _):
            break

        case (.loggedOut, .loadingStarted):
            state = .loading
        case (.loggedOut, _):
            break

        case (.error, .loadingStarted):
            state = .loading
        case (.error, .logout):
            state = .loggedOut
        case (.error, _):
            break

        case (.empty, .loadingStarted):
            state = .loading
        case (.empty, .logout):
            state = .loggedOut
        case (.empty, _):
            break

        case (.list, .loadingStarted):
            state = .loading
        case (.list, .logout):
            state = .loggedOut
        case (.list, _):
            break
        }
    }
}

Так, метод transition(with:) став довшим, але він не став складнішим, а всі можливі варіанти взаємодії нових станів та подій знаходяться в одному місці, і тому нам легко стежити за коректністю усіх переходів. До того ж властивість вичерпності інструкції switch повністю або частково, як у цьому випадку, допомагає нам слідкувати за коректністю коду вже під час компіляції.

Як зміниться інтерактор? Ми додамо у нього нову залежність на AuthService, та новий метод logout(), котрим користуватиметься наш в’ю-контролер. Вже наявний метод loadCheckins() не стане від цього складнішим, як минулого разу, він узагалі не зміниться:

class CheckinListInteractorImpl: CheckinListInteractor {
    private(set) var stateMachine = CheckinListStateMachine()
    private let listProvider: CheckinListProvider
    private let authService: AuthService

    ...

    func logout() {
        authService.logout()
        stateMachine.transition(with: .logout)
    }
}

Зміни у в’ю-контролері будуть тривіальними: слід просто викликати ще один метод інтерактора, та зображувати іще один стан. Тому ми їх опустимо, адже на годиннику вже перша ночі, а в мене ще серіали не дивлені.

На сьогодні все

Стейт-машини не є срібною кулею. Як і будь-який інший патерн проєктування, вони не зроблять ваш код ідеальним, не зроблять ваші проєкти цікавішими та не закінчать епоху бідності вже сьогодні. Однак, вони мають дуже зручну властивість: перетворювати нелінійну логіку у лінійну, чим часом дуже сильно спрощують наш код. Вони універсальні, тому вони знаходять місце на будь-якому рівні абстракції ваших додатків. Також вони допомагають виокремити з будь-якої компоненти логіку у чистому вигляді, що дозволяє досить просто покрити її тестами.

Власне, наступного разу ми поговоримо про покриття стейт-машин тестами. Також у наступних частинах я хочу поговорити про різновиди ієрархічні та ієрархічно-ортогональні стейт-машини та про задачі, котрі вони дозволяють розв’язувати у простий та елегантний спосіб.

Приклади коду, з яким ми мали щастя працювати вище, можна завантажити отут.