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