Скінченні автомати можуть викликати моторошні в'єтнамські флешбеки про незалік з теорії алгоритмів, від них може віяти смутком чорно-білих сторінок даташитів якихось мікрочипів, і коли я питаю на співбесідах iOS-розробників про їхні улюблені шаблони — скінченні автомати є точно не найчастішою відповіддю.
Вони точно не можуть конкурувати в хайповості з усілякими модними патернами світу iOS-розробки на кшталт VIPER, RIBs, Redux чи MVVM.
Однак скінченні автомати, або Finite State Machines, – це абсолютно незаслужено забутий шаблон проєктування, що робить життя iOS-розробника простішим і зрозумілішим.
Сьогодні ми з вами спробуємо поглянути на нього по-новому і знайти у своїх проєктах місце для цієї простої та універсальної абстракції.
Про скінченні автомати
Перш ніж почати, домовмось називати скінченні автомати просто й по-народному: стейт-машинами.
Це не зовсім коректний переклад цього терміна, але в коридорах наших ІТ-компаній ви, найімовірніше, почуєте саме цей варіант, адже лютий англо-український техносурж — це і є той правдивий варіант української, котрим спілкуються інженери за межами книжок про програмування.
Отож, почнемо.
Що таке стейт-машина?
Це спосіб описати роботу якогось пристрою.
Уявімо собі, що в нас є холодильник.
Він працює дуже просто: якийсь час він охолоджує свою камеру, а потім якийсь час чекає, доки камера нагріється до максимально допустимої температури.
Фактично, в нього є два режими роботи: охолодження та очікування.
Ці режими ми називатимемо станами.
Холодильник переключається між своїми станами лише у випадку настання певних подій, зокрема:
температура опустилась нижче мінімуму
температура піднялась вище максимуму
Ці події змушують холодильник переходити з одного стану в інший:
<схема>
Таке зображення роботи холодильника ми і називаємо стейт-машиною, або машиною із скінченними станами.
Будь-який пристрій, який у різний час веде себе по-різному, можна описати за допомогою стейт-машини.
Перевага цієї форми в тому, що вона є інтуїтивно зрозумілою.
Наприклад, зовсім не складно зрозуміти алгоритм роботи якоїсь програми, коли його записано так:
<схема>
Окрім того, що ця форма є зрозумілою, вона є повною.
Зі схеми вище зрозуміло, що операцію скасування (cancel) можна виконати тоді, коли ця програма знаходиться у стані Processing, але не можна виконати тоді, коли програма знаходиться у стані Loading.
Лише уявіть, якби ваш замовник описував вам вимоги до вашої програми, послуговуючись мовою стейт-машин, скільки безцінних годин життя ви могли би зекономити?
Простота й універсальність цієї форми представлення інформації ставить її в один ряд із діаграмами Венна та діаграмами Ґанта.
Мова програмування Swift дозволяє дуже просто реалізовувати стейт-машини.
Спробуймо описати роботу холодильника:
<код>
Це лише один зі способів представити скінченний автомат мовою Swift, існує багато інших, часто нічим не гірших, а іноді навіть і чимось кращих за даний.
Перевагою даного запису скінченного автомата є використання вичерпності інструкції switch у Swift.
Якщо завтра нам доведеться реалізовувати новий стан, компілятор змусить нас подумати про те, як слід реагувати на кожну з існуючих подій у цьому новому стані.
Це ж саме працюватиме з новими подіями.
<код>
Те саме стосується й нових подій.
Коли ми додаємо якусь нову подію, компілятор змушує нас подумати: в яких станах доречно реагувати на нову подію, а в яких — ні?
<код>
Проєктуємо, використовуючи скінченні автомати
Ясна річ, скінченні автомати можна застосовувати не лише при проєктуванні холодильників.
Ми — iOS-інженери: ми не будуємо холодильники, ми проєктуємо інтерфейси та скролимо списки.
Спробуймо втулити стейт-машину куди-небудь у якийсь код, який вирішував би реальну задачу.
Уявімо собі, що нам потрібно реалізувати кнопки "Shift" на мобільній клавіатурі.
Вона відрізняється від кнопок на фізичній клавіатурі тим, що фактично поєднує логіку кнопок Caps Lock та Shift в одній кнопці, тому вона має три стани: Off, On, та CapsLock.
В залежності від швидкості і кількості натискань на кнопку, її стан змінюються наступним чином:
Shift state machine
Спробувавши лаконічно пояснити, як повинна працювати кнопка, ми, власне, й побудували стейт-машину, і тепер лишилось перетворити її на код.
<код>
Реалізація стейт-машини нескладна, і не вимагає додаткових коментарів.
Користувач цієї стейт-машини повинен робити дві речі: надсилати події та міняти UI в залежності від стану цієї стейт-машини.
Слід, утім, звернути увагу на те, чого в цьому коді немає.
Тут немає роботи з конкретними UI-елементами.
Тут узагалі немає жодних залежностей.
Логіка кнопки живе окремо, а UI — окрема.
Ми, наприклад, можемо переписати UI з UIKit на SwiftUI, при цьому логіка не зміниться.
Зручно, чи не так?
Приклад з таблицею
Розгляньмо більш життєвий приклад.
Нехай ми будуємо додаток, в якому користувач може відмічати різні пам'ятки та цікаві місця, котрі він відвідав, і нам потрібно відобразити список цих пам'яток.
Та що там робити, UITableViewController чи List зі SwiftUI, 15 хвилин — і готово.
<Скріншот додатка зі списком чекінів>
Щоправда, якщо ми хочемо, щоб цим списком користувались живі люди, і щоб їм було зручно, то нам варто подумати про багато нюансів.
Наприклад, коли список порожній — користувач побачить просто білий екран.
І в нашого користувача будуть питання: де я?
Чому екран порожній?
Це якась помилка?
Чи просто поки що немає даних?
Тому якщо ми любимо свого користувача, ми покажемо йому якесь пояснення:
Як не варто показувати порожні списки:
Як слід показувати порожні списки:
<Скріншот додатка з неправильним порожнім списком> |<Скріншот додатка з правильним порожнім списком>
Якщо ми зберігатимемо дані користувачів на сервері, то користувач побачить свої чекіни не одразу, йому слід буде почекати, допоки не завантажаться дані.
І знову ж таки, якщо ми любимо свого користувача, ми не полінимось пояснити йому, що ж відбувається, показавши екран із завантаженням:
<Скріншот додатка зі завантаженням списку>
Врешті, не всяке завантаження даних із мережі закінчується успішно, виникнення помилки в такому випадку є цілком штатною ситуацією.
Шибати UIAlertController в обличчя користувача щоразу, коли він зайшов у підземний перехід і в нього пропало інтернет-з'єднання — це насправді трохи навіть грубо, більш доречним буде вбудувати повідомлення про помилку в сам екран, турботливо поклавши до нього кнопочку "Спробуй ще" (як колись писали під кришечками акційної Фанти).
<Скріншот додатка з помилкою завантаження>
Коли проєктуєш модель даних для такого списку, дуже допомагає вчасне усвідомлення, що модель не повинна бути структурою, вона повинна бути перечисленням.
Дійсно, ми показуємо або список, або екран із завантаженням, або екран з помилкою, або екран з повідомленням про те, що немає даних:
<код>
Однак такою моделлю даних потрібно якось керувати.
А ще треба якось реагувати на зміни у даній моделі, не ускладнюючи код.
Уявімо собі, що ми керуємо даною моделлю напряму у в'ю-контролері:
<код>
Як бачимо у прикладі вище, у простих випадках керувати такою моделлю нескладно, однак при збільшенні кількості елементів у перечисленні CheckinListViewData, що служить нашою моделлю, збільшується і складність нашого коду.
Уявімо собі, що ми хочемо реалізувати принцип "відкладеного логіну", коли користувач може послуговуватись значною частиною функціональності додатка, не аутентифікацію, і змушений логінитись лише в момент, коли це дійсно необхідно.
У такому додатку на екран зі списком пам'яток може потрапити неаутентифікований користувач, і це є штатною ситуацією:
<Скріншот додатка з необхідністю залогінитись>
Для реалізації такої поведінки нам потрібно додати новий стан до нашої моделі:
<код>
Бачимо, що тепер нам потрібно міняти нашу модель у різних місцях, з різних причин і при цьому тримати її консистентною.
Саме з цих причин у нас з'являються інструкції guard на початку методів logoutPressed() та downloadCheckins().
З кожним розширенням цей код буде дедалі ставати складнішим: при додаванні кожного нового методу, у котрому ми змінюємо модель, слід проінспектувати кожен існуючий метод, що змінює модель, і перевірити його коректність.
Тому з масштабуванням така реалізація буде все більш і більш заплутаною, і у ній все частіше з'являтимуться баги.
Не кажучи вже про неминуче переростання у Massive View Controller.
Знаю-знаю, ви вже проходили через ці граблі і вже давно обрали для себе MVVM/VIPER/Redux/RIBs, а дехто вже втопив усі ті кляті язичницькі в'ю-контролери у річці, приєднавшись до Української SwiftUI-автокефалії.
Однак наступний спосіб "кудись втулити стейт-машину" жодним чином не має суперечити вашим релігійним переконанням, скоріше допомогти їм ствердитись.
Ліпимо однонаправлені архітектури зі стейт-машинами
Ідея проста: виокремити з в`ю-контролера певні відповідальності в окремі компоненти, і об'єднати їх однонаправленим зв'язком наступним чином:
<схема>
ViewController тепер відповідатиме лише за відображення даних і передаватиме згенеровані користувачем дії до інтерактора.
Interactor знає про різноманітні сервіси нашого додатка і реалізовує взаємодію (тобто інтеракцію) з ними.
У нашому випадку — це сервіс зі завантаження чекінів та аутентифікаційний сервіс.
State Machine реагує на події, котрі приходять з інтерактора, та реалізовує логіку зміни стану в залежності від події.
ViewController спостерігає за станом стейт-машини та відображає його.
Фактично, все, що зображує ViewController, залежить виключно від стану State Machine, іншими словами, UI цього в'ю-контролера є чистою функцією стану.
Чуєте, як запахло хайпом?
Та це ж головний принцип SwiftUI!
Дійсно, даний підхід є абсолютно природнім і для SwiftUI:
<схема>
Переробимо все на однонаправлену архітектуру зі стейт-машинами.
Тільки спершу ми відійдемо на крок назад і реалізуємо версію без логінів-логаутів.
Це дозволить нам перевірити наш підхід на гнучкість: ми реалізовуємо просту задачу, а потім в рамках цієї реалізації пробуємо додавати нову функціональність, як ми це робили вище.
Спершу ми створимо стейт-машину, котра опише логіку переходів.
Ми одразу ж реалізуємо спостереження за станом цієї стейт-машини:
<код>
Тепер створимо інтерактор, котрий володітиме даною стейт-машиною.
Інтерактор повинен спілкуватись із сервісами рівня додатка:
<код>
Тепер в'ю-контролер стає простим, як дошка-сороківка:
<код>
Як буде мінятиметься наша нова реалізація, якщо додати в неї стан .loggedOut та кнопку logOut?
У першу чергу зміниться стейт-машина: у ній з'явиться новий стан та нова подія.
При цьому логіка всередині її не стане складнішою, вона просто стане довшою.
Це і є тією чарівною особливістю стейт-машин, за яку я так їх люблю: вони перетворюють нелінійний код на лінійний і тому при ускладненні задачі масштабуються лінійно.
And I think it's beautiful:
<код>
Так, метод transition(with:) став довшим, але він не став складнішим, а всі можливі варіанти взаємодії нових станів та подій знаходяться в одному місці, і тому нам легко стежити за коректністю усіх переходів.
До того ж, властивість вичерпності інструкції switch повністю або частково, як у цьому випадку, допомагає нам слідкувати за коректністю коду вже під час компіляції.
Як зміниться інтерактор?
Ми додамо у нього нову залежність на AuthService та новий метод logout(), котрим користуватиметься наш в'ю-контролер.
Вже існуючий метод loadCheckins() не стане від цього складнішим, як минулого разу, він узагалі не зміниться:
<код>
Зміни у в'ю-контролері будуть тривіальними: слід просто викликати ще один метод інтерактора та відображати іще один стан.
Тому ми їх опустимо.
На сьогодні все
Стейт-машини не є срібною кулею, як і будь-який інший патерн проєктування, вони не зроблять ваш код ідеальним, не зроблять ваші проєкти цікавішими і не закінчать епоху бідності вже сьогодні.
Однак вони мають дуже зручну властивість перетворювати нелінійну логіку у лінійну, чим часом дуже сильно спрощують наш код.
Вони універсальні, тому вони знаходять місце на будь-якому рівні абстракції ваших додатків.
Також вони допомагають виокремити з будь-якої компоненти логіку у чистому вигляді, що дозволяє досить просто покрити її тестами.
Власне, наступного разу ми поговоримо про покриття стейт-машин тестами.
Також у наступних частинах я би хотів поговорити про різновиди ієрархічні та ієрархічно-ортогональні стейт-машини та про задачі, котрі вони дозволяють розв'язувати у простий та елегантний спосіб.
Приклади коду, з яким ми мали щастя працювати вище, можна скачати тут.
