Скінченні автомати можуть викликати моторошні в'єтнамські флешбеки про незалік з теорії алгоритмів , від них може віяти смутком чорнобілих сторінок даташитів якихось мікрочіпів , і коли я питаю на співбесідах 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 ( ) не стане від цього складнішим , як минулого разу , він узагалі не зміниться :
<код>
Зміни у в'ю -контролері будуть тривіальними : слід просто викликати ще один метод інтерактора , то відображати іще один стан .
Тому ми їх опустимо .
На сьогодні все
Стейт - машини не є срібною кулею , як і будь-який інший патерн проєктування , вони не зроблять ваш код ідеальним , не зроблять ваші проекти цікавішими і не закінчать епоху бідності вже сьогодні .
Однак вони мають дуже зручну властивість перетворювати нелінійлу логіку у лінійну , чим часом дуже сильно спрощують наш код .
Вони універсальні , тому вони знаходять місце на будь-якому рівні абстракції ваших додатків .
Також вони допомагають виокремити з будь-якої компоненти логіку у чистому вигляді , що дозволяє досить просто покрити її тестами .
Власне , наступного разу ми поговоримо про покриття стейт - машин тестами .
Також у наступних частинах я би хотів поговорити про різновиди ієрархічні та ієрархічно - ортогональні стейт - машини та про задачі , котрі вони дозволяють розв'язувати у простий та елегантний спосіб .
Приклади коду , з яким ми мали щастя працювати вище , можна скачати тут .
