Оптимальный архитектурный шаблон iOS

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В статье описывается оптимальный архитектурный шаблон большого iOS-приложения на Swift, выступающего преимущественно тонким клиентом. Основной упор делается на применение чистой архитектуры. Статья предназначается как новичкам, так и опытным программистам. Для удобства навигации имеется оглавление.

Автор: Олег Бахарев. iOS - тимлид.

Оглавление

Введение
Определения и принципы
Постановка проблемы
Что хотим получить
Решение - Чистая архитектура
Принципы SOLID
Выделение архитектурных уровней в нашем случае
Компоненты чистой архитектуры iOS
Краткий обзор распространённых архитектурных шаблонов iOS
Model-View-Controller
Model-View
Шаблоны Чистой Архитектуры iOS
VIPER
VIP (CleanSwift)
Оптимизация архитектурного шаблона
Simplified VIP - SVIP (NEW)
Анатомия SVIP
Интерактор
Презентер
Вид
Воркер
Конфигуратор
Масштабирование
Пакетная структура проекта
Заключение
Полезные ссылки

Введение

Зачем вообще нужно задумываться о программной архитектуре? Кажется, в программной архитектуре всё постепенно складывается подобно тому как рождалась архитектура строительная. Сначала люди строили по своему видению (т.е. в режиме стартапа), но по мере увеличения размеров построек (собачья конура, сарай, дом, дворец), строить становилось труднее и труднее, и тогда возникла необходимость упорядочить и систематизировать знания о принципах и методах строительства сооружений. Вот и в создании программного обеспечения (ПО) со временем (и ростом размеров) были обнаружены принципы и закономерности облегчающие создание ПО и в идеале позволяющие без особого труда (и прочих издержек) создавать продукты неограниченной сложности и размеров.

Обладая значительным опытом в создании крупных программных продуктов под iOS с тысячами файлов кода и сотнями тысяч пользователей, желаем поделится опытом эффективного (по трудозатратам и стоимости) создания таких продуктов.

Определения и принципы

Прежде чем углубляться в архитектурные тонкости, введем некоторый понятийный аппарат, которым будем впоследствии пользоваться.

Суть программной архитектуры - организация ПО в соответствии с принципами, определяющими регулярную структуру, с целью снижения трудозатрат и стоимости на сопровождение и развитие программного продукта.

Модель, определяющая регулярную структуру ПО, называется архитектурным шаблоном.

В отличие от архитектуры строительной, где крупные объекты по конструкции независимы от мелких, «дворцы» ПО состоят из «сараев», которые состоят из «конур». По этому кратко напомним элементарные принципы организации программного кода.

Прежде всего отметим, что программисты в больших проектах гораздо больше времени тратят на чтение и понимание кода, чем на его написание. По этому при написании кода следует отдавать предпочтение простоте его чтению и пониманию перед краткостью записи.

Любая программа занимается преобразованием входных данных в выходные. Выходные данные получаются путем преобразования входных данных посредством выполнения программного кода, задающего поведение программы. 

При этом, в процессе своей работы обычно создаются некоторые промежуточные, временные данные. Обозначим эти временные данные как состояние программы.

Определим программу как совокупность множеств состояния и поведения. 

  • Состояние - все временные данные (переменные) программы.

  • Поведение - програмный код преобразующий данные и управляющий состоянием программы.

Безотносительно к проблеме архитектуры следует всегда придерживаться следующих принципов написания кода, позволяющих упростить понимание кода:

  • Состояние следует всегда минимизировать.

  • Следует избегать параллельных данных. Т.е. дублирования информации в переменных состояния.

  • Поведение нужно стремиться выпрямлять. Т.е. минимизировать количество условных ветвлений в коде (цикломатическую сложность).

  • Функции следует разделять на команды (изменяют состояние, не возвращают данные) и запросы (не изменяют состояние, возвращают данные). Не следует смешивать одно с другим.

  • Каждая функция должна выполнять только одно действие и иметь название, определяющее это действие.

  • Современный код - это, зачастую, асинхронный код (async await). Следует избегать высокочастотных асинхронных вызовов. Например не следует обрабатывать смену позиции скроллинга асинхронным образом. Иначе это приведет к большим потерям производительности по причине значительного времени, затраченного на согласование асинхронного кода.

Отдельно упомянем конструктивную защиту от ошибок. В случае возникновения событий, значительно меняющих состояние программы, бывает полезно, вместо адаптации состояния к новым обстоятельствам, уничтожить состояние и воссоздать заново. Классический пример - это смена учетной записи в программе. Вместо того чтобы вручную чистить локальные кэши заполненные данными от прошлой учетной записи, лучше использовать механизм автоматической чистки кэшей при уничтожении состояния программы. И, при смене учетной записи, уничтожить старое состояние и воссоздать заново новое.

Практически все современные языки общего назначения (Swift - не исключение) поддерживают объектно-ориентированный подход (ООП) организации программного продукта. Он подразумевает разделение кода программы на классы, объединяющие состояние (поля) и поведение (методы) некоторой сущности, с возможностью ограничения доступа извне к состоянию и поведению класса. С архитектурной точки зрения, класс имеет публичную часть - доступную извне и приватную - доступную только изнутри класса.

Определим понятием инвариант класса внутреннюю согласованность состояния класса. Например, если класс содержит список строк и индекс для быстрого поиска по любой подстроке из этого списка, то инвариант выполнен, когда после добавления новой строки, индекс обновлен для поиска подстроки из добавленной строки.
Приведем некоторые полезные правила написания клссов:

  • Инкапсуляция состояния. Следует ограничивать доступ к состоянию класса извне этого класса. Причем не только на запись, но и на чтение. Ограничение на запись позволяет предотвращать нарушение инварианта класса. Ограничение на чтение состояние предотвращает вынос внутренней логики класса в другие места. Размывание логики существенно усложняет понимание кода.

  • Инкапсуляция поведения. В публичный доступ следует выносить минимальное количество методов, составляющий внешний интерфейс класса. Между вызовами публичных функций инвариант класса должен сохраняться. Приватные функции реализуют внутреннюю логику класса. Между вызовами приватных методов инвариант сохранять необязательно.

  • Из приватных функций класса не следует вызывать свои публичные. Между вызовами публичной функции инвариант всегда должен сохраняться. Перед вызовом публичной функции из приватной, инвариант класса может быть нарушен. Это может приводить к непредвиденным (и непредсказуемым) последствиям.

Постановка проблемы

С ростом размера проекта написанного без оглядки на архитектуру, рано или поздно обнаруживается существенный рост трудоёмкости и стоимости сопровождения и развития проекта. Причинами этого, по нашему мнению, являются:

  • Высокая связность кода. Разные части проекта зависят от множества других частей. Приходящим разработчикам становится трудно разобраться во внутренних зависимостях. Изменения в одних частях проекта приводят к поломкам в других частях.

  • Меняются требования и дизайн. Часто стартапы делаются исходя из ложного предположения, что требования и дизайн будут постоянными в течении всего жизненного цикла программы. Но это очень далеко от реальности. И когда (не если, а когда) в требования и дизайн начнут вносить существенные изменения, трудозатраты (и стоимость) реализации этих изменений могут увеличиваться по экспоненте относительно размеров уже написанного кода.

  • Устаревают старые и появляются новые технологии. В случае iOS за примерами далеко ходить не надо. На смену Objective-C приходит Swift. На смену UIKit приходит SwiftUI. И сам SwiftUI от года к году изменяется весьма существенно. На смену протоколу ObservableObject приходи макрос Observable. На смену CoreData приходит SwiftData. Выгода от применения новых технологий заключается в существенном снижении трудозатрат (и стоимости) на развитие продукта относительно старых технологий. Но возникает проблема совместимости новых технологий со старым кодом. Иногда эта проблема встаёт настолько остро, что становится дешевле полностью переписать приложение на новых технологиях чем адаптировать старое приложение к новым.

Особо отметим остроту проблемы перехода с UIKit на SwiftUI. Этот переход осложняется сменой парадигм программирования. UIKit использует императивный подход (последовательное выполнение инструкций, последующие инструкции могут применять результаты выполнения предыдущих), а SwiftUI использует декларативный подход (описание желаемого результата без указания способа его получения - как в HTML или SQL). Причем, в крупных проектах невозможен мгновенный переход от одной технологии к другой. Необходимо применять последовательный перевод одних частей программы за другими без остановки процесса выпуска обновлений программы.

Кроме этого, упомянем проблему покрытия кода автоматическими тестами. Тесты необходимы для того чтобы после внесении изменений мы имели существенные гарантии сохранения работоспособности программы. В больших программных продуктах с тысячами файлами кода и сотнями тысяч пользователей, задача покрытия становится насущно необходимой.Тестирование, кроме проверки работоспособности приложения на момент написания, существенно снижает в будущем стоимость сопровождения продукта через снижение затрат на повторное тестирование при внесении изменений. И дополнительно мотивирует разработчиков минимизировать состояние и упрощать поведение для того чтобы писать меньше тестов. Определение того, что и как тестировать, и приведение программы к «тестопригодному» виду тоже является задачей решаемой архитектурным (т.е. принципиальным) путем.

Что хотим в результате получить

Мы хотим получить архитектурный шаблон, который обеспечит:

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

  • Снижение связности кода.

  • Постепенный переход от UIKit к SwiftUI.

  • Покрытие автоматическими тестами.

Решение - Чистая архитектура

Для начала, обратим внимание на успешный мировой опыт организации программной архитектуры. А именно, на принципы дизайна программного обеспечения SOLID, сформулированные Робертом Мартиным в книге «Чистая Архитектура». Эти принципы стали настолько популярны, что архитектура, удовлетворяющая этим принципам стала называться «чистой»

Программную систему предлагается разделить на уровни по принципу от более предметного к более инструментальному. То есть, от бизнес-правил в сторону вариантов использования и к сетевому обмену, интерфейсу пользователя и базам данных. 

Принципы SOLID:

  • Принцип Единственной Ответственности (Single Responsibility). У сущности должна быть только одна причина изменения. Или у сущности должен быть только один заказчик, которого она обслуживает. Пытаться сделать сущность пригодной для различных вариантов использования - быстрый способ запутать логику и наделать ошибок.

  • Принцип Открытости/Закрытости (Open/Closed). Однажды заведенный публичный интерфейс сущности должен оставаться неизменным (Closed). Но допустимо добавлять новые методы (Open). Это особенно важно для библиотек общего пользования.

  • Принцип Подстановки Барбары Лисков (Barbara Lickov Substitution). Если сущность реализует некоторый интерфейс(протокол), то сущность можно использовать в любом месте где этот протокол используется. Недопустимо создавать реализацию, пригодную только в одном конкретном употреблении.

  • Принцип Разделения Интерфейса (Interface Segregation). Сущность для своей работы должна использовать только необходимые ей методы других сущностей. Это означает, что лучше иметь много маленьких интерфейсов, чем один большой.

  • Принцип Инверсии Зависимостей (Dependancy Inversion). Сущность не должна зависеть от конкретных реализаций других сущностей. Зависимость должна быть через интерфейсы (протоколы в Swift). При этом, направление зависимости меняет направление относительно потока управления во время выполнения (происходит инверсия зависимости относительно потока управления). См. пояснение на картинке в правом нижнем углу.

Выделение архитектурных уровней в нашем случае

Вооружившись этим принципами, а также учитывая фронт-енд специфику нашего приложения, выделим в коде 3 изолированные уровня:

  • Бизнес-логика. Всё состояние и поведение, обеспечивающее реализацию вариантов использования согласно техническому заданию(ТЗ) и ничего кроме него. Всё не относящееся к ТЗ выносится из Бизнес-логики. 

  • Вспомогательные Инструменты. Сетевой слой, обработка транспортных ошибок и общих нештатных ситуаций (не относящихся к варианту использования бизнес-логики), кэш данных, аналитика событий.

  • Интерфейс Пользователя (User Interface - UI). Платформенный UI-фреймворк (UIKit, SwiftUI), корпоративная дизайн-система, свёрстанный UI.

Определившись с уровнями (слоями) архитектуры, выберем архитектурный шаблон и разобьём наше приложение на функциональные модули, организованные в соответсвии с выбранным шаблоном. Каждый модуль - единица реализации архитектурного шаблона. В нашем случае, модуль - это единица показа. Это может быть сцена (экран) или часть сцены.

Указанное структурирование на модули и уровни обеспечивает или улучшает:

  • Понимание кода. Если код всего приложения имеет регулярную, многократно повторяющуюся структуру с явно выделенными компонентами, то разработчику становится проще разобраться в ранее незнакомых ему местах программы.

  • Сопровождение. Изменение в одних местах программы осуществляется полностью аналогично изменениям в других местах программы.

  • Отладку. Основное нетривиальное поведение изолированно в компактном слое бизнес-логики. Мы его стремимся изолировать, минимизировать состояние и упростить поведение.

  • Тестирование. Прежде всего, тестирование должно покрывать бизнес-логику, которая удобно изолирована от всего постороннего.

  • Повторное использование. Это относится больше к инструментальной части. Поскольку там нет бизнес-логики, постольку некоторые инструменты подходят к повторному использованию.

Всё это в совокупности снижает трудозатраты и стоимость на сопровождение и развитие крупного программного продукта.

К сожалению, далеко не всегда возможно соблюсти «чистоту» кода. В любой программе неизбежно возникнут сущности, в которых будут нарушены принципы SOLID. Например, там будут зависимости от конкретных классов. Такие сущности в противоположность «чистых» принято называть «грязными» (Р. Мартин). Например, это фабрики формирующие модули и роутеры, передающие управление другим сущностям.

Грязные сущности затруднительно тестировать (по причине явных завсимостей от конкретных внешних сущностей) и по этому в них не должно быть никакого переменного состояния и бизнес-логики.

Компоненты чистой архитектуры iOS

  • Интерактор: Инкапсулирует всё состояние и поведение бизнес-логики модуля. Никакой бизнес-логики не должно быть за пределами интерактора. Никакого доступа к состоянию интерактора извне быть не должно. Никаких запросов данных со стороны UI ( только команды)

  • Презентер: Является адаптером интерактора к визуальному представлению (UIKit или SwiftUI). Интерактор через презентер производит обновление UI.

  • Воркер: Является источником данных и объектом управления для интерактора. Интерактор запрашивает у воркера данные и вызывает команды обновления данных. Кроме этого, воркер может осуществлять вспомогательную инструментальную деятельность для интерактора (Хранить кэш данных или отправлять аналитику действий пользователя).

  • Роутер: Обеспечивает переход от одного модуля к другому (показ модулей). Интерактор обеспечивает роутер данными для показа модуля.

  • Вид: Взаимодействует с пользователем посредством системных фреймворков (UIKit/SwiftUI). Транслирует действия пользователя в интерактор.

Здесь мы только кратко (концептуально) описали компоненты чистой архитектуры. Позже мы подробнее рассмотрим каждый компонент уже применительно к выбранному архитектурному шаблону.

Краткий обзор распространённых архитектурных шаблонов iOS

Прежде чем рассматривать чистые архитектурные шаблоны, напомним основные классические архитектурные шаблоны, обозначив их достоинства и недостатки.

Model-View-Controller

MVC
MVC

Особенности:

  • Модель содержит состояние и бизнес-логику модуля.

  • Вид содержит интерфейс пользователя.

  • Контроллер обеспечивает взаимодействие модели с видом.

Достоинства:

  • Естественный архитектурный шаблон UIKit.

  • Хорошо известен разработчикам.

  • Большинство стартапов на UIKit создаются по этому шаблону.

Недостатки:

  • Перегрузка ответственностью контроллера. Тенденция к переносу бизнес-логики из модели в контроллер. Широко распространенное явление неконтролируемого разрастания контроллера, превращающего аббревиатуру MVC в «Massive View Controller».

  • Не подходит для SwiftUI, так как не поддерживает декларативную парадигму.

  • Изначально создавался без учета принципов чистой архитектуры (SOLID).

  • Автоматическое тестирование затруднено. Нет явных входных и выходных протоколов, позволяющих применять тестовые заглушки и моки.

Model-View

Model-View
Model-View

Особенности:

  • Модель содержит состояние и бизнес-логику модуля. А также данные для отображения, которыми снабжает вид.

  • Вид декларативным образом описывает интерфейс пользователя. При изменении отображаемых данных выполняет рендеринг обновленного интерфейса пользователя, а при взаимодействии с пользователем обновляет состояние модели.

Достоинства:

  • Естественный архитектурный шаблон SwiftUI.

  • Хорошо известен разработчикам.

  • Большинство стартапов на SwiftUI создаются по этому шаблону

Недостатки:

  • Не подходит для UIKit

  • Смешение состояний бизенс-логики и UI (макаронный код). Затрудняет понимание бизнес-логики модуля.

  • Создавался без учета принципов SOLID.

  • Автоматическое тестирование затруднено. Нет явных входных и выходных протоколов, позволяющих применять тестовые заглушки и моки.

Шаблоны Чистой Архитектуры iOS

VIPER (View, Interactor, Presenter, Router)

VIPER
VIPER

В данном шаблоне присутствуют все компоненты чистой архитектуры iOS.

Достоинства:

  • Все вышеописанные достоинства чистой архитектуры. Главные из которых - изоляция бизнес-логики и удобство её тестирования.

  • Широко распространен по причине самого раннего появления среди «чистых архитектурных» шаблонов iOS.

  • Подходит для применения и с UIKit и со SwiftUI. В случае SwiftUI презентер содержит в себе ObservableObject.

Недостатки:

  • Высокая структурная сложность: 5 сущности, 6 протоколов на модуль (каждая связь через протокол).

  • Перегруженный связями и задачами Презентер. Он и преобразует данные из интерактора для показа в Виде, и транслирует действия пользователя из Вида в интерактор.

  • Тенденция к переносу бизнес-логики в Презентер. Один из признаков - наличие в Презентере переменного состояния. Это очень сильно затрудняет понимание кода модуля. Именно это является главным недостатком этого шаблона.

VIP (Clean Swift)

VIP
VIP

Также является образцом чистой архитектуры.

Достоинства:

  • Чистая архитектура.

  • Связи внутри треугольника VIP направлены в одну сторону. Лучше изоляция сущностей. Конструктивная защита от переноса бизнес-логики из интерактора в презентер.

  • Презентер отвечает только за передачу данных в Вид. 

  • Также как и VIPER подходит для пременения и с UIKit и со SwiftUI.

Недостатки:

  • В Роутер идёт зависимость от Вида что приводит к необходимости дополнительной связи из Роутера в Интерактор за данными. 

  • Структурная сложность всё ещё высока: 5 сущностей, 6 протоколов

Оптимизация архитектурного шаблона

Общим неприятным свойством обоих рассмотренных чистых архитектурных шаблонов (VIPER, VIP) является высокая структурная сложность, обусловленная наличием связей между сущностями через протокол. Это требование архитектурной чистоты. Но так ли необходимо соблюдать архитектурную чистоту всех сущностей шаблона?

Напомним главные цели применения архитектурного шаблона:

  • Изоляция бизнес-логики в интеракторе

  • обеспечение тестируемости

  • Применимость шаблона и для SwiftUI и для UI Kit.

Обратим внимание на презентер. Если мы запрещаем в презентере иметь переменное состояние, то поведение презентера сводится только к преобразованию данных для показа в UI. Эти преобразования, как правило, тривиальны и их тестировать необязательно. Если в презентере нет бизнес-логики и нет необходимости его тестировать, то можно вывести его за пределы чистой архитектуры сделать презентер грязным. Это действие позволит существенно упростить архитектурный шаблон:

  • Связь из презентера в вид становится прямой, без промежуточного протокола.

  • Презенер объединяется с Роутером в одну сущность. Эти сущности очень похожи. Они не имеют переменного состояния, и являются грязными. Их не надо тестировать.

Заметим, что заменой Презентера и Вида на другие мы можем, не меняя бизнес-логику в интеракторе, поменять представление с UIKit на SwiftUI (и обратно). Таким образом, мы достигаем все поставленные цели.

Simplified VIP - SVIP (NEW)

SVIP
SVIP

Достоинства:

  • Существенное упрощение структуры. Имеем 4 сущности и 3 протокола. На одну сущность и на 3 протокола меньше сравнению с рассмотренными аналогами.

  • При этом нисколько не пострадала изоляция бизнес-логики в интеракторе.

  • Сохраняется удобство тестирования интерактора.

  • Cохраняется возможность применять наш шаблон и с UIKit и со SwiftUI. Причем в рамках одного проекта. Часть модулей может быть на UIKit, часть на SwiftUI.

Недостатки:

  • Теряется возможность тестировать презентер и вид автотестами. Но презентер покрывать модульными тестами необязательно потому что у него нет переменного состояния и его поведение тривиально. Для тестирования вида можно применять UI-тесты и эскизы(previews) в случае SwiftUI.

По нашему мнению достоинства шаблона SVIP с большим запасом перевешивают его недостатки. Далее мы подробно разберем устройство SVIP.

Анатомия SVIP

Экран ввода Пинкода
Экран ввода Пинкода

Разберем внутреннее устройство модуля SVIP на примере очень распространенного экрана ввода пинкода.

Интерактор

SVIP Интерактор
SVIP Интерактор

Intreractor - протокол бизнес-логики модуля (например PinCodeEnterInteractor). Его реализует класс (точнее actor) Logic (PinCodeEnterLogic). Выбор для протокола имени Interactor обусловлен указанием на его роль в архитектуре для связи на него из вида, т.е. все вызовы из вида в интерактор происходят как обращение к протоколу с названием Interactor через поле с названием interactor. А выбор имени Logic для класса указывает что Интерактор содержит только бизнес-логику и ничего кроме неё. Всё переменное состояние бизнес-логики только здесь. Все детали, не относящиеся к бизнес-логики выносим из интерактора. Кроме того, интерактор реализуем в виде actor, а его протокол наследуется из AnyActor. Все методы протокола делаем асинхронными. Выгода - автоматическое потокобезопасное перемещение бизнес-логики с главного потока. Все связи интерактора с другими сущностями (презентером и воркерами) делаем только через протоколы. Это требования чистоты архитектуры и удобства тестирования. Если связь через протокол, то удобно для тестов делать заглушки этих протоколов.

Важное замечание. Внутри интерактора не следует использовать сетевые транспортные сущности (т.н. Data Transfer Objects (DTO)) и сущности отображения (ячейки таблиц, коллекций и их элементы данных. Крайне желательно вообще даже не включать пакеты UIKit и SwiftUI в интерактор. Бизнес-логику следует изолировать от всего постороннего и зависящего от низкоуровнего сетевого обмена и форматов представления данных. Это облегчает понимание бизнес-логики и повышает устойчивость тестируемого кода к изменениям в постороннем относительно бизнес-логики коде.

Пример протокола интерактора:

protocol PinCodeEnterInteractor: AnyActor {
    func loadData() async
    func tapped(number: Int) async
    func logout() async
    func handle(action: PinCodeEnterData.RightBottomAction) async
}

Презентер

SVIP Презентер
SVIP Презентер

Presenter - протокол Презентера модуля (например PinCodeEnterPresenter. Его реализует класс Router (PinCodeEnterRouter). Такой выбор имен выбран из тех же соображений что и с интерактором. Протокол присутствует как связь из интерактора в прзентер. Все вызовы из Интерактора в Презентер происходят через поле presenter типа Presenter. Поскольку презентер не имеет переменного состояния (класс роутера может иметь поля данных, но они не изменяются в процессе жизни класса), постольку его необязательно тестировать. У него нет сложного поведения. Мы его исключаем из тестирования. А поскольку ранее мы его исключили из чистой части нашего архитектурного модуля, постольку связь презентера с представлением осуществляем непосредственно без промежуточного протокола.

Пример протокола презентера:

protocol PinCodeEnterPresenter: WBPresenter {
    func present(data: PinCodeEnterData.PresentationModel) async
    func presentDotsError() async
    func presentMainScreen() async
    func presentLogout() async
}

Конкретизируем задачи Презентера (Роутера) в шаблоне SVIP:

  • Формирование представления данных, переданных из интерактора.

  • Выполнение общих задач отображения данных: показ ошибок, показ индикатора загрузки, показ информационных баннеров, показ диалога «поделиться», копирование в буффер обмена и т.п.

  • Показ других модулей.

  • Скрытие данного модуля.

Большинство задач презентера может иметь реализацию по умолчанию. Если интерактор это актор, то презентер это обычный класс, но с пометкой, что все его методы должны выполняться на главном потоке (@MainActor). Это необходимое условие работы с UI. Все методы протокола Презентера помечаются как асинхронные, чтобы переход на основной поток осуществлялся незаметно.

Поскольку Презентер это класс, то он может иметь базовый класс в котором может быть функционал (без переменного состояния) для выполнения общих задач отображения.

Приведем пример базового функционала презентера:

public protocol WBErrorPresenter {
    func present(error: Error, hasLoadedContent: Bool) async
}

public extension WBErrorPresenter {

    /// Удобная обёртка для всех вызовов async throws workers из logic-акторов.
    func catching(
        hasLoadedContent: Bool = false,
        task: () async throws -> Void
    ) async {
        do {
            try await task()
        } catch is CancellationError {
            print("Current Task cancelled")
        } catch let error as URLError {
            if error.code == .cancelled {
                print("Current URL Request cancelled")
            } else {
                await present(error: error, hasLoadedContent: hasLoadedContent)
            }
        } catch {
            await present(error: error, hasLoadedContent: hasLoadedContent)
        }
    }

}

public protocol WBPresenter: WBErrorPresenter {
    func present(_ status: WBPresentation.Status, message: String) async
    func presentLoader(disableUI: Bool) async
    func presentHideLoader() async
    func presentClose() async
    func presentCopyToClipboard(text: String) async
    func present(share: Any) async
}

Приведем пример автоматической обработки ошибок в интеракторе:

extension PinCodeEnterLogic: PinCodeEnterInteractor {
    // ...
    func handle(action: PinCodeEnterData.RightBottomAction) async {
        switch action {
        // ...
        case .face, .touch:
            await presenter.catching {
                try await worker.authenticateWithBiometric()
                await presenter.presentMainScreen()
            }
        default: break
        }
    }
    // ...
}

Вид

SVIP Вид
SVIP Вид

Вид реализует интерфейс пользователя (UI). Архитектурный шаблон подразумевает независимость от способа реализции UI. Это может быть UIKit или SwiftUI. Можно перевести модуль с UIKit на SwiftUI только заменив роутер и вид, не трогая логику.

В случае с UIKit презентер непосредственно взаимодействует с UIViewController/UIView своего модуля. Имея доступ к своему UIViewController, презентер может презентовать или пушить ViewController’ы других модулей, выполняя функционал роутера (которым он у нас и является).

В случае SwiftUI презентер внутри себя содержит ObservableObject своего View, настраивая его для взаимодействия с пользователем. Поскольку ObservableObject модуля это класс, мы его наследуем от базового класса (например BaseStateObect), который дополнительно содержит @Published поля для автоматизации большинства общих действий (пуши и презентации видов других модулей, показ ошибок, показ экранов отсутствия данных, триггер закрытия). А со стороны View у нас заводится viewModifier (например, baseConfigure), который принимает экземпляр BaseStateObject и реализует все поведение общих на всё приложение задач (приведенных выше).

Воркер

SVIP Worker
SVIP Worker

Воркер является механизмом, представляющий внешнюю деталь интерактора. Например, это может быть источник данных и/или объект управления интерактора. Воркеров может быть несколько. В качестве воркера может использоваться как некий общий на всё приложение сервис или интерактор вызывающего модуля (Так происходит масштабирование архитектурного шаблона, но об этом позже). Почти всегда, воркер это актор потому что он или вызывающий интерактор или общесистемный сервис, который используется через механизм внедрения зависимостей (depndancy injecting - DI) в асинхронном и параллельном режиме.

Пример протокола воркера:

protocol PinCodeEnterWorker: AnyActor {
    var biometricType: PinCodeEnterData.BiometricType { get async }
    var pinCode: String { get async }

    /// Биометрическая аутентификация. В случае отмены кидает CancellationError
    func authenticateWithBiometric() async throws
}

Конфигуратор

Каждый модуль имеет конфигуратор. Конфигуратор принимает входные параметры модуля, собирает модуль из различных частей и возвращает вид модуля для показа. Конфигуратор вызывается из роутера вызывающего модуля. Если есть реализация модуля для вариантов UIKit и SwiftUI, то можно заменить одну реализацию на другую, вызвав соответствующий конфигуратор. Обратим внимание, что реализации чистой части архитектурного шаблона SVIP (интерактор и воркер) в обоих реализациях одинаковы.

Приммер кофигуратора для SwiftUI

public struct PinCodeEnterConfigurator {

    public static func configureModule() -> some View {
        let presenter = PinCodeEnterRouter()
        let worker = PinCodeEnterService(settingsProfile: SettingsProfile())
        let interactor = PinCodeEnterLogic(presenter: presenter, worker: worker)
        let viewModel = PinCodeEnterViewModel(interactor: interactor)
        let view = PinCodeEnterView(viewModel: viewModel)
            .onAppear { presenter.viewModel = viewModel }
        return view
    }

}

Пример конфигуратора для UIKit

public struct PinCodeEnterLegacyConfigurator {

    public static func configureModule() -> UIViewController {
        let presenter = PinCodeEnterLegacyRouter<PinCodeEnterLegacyViewController>()
        let worker = PinCodeEnterService(settingsProfile: SettingsProfile())
        let interactor = PinCodeEnterLogic(presenter: presenter, worker: worker)
        let viewController = PinCodeEnterLegacyViewController(interactor: interactor)

        presenter.delegate = viewController

        return viewController
    }

}

Мы почти закончили обсуждать внутреннее устройство шаблона SVIP. Осталось только обсудить схему владения сущностями внутри модуля SVIP. Итак, у нас 3 сущности (Интерактор, Презентер, Вид), которые ссылаются по кругу друг на друга. Интерактор хранит ссылку на презентер, презентер на вид, вид на интерактор. Чтобы не возникла утечка памяти из-за сильного цикла, одна из связей должна быть слабой. Заметим, что конфигуратор возвращает именно вид модуля. Значит, вид модуля является сущностью, от которой строится цепочка владения сущностей в памяти (для SwiftUI это StateObject вида). По этому вид владеет интерактором (сильная связь). Интерактор владеет презентером (сильная связь). А вот презентер ссылается на вид по слабой ссылке, чтобы не возник сильный цикл. Для разрушения всего модуля, достаточно разрушить вид модуля.

Пример структуры модуля из вышеприведенного примера (Legacy - только для примера. В реальных модулях её не будет):

В реальных приложениях для построения каркаса модуля удобно использовать кодогенераторы вроде Generamba

Масштабирование

Ранее мы ввели понятие инвариант класса. Модуль тоже имеет свой инвариант. Это согласованность состояния его бизнес-логики в интеракторе. Задача интерактора - сохранять и контролировать инвариант модуля в процессе взаимодействия с пользователем, а также в процессе межмодульного взаимодействия (с модулем показывающим этот и с модулями показываемыми из этого - субмодулями).

Интерактор модуля может быть воркером субмодуля
Интерактор модуля может быть воркером субмодуля

Важное правило: Изменения в субмодулях не должны приводить к изменению инварианта родительского модуля. Для выполнения этого правила рекомендуется делать родительский интерактор воркером субмодуля. В этом случае воркер естественным образом узнаёт об изменении состояния, произведенном в субмодуле и может обновить состояние и сохранить свой инвариант перед следующим запросом данных из субмодуля. Таким образом, модуль сам контролирует сохранение своего инварианта. Альтернативное, не самое лучшее решение - передача в воркер субмодуля воркера родительского модуля и отлов уведомлений об изменении субмодуля. В этом случае, инвариант родительского модуля полагается на правильность поведения в субмодуле, теряя контроль над своим инвариантом.

Пакетная структура проекта

Современное крупное приложение следует разбить на отдельные пакеты. Пакетная структура позволяет:

  • Независимо собирать модули, тестировать и даже выполнять с пользовательским интерфейсом в случае SwiftUI. Независимо - означает что каждый пакет содержит в себе все ресурсы, код и зависимости на другие пакеты, которые ему необходимы для сборки. Также пакет содержит, тесты и моки, для независимой от всего приложения отладки.

  • Минимизировать связи и зависимости между частями проекта. Аппарат ограничения видимости сущностей Swift (public/internal) работает только на межпакетном уровне.

  • Эффективно (быстрее сборка) разрабатывать проект по командам(стримам).

  • Вынимать пакеты (со всеми зависимостями) из одного проекта и вставлять их в другой.

  • Полностью заменять один пакет другим (вместо рефакторинга) в случае глубокого обновления подсистемы.

Пример пакетной структуры реального проекта:

Пример отдельной разработки пакета в большом приложении:

Полностью функциональный экран SwiftUI при разработке в отдельном пакете
Полностью функциональный экран SwiftUI при разработке в отдельном пакете

Заключение

Мы рассмотрели оптимальный, с нашей точки зрения, архитектурный шаблон для iOS SVIP, который позволяет соблюдать чистоту и тестируемость бизнес-логики с наименьшими относительно альтернатив структурными издержками, и, при этом, подходит для применения со SwiftUI и с UIKit, что позволяет осуществить постепенную миграцию с одной парадигмы UI на другую. Хочется надеяться, что статья окажется полезной и для Android-разработчиков. Там существует похожая проблема миграции приложений с Android View на Jetpack Compose. Спасибо за внимание.

Полезные ссылки

  • Роберт Мартин "Чистая Архитектура"

  • Кент Бек "Мастерство программирования"

  • Raúl Ferrer García "iOS Architecture Patterns"

  • Олег Бахарев "Идеальный наблюдатель на Swift"

  • Олег Бахарев "Идеальный REST клиент iOS"

Источник: https://habr.com/ru/companies/wildberries/articles/798275/


Интересные статьи

Интересные статьи

Привет! Меня зовут Ксения Гаврилова, я дизайн-менеджер команды продуктовых дизайнеров в Selectel. Определяю и поддерживаю дизайн-процесс и качество дизайна продуктов в компании, занимаюсь поиском и ...
Иногда бывает необходимо создать множество повторяющихся документов, которые отличаются лишь номером, датой и ещё парой текстовых строк. Очень грустно тратить на их создание своё время - ведь требуетс...
В предыдущей статье, посвященной перебору элементов кортежей, мы рассмотрели только основы. В результате нашей работы мы реализовали шаблон функции, который принимал кортеж и мог красиво вывести его в...
Увидело свет очередное обновление небольшой библиотеки для встраивания асинхронного HTTP-сервера в C++ приложения: RESTinio-0.6.12. Хороший повод рассказать о том, как в этой версии с пом...
Эта статья является заключительной в серии о применении архитектурного шаблона MVI в Kotlin Multiplatform. В предыдущих двух частях (часть 1 и часть 2) мы вспомнили, что такое MVI...