Порівнюємо enterprise-архітектури на SwiftUI

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Всім привіт, мене звати Ілля Кучерявий і я Software Engineering Manager та Head of iOS Practice in East Ukraine, EPAM.

Чому я зміг написати цю статтю:

  • 8+ років як інженер програмного забезпечення.
  • Останні 4+ роки також виконую ролі Team Lead / Solution Architect / Delivery Manager.
  • Більше року використовую SwiftUI в продакшн розробці.
  • Hands-on, починаючи з iOS 6.
  • Займаюся перемовинами і консультацією щодо мобільної специфіки з замовниками та архітектурою мобільних проектів.
  • Побудував навчальну платформу для iOS розробників у EPAM.
  • Координую навчальні програми для iOS та Android інженерів у EPAM по всьому світу.

Про що стаття

У статті розглянемо та порівняємо три типи найбільш популярних Enterprise-архітектур, які використовують засоби SwiftUI для відображення візуального користувацького інтерфейсу. Для порівняння використовуємо метод експерименту з шаблонним тестовим завданням.

У статті розглянута перша версія SwiftUI (SwiftUI v1.0).

Примітка: під SwiftUI v1.0 мається на увазі перша редакція Framework, яка вийшла разом с XCode 11, 20-го вересня, 2019. Під SwiftUI v2.0 мається на увазі інша редакція Framework, яка вийшла 16-го вересня, 2020.

Кому і чим стаття буде корисною

Стаття буде корисною для Middle, Senior, Lead iOS інженерів та Mobile Solution Architects для систематизації отриманих знань, та усім іншим розробникам, які ще не встигли попрацювати зі SwiftUI.

Буде добре, якщо до початку читання ви вже будете знати, що таке Protocol-oriented-programming (далі POP) [1], Generic Protocols, Model-View-Controller (MVC) та Model-View-ViewModel (MVVM) Architecture.

Поточні обмеження SwiftUI

Час від часу Apple випускає нові технології у світ, а також адаптує нові види розробки у своїх Frameworks. Але як усі ми знаємо, перші версії цих технологій, інструментів та бібліотек, хоч і вирішують основну задачу, але потребують вдосконалення. Так і SwiftUI v1.0 має цілу низку обмежень і проблем. Ці обмеження можно розділити на три групи: обмеження реактивного типу, обмеження дизайну і реалізації, обмеження які несуть в собі Generic протоколи, використані у SwiftUI.

Обмеження Реактивного Типу

SwiftUI побудований на Property Wrapper, яких до цього не існувало у попередній версії Swift, а це означає, що:

  • Код повинен бути змінений для підтримки SwiftU.I
  • @ObservedObject, @EnvironmentObject модифікатори не можуть бути Optional, а також можуть використовуватися тільки для Сlass-only протоколів
    (protocol SomeProtocol: class { ... }). Тобто якщо у вас є legacy code де частина UI-моделей це структури, то їх потрібно буде переписати
  • ViewModels повинні успадковвувати ObservableObject. Це означає, що, якщо ViewModels для вас це частина закритого Framework, який ви не можете модифікувати, то ви не зможете використовувати такі класи як ViewModels

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

При використовуванні SwiftUI реактивний підхід не можна не використоввувати.

Обмеження дизайну і реалізації

Ускладнений спосіб роботи для:

  • Робота з Frames, наприклад, немає простого способу зробити scroll до textfield на подію become first responder.
  • Робота з ScrollView offset (покращено у SwiftUI v2).

  • Робота з незвичайними жестами, Custom Gestures (покращено у SwiftUI v2).

Функціональність, якої немає:

  • Немає інфраструктури Controller-типу, тобто будь-яка View може виконати навігацію будь-куди, тобто потенційно порушується принцип Router / Coordinator.
  • Немає UIResponder.
  • Немає Full Screen Popover (покращено у SwiftUI v2).
  • Немає багатострокового TextField або TextView (покращено у SwiftUI v2).
  • Немає Activity indicator (покращено у SwiftUI v2).

Проблеми з продуктивністю rendering-у, HStack та VStack для 10 000 об’єктів для першої відмальовки підгружаються за 3-5 секунд, що порушує принцип 30-60-90 кадрів на секунду (FPS).

Обмеження Generic протоколів

  1. Протокол View — це Generic Protocol, тобто Views можуть бути тільки structure-типу.
  2. final class-тип компілюється та викликає помилку у runtime, class-тип не компілюється.
  3. Identifiable — це Generic protocol, а це означає, що увесь рівень абстракції для рівня ваших моделей повинен також бути Generic Protocols, тобто увесь рівень абстракції це також Generic Protocols. Або ви використовуєте базові класи та наслідування, замість протоколів і POP.

Деякі SwiftUI Views — це Generic Protocols (у UIKit, усі Views це спадкоємці від UIView), а це означає, що знання Generics збільшує поріг входу для нових інженерів, а також потрібно використовувати т.н. type-erased AnyView.

Опис експерименту

Для вимірювання різниці між архітектурними підходами, давайте створимо типовий додаток або user-flow, який ми реалізуємо в повсякденному житті, як iOS інженери:

  • 3+ екрани для навігації.
  • Робота з мережею.
  • Використання відображення типу Список.
  • Використання компонентного відображення (перевикористання різних компонентів між екранами).
  • Робота з навігацією та Navigation Bar.
  • Логіка з валідацією.
  • Робота з юніт-тестами.

Прикладом такого додатку може слугувати типовий додаток з можливостями входу, реєстрації та перегляду якогось списку (User-flows: Login, Registration, List Viewing).

Схематично це можно зобразити так:

Тобто, детальний список екранів буде виглядати так:

  • Екран Входу.
  • Багатокрокова реєстрація:
    • Екран Вводу імені користувача і електронної адреси.
    • Екран Створення і підтвердження пароля.
    • Екран Вибору тегів зі списк.у
  • Екран Новин за вибраними тегами.

Огляд вимог для мобільних додатків типу Enterprise

Далі будуть порівняні архітектури, які можно використати у Enterprise-розробці великих масштабів. З мого досвіду, це додатки для яких виконується більшість правил типу:

  • Більше 25 user-flows.
  • Більше 100 000 LOCs реалізації.
  • Багатомодульний мікс із Objective-C та Swift у процесі переходу на останній Swift.
  • Дублює функціонал з Web Frontend, і має 0-15% користувачів від усіх користувачів платформи.
  • Ключовий канал комунікації з кінцевими клієнтами, має 70-100% користувачів.
  • Створюється, або підтримується командою/командами 5-10 iOS розробників, тобто більшість UI та логічних модулів можуть створюватися паралельно, незалежно одне від одного.
  • Повна готовність до автоматизованого тестування та CI/CD, оскільки цикл регресійного тестування зав’язаний на цикл релізів і відбувається екстремально часто (1-4 тижні).

Метрики для аналізу

Для аналізу реалізованих додатків будемо використовувати типові метрики:

  • Кількість файлів (Files count).
  • Кількість найменувань (Declarations count).
  • Кількість строк/ліній коду (Lines of code, далі LOCs).

Короткий огляд типових елементів спільних для всіх архітектур

  • InputField.swift — реалізація компоненту для вводу тексту у простому та захищеному виді (password input).
  • ErrorText.swift — реалізація компоненту для відображення повідомлень про помилки.
  • LoadingView.swift — реалізація компоненту, який представляє стан завантаження.
  • Mocks.swift — замість реальної мережевої взаємодії будемо використовувати набір захардкоджених (hardcoded) відповідей і асинхронний таймер на відповідь.

Огляд Компонентної Архітектури

Визначимо Компонентну архітектурою, як архітектуру, що складається з компонентів — функціональних сутностей, при чому у кожної з цих сутностей є вхідні аргументи, внутрішня логіка і вихід. Для UI-компонентів вихід буде виглядати у зміненні аргументів та/або відображення їх на екрані.

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

Архітектура типового логічного модуля

Типовий логічний модуль такої архітектури буде складатися з одного або більшої кількості компонентів. Не повинен мати обмежень для розділення бізнес-логіки між компонентами, це скоріш буде розділення логіки за принципом здорового глузду. Основна частина компонентів — це Views. Маршрутизація у таких компонентах може бути виконана з будь-якого рівня. І також зміни до аргументів одного підкомпонента вимагають змін у цілому ланцюжку аргументів, оскільки аргументи підкомпонента #2 повинні міститися у списку аргументів підкомпонента #1.

Приклад логічного модуля такої архітектури зображений на приведеному малюнку:

Огляд прикладів коду

github.com/...​ure/Views/LoginView.swift

З цього прикладу можно підтвердити сказане вище: наявні підкомпоненти InputField, ErrorText та Button, LoginView виступає у ролі Root Component. Логіка підкомпонент розділяється за здоровим глуздом, але LoginView все ж являє собою змішування різних типів логіки в одному файлі/структурі: UI логіка, логіка валідації і логіка навігації.

Схема реалізації класів

Наведена нижче діаграма відповідає кількості класів/структур наявних у кінцевій імплементації додатку. Колір відповідає вид типу логіки притаманний класу/структурі.

Метрики реалізації

  • 130 Пустих ліній коду
  • 50 Коментарів
  • 449 Коду
  • 629 Всього LOCs
  • 12 Найменувань (не включаючи Previews)
  • 11 Файлів

Переваги Компонентної Архітектури

  1. Низький поріг входу, потребує лише основних знань SwiftUI.
  2. Легко читати, написаний в стилі офіційних навчальних матеріалів від Apple.
  3. Мала кількість LOC.
  4. Мала кількість класів та файлів.
  5. Декларативна навігація для кожного модуля.
  6. Legacy Мережевий рівень (Network layer) може бути використаний без додаткових модифікацій.
  7. Невеликі витрати на підтримку SwiftUI Previews.

Недоліки Компонентної Архітектури

  1. Змішування логіки для навігації, business-логіки та UI логіки, порушення принципів SOLID.
  2. Previews може вплинути на виробничий код, тому необхідне розділення коду (наприклад через compile-time парадигму #if).
  3. У деяких випадках для оновлення екрану потрібно використовувати сумнівні підходи.
  4. Unit-testing ускладнений або неможливий.

Огляд Архітектури з Protocol-binding

Архітектура типового логічного модуля

Під Protocol-архітектурою будемо мати на увазі MVVM-C архітектуру з Protocol-binding.

Типовий логічний модуль:

  • Складається з View, Model and ViewModel:
    • View відповідає за відображення UI.
    • Model репрезентує дані (мережеві, дані бази даних, дані логічних потоків).
    • ViewModel відповідає за бізнес-логіку пов’язану з обробкою UI (валідація даних, запуск анімацій, будь-які підрахунки, виклик мережевих запросів, доступ до інших менеджерів даних і т. д.).
  • Усі залежності вбудовуються через Protocols.
  • Навігація / побудова інших модулів виконується на рівні Координатора.
  • Немає з’єднаня від ViewModel до View, як у деяких реалізаціях MVVM, через обмеження SwiftUI.

Огляд прикладів коду

github.com/...​ure/Views/LoginView.swift

github.com/...​dels/LoginViewModel.swift

З цього приклада можно побачити що: View це Generic Struct. View містить лише логіку інтерфейсу, і усі дії делеговані ViewModel. Усі залежності введені (injected) через протоколи як у View так і в ViewModel. ViewModel містить лише business-логіку.

Cхема реалізації класів

Метрики реалізації

  • 247 Пустих ліній коду
  • 97 Коментарів
  • 770 Коду
  • 1114 Всього LOCs
  • 42 Найменувань (не включаючи Previews)
  • 23 Файлів

Переваги Архітектури з Protocol-binding

  • SOLIDity, бо логіка розділена компонентами:
    • Coordinator обробляє всю логіку побудови user-flow / навігації.
    • Views — це чистий UI.
    • ViewModels обробляє бізнес-логіку.
  • Мережа та Мережеві моделі повністю розділені.
  • Просте тестування на кожному рівні: Unit, Integration, UI.
  • Відчувається як майже класичний enterprise MVVM-C (легкий поріг входу для ветеранів).
  • Legacy Network потребує лише невеликого рефакторингу для інтеграції (декларація protocols).
  • Previews не може впливати на production логіку.

Недоліки Архітектури з Protocol-binding

  • 70% більше коду та на x3,5 збільшення кількості найменувань порівняно з Компонентною архітектурою.
  • Великий вплив підтримки Preview, потрібно зробити багато Preview Mocks.
  • Складна інфраструктура Router, повинні бути створені додаткові класи та інфраструктура для підтримки безперебійної маршрутизації.
  • View — це функція стану, тому ViewModel не може мати ViewInterface, оголошений як відокремлений протокол,можна лише відділити ViewState, але це все ще не класичний MVVM protocol-to-protocol.
  • Вищий поріг входу для новачків порівняно з Компонентною архітектурою. Views це вже не просто struct, а generic struct, додано POP, треба читати більше коду.

Огляд Архітектури з Reactive-binding

Архітектура типового логічного модуля

Як і у минулому випадку логічний модуль складається з View, Model and ViewModel. View is a function of VM.State, при цьому, ViewModel — це проста функція VM.Event, яка в результаті просто змінює VM.State. Навігація та створення модулів також виконується на рівні Coordinator.

Огляд прикладів коду

github.com/...​ure/Views/LoginView.swift

github.com/...​dels/LoginViewModel.swift

З наведеного вище приклада можно сказати що: View зберігає свій внутрішній стан, на відміну від Protocols підходу, а, ViewModel в свою чергу, не містить будь-якого внутрішнього стану View. View це велика функція стану, яка має декілька кінцевих реалізацій. View надсилає події з аргументами до ViewModel. Події та стани (VM.Event, VM.State) чітко розділені і задекларовані. ViewModel завжди має початковий стан. У ViewModel уся business-логіка написана в декларативному стилі. Усі вхідні дані перетворюються у якийсь задекларований стан, і немає незадекларованих станів.

Cхема реалізації класів

Метрики реалізації

  • 254 Пустих ліній коду
  • 79 Коментарів
  • 861 Коду
  • 1194 Всього LOCs
  • 20 Найменувань (не включаючи Previews)
  • 18 Файлів

Переваги Архітектури з Reactive-binding

  • Логіка розділена між компонентами.
  • Чітке розділення між можливими ViewState та Events.
  • Декларативний стиль business-логіки може бути більш читаємим.
  • Менша кількість декларацій порівняно з Protocols підходом.
  • Тестування тільки business-логіки можливе і є порівнянням VM.Event до VM.State.
  • Інфраструктура маршрутизатора менш складна порівняно з Protocols підходом.
  • SwiftUI Previews можливо легко створювати під кожний окремий ViewState.

Недоліки Архітектури з Reactive-binding

  • Збільшення коду на 70% порівняно з Компонентною архітектурою.
  • Майже така ж сама кількість LOCs порівнюючи з протокольним підходом.
  • Ускладнена реалізація pop-back поведінки, якщо є окремий ViewState який відповідає за навігацію (після завершення навігації його потрібно змінювати на default state/idle).
  • Іноді управління станом (ViewState) може бути складним і неоднозначним, тому вимагає додаткового тестування на етапі developer-testing та user-testing.
  • Вищий поріг входу для новачків через реактивне та функціональне програмування, порівняно з простим імперативним підходом у Protocol та Компонентній архітектурі.
  • Без прив’язки до Events та States члени команди можуть створити безлад коли наприклад один реактивний binding/event використовується у багатьох місцях порушуючи SOLID.
  • Рівень Legacy Network потребує створення додаткового реактивного фасаду.

Підсумок

Порівняльна таблиця


ComponentProtocolReactive
CONSUMER ATTRIBUTES
Entry thresholdLowMediumHigh
Code count (LOCs)LowHighHigh
Testability LowHighMedium
SOLIDity LowHighMedium
Preview support impact LowHighLow
Legacy Network SupportHighMediumMedium
Side effects ReloadNoStatemanagement errors

Висновок

Як можна побачити, кожна з цих архітектур має свої переваги та недоліки, і всі вони відрізняються. При цьому, кожна з архітектур має місце для існування у Enterprise проєктах, оскільки логічні модулі можуть створюватися паралельно одне від одного, без сильної зв’язки (окрім навігації у Компонентній архітектурі). При цьому потрібно просто розуміти під який конкретний кейс ви можете використовувати вибрану архітектуру.

Розгляд типових кейсів під кожну архітектуру

Для того щоб вибрати типові кейси, давайте виділемо найсильнішу/найслабшу сторони трьох архітектур. Я бачу це так:

Компонента архітектура — найменше число LOCs для реалізації, потребує багато часу якщо постійно модифікувати (дублювання ланцюга аргументів для підкомпонент) і майже неможливо робити повноцінні unit-тести.

Protocols архітектура — велике число LOCs, але повністю ізольовані класи/структури, тобто не тільки логічні модулі можно розробляти паралельно але й усі підкомпоненти. Також відмічу найбільшу простоту до написання unit-тестів.

Reactive архітектура — велике число LOCs, не все можно протестувати використовуючи unit-тести, але частина типових business-case легко імплементуються через асинхронну природу і декларативну business-логіку.

Таким чином можна вибрати типові кейси:

Компонента архітектура — робота з швидкими Proof-of-concept без тестів, тому що для цього типу проекта вони не потрібні. Minimal Viable Product, у якого лімітований час/бюджет на розробку.

Protocols архітектура — коли потрібно робити великий проєкт великою командою, маючи при цьому короткий цикл релізів. Також підходить для проєктів, де є необхідність мати покриття unit-тестами (test coverage) більше 70%.

Reactive архітектура — робота з типовими асинхронними business-case, проекти де багато людей взаємодіють групою із великою кількістю варіантів для взаємодії відразу: чати, віртуальні простори для online редагування/створення контенту типу Miro, Google Docs, Figma, і таке інше.

Повний код експерименту

Повний код експерименту можна знайти за посиланнями:

Додаткові матеріали та посилання, які можуть бути корисними

  1. developer.apple.com/...​videos/play/wwdc2015/408
  2. martinfowler.com/...​ramid.html#TheTestPyramid
  3. www.swiftbysundell.com/...​rticles/mocking-in-swift
  4. zucisystems.medium.com/...​lopment-2020-859aec3a82d3
  5. www.agilealliance.org/glossary/mvp

Долучайтеся до обговорення в коментарях та новоствореної Відкритої Гільдії iOS Інженерів України t.me/ios_guild_ukraine.

👍НравитсяПонравилось6
В избранноеВ избранном1
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Вищий поріг входу для новачків через реактивне та функціональне програмування, порівняно з простим імперативним підходом у Protocol та Компонентній архітектурі.

Это иллюзия, а точнее cognitive bias, чём-то близкий к curse of knowledge.
Выше не порог входа, а порог перехода для людей с императивным ООП бэкграундом. Для абсолютных новичков никаких трудностей FRP с декларативным UI и бизнес логикой не вызывает. Проверил лично на жене. Она как рыба в воде с The Composable Architecture от Point-Free. А вот сейчас работает с императивщиной и теряется, ибо нет интуиции с последовательно исполняемым, side-effecty кодом.

Без прив’язки до Events та States члени команди можуть створити безлад коли наприклад один реактивний binding/event використовується у багатьох місцях порушуючи SOLID.

Этого не будет происходить, если View будет generic и параметризироваться своим собственным типом Event и State.

Можно поспорить, потому что, по своей природе императивный подход он однопоточный, а реактивный многопоточный (так как строиться на предположении что все events приходят в обработчики в одно время). С такой точки зрения меньше порог входа у того подхода, где используется более простой механизм.

спасибо за статью, очень интересно

@ObservedObject, @EnvironmentObject модифікатори не можуть бути Optional, а також можуть використовуватися тільки для Сlass-only протоколів
(protocol SomeProtocol: class { ... }). Тобто якщо у вас є legacy code де частина UI-моделей це структури, то їх потрібно буде переписати

Вот так не получится. Нужно конкретный класс который конформит ObservableObject

Можно развернутый пример что хочется сделать и что не получится. Вариант в скобках изображает простой пример class-only протокола для наглядности.

@EnvironmentObject var a: SomeProtocol

Не скомпилируется

Да, не скомпилируется потому что для Wrapper скорее всего нужен Concrete Type

Я так и написал указав на ошибку в статье: "

@ObservedObject, @EnvironmentObject модифікатори не можуть бути Optional, а також можуть використовуватися тільки для Сlass-only протоколів
(protocol SomeProtocol: class { ... }).

"

Имел ввиду, что вы не можете использовать Concrete Types которые не class,
а не в смысле что вы можете использовать протоколы или generics внутри wrappers.

Подписаться на комментарии