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

Все, кто хочет не просто знать что стоит за названием той или иной архитектуры, но ещё и в каком случае какую использовать — наливайте чай и устраивайтесь поудобнее, будет лампово. Разбираем паттерны, реализующие концепцию Чистой Архитектуры — самые масштабируемые и надёжные.

Введение

Привет, Хабр! Я всё ещё ведущий инженер-разработчик iOS в КРОК и аспирант-препод в МЭИ. В этом посте я рассказывала про архитектурные паттерны MV(X). У всех MV(X) архитектур есть один общий недостаток: они не описывают как должно происходить взаимодействие между экранами, только то как данные циркулируют внутри экрана.

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

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

По сути, MV(X) архитектуры и не являются архитектурами вовсе — это презентационные паттерны, которые описывают слой представления и не более того. В этом-то и кроется вся проблема! Мы пытаемся использовать MVC как архитектуру, когда это просто паттерн для описания её кусочка.

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

Основная часть заблуждений относительно того, является MV(X) “архитектурой” или нет кроется в том, что в MV(X) всегда отделяют слой Модели, и кажется что этого как-то достаточно. Но на самом деле нет.

Дело в том, что зачастую бизнес-логику удобно поделить на два уровня:

  1. Логика предметной области (Enterprise business logic) — описывает собственно бизнес-процессы. Например объект “студент” можно преобразовать в “магистра” и в “программера” (по отдельности или одновременно) — это описание предметной области. Также при преобразовании “студента” в “магистра” ему необходимо выдать шапочку — то есть это уже целый бизнес-процесс.
  2. Логика приложения (Application logic) — описывает процессы и потоки данных внутри приложения. Как правило, они мало связаны с логикой предметной области и больше зависят от UI/UX дизайна и “внутренней кухни” платформы. Например, чтобы этот человечек получил красивую шапочку, нужно запустить бизнес-процесс его преобразования из “студента” в “магистра”, а для этого надо перейти на экран выдавания шапочек и нажать кнопку “дать шапку” — это и есть логика приложения. Иначе она может называться как “сценарии использования” (Use Cases) и, вообще говоря, описывает то, как модели предметной области применяются в нашем приложении.

В итоге получаем два вложенных слоя: логика приложения смотрит на логику предметной области. А логика предметной области ни от чего не зависит (с точки зрения архитектуры ПО) — она либо есть, либо её нет.

Разворачивая зависимости дальше увидим, что от логики приложения зависят все наши контроллеры и/или презентеры: они сами по себе просто не знают что и когда показывать, они знают только как показывать: через Виды.

Виды и весь UI-специфичный слой зависит, понятное дело, от презентеров и контроллеров.

Непокрытыми у нас остались только всякие нетворк-слои, data access layers и различные внешние зависимости. Обобщая, мы увидим, что во внутренних слоях, описанных выше, им места нет, а значит, придется их вывести наружу.

Получим примерно вот такое [1]:

Каждый круг изображает части ПО. Внешние круги описывают механизмы взаимодействия, а внутренние — правила взаимодействия [1]. Названия в секторах на иллюстрации примерные и не обязаны быть именно такими [4] (может приложение вообще без нетворка работает — имеет право!), и приведены просто чтобы вы представили что именно представляет из себя тот или иной слой. По сути их можно поделить следующим образом:

  1. Сущности (предметная область и логика)
  2. Сценарии использования (логика приложения)
  3. Адаптеры интерфейсов (контроллеры, презентеры — всё, что помогает внешним фреймворкам общаться с приложением)
  4. Внешние фреймворки (и/или устройства)

Чем выше поднимаемся (чем ближе к центру) — тем более высокоуровневое описание системы получаем. В итоге слой “сущности” полностью оторван от какой-либо реализации системы и в тупую описывает предметную область как она есть. Что позволяет:

а) переиспользовать описание предметной модели, просто взяв сорсы и впихнув в другое приложение

Гексагональная архитектура

Похожий принцип выделения адаптеров и логики используется в так называемой гексагональной архитектуре (Hexagonal Architecture, она же Ports&Adapters Pattern) за исключением того, что в гексагональной архитектуре нет разделения на слои.

Вкратце, вот отличная иллюстрация этого подхода [1]:

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

Но почитать о ней подробнее можно здесь:

[1] Hexagonal Architecture for iOS. An architecture pattern that focuses on… | by Oleksandr Stepanov

[2] Clean and Hexagonal Architectures for Dummies | by Luís Soares | CodeX | Mar, 2021

Если представить эту же диаграмму как пирамидку, то запомнить что UI — это низкоуровневое, а предметная модель — верхнеуровневое, становится совсем легко:

Более того, таких слоёв может быть и больше (и меньше): порой имеет смысл какие-то из них разделить и вложить друг в друга или наоборот. Если говорить о чистой архитектуре в целом, то такую задачу разделения слоёв приходится решать каждый раз заново — это и есть “проектирование архитектуры”. В [4] предлагается разделять компоненты на верхнеуровневые “политики” и низкоуровневые “детали”:

  • политики — это правила по которым что-то происходит с данными (бизнес-логика, правила валидации)
  • детали — это компоненты, которые что-то делают с данными согласно политикам (СУБД, UIKit)

Ведь по сути любое приложение — это одна большая “политика”: что надо сделать с данными, поданными на вход, чтобы выдать юзеру нужный ему результат. Поэтому главное в разработке приложения — определить все политики (а не нарисовать красивые кнопочки).

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

Правило зависимостей

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

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

Это называется “правило зависимостей” (Dependency Rule) [1], и означает оно следующее: ничто во внутреннем круге не может знать или как-либо ссылаться на что-либо во внешнем круге. К примеру, ни одно понятие (класс, функция, переменная), упомянутое во внешнем круге, не должно упоминаться во внутреннем.

Так, например, предметная логика не должна знать о том, на каком экране расположена кнопка “выдать шапку” и что вообще такая кнопка существует.

Это правило относится и к форматам данных: внутренние круги оперируют одними форматами данных, а внешние — другими. Мы не хотим, чтобы внешние круги влияли на работу внутренних. Их задача передавать нам информацию, а не ломать нас!

Примечание: под “форматами данных” тут конечно же имеются в виду модели: классы или структуры, которыми описываются те или иные объекты.

Источники:

[1] Clean Coder Blog | The Clean Architecture

[2] madetech/clean-architecture: A (work-in-progress) guide to the methodology behind Made Tech Flavoured Clean Architecture

[3] Заблуждения Clean Architecture / Блог компании MobileUp / Хабр

[4] Clean Architecture | A CRAFTSMAN’S GUIDE TO SOFTWARE STRUCTURE AND DESIGN — Robert C. Martin

Примеров реализации чистой архитектуры среди архитектурных паттернов для iOS немало, наиболее известными из них являются пожалуй VIPER и CleanSwift.

VIPER

VIPER — страшный сон большинства iOS разработчиков на рынке. На интервью вопросы про VIPER вызывают разные реакции, но самая распространенная — “бей и беги”.

Кажется, VIPER — это что-то прикольное, давайте разберёмся, что это все-таки за зверь.

VIPER расшифровывается в схожей манере с MV(X):

  • View — показывает что скажет Презентер и передает ввод пользователя Презентеру
  • Interactor — содержит описание сценария использования
  • Presenter — содержит логику отображения и умеет подготавливать данные для представления пользователю, а также реагировать на ввод пользователя
  • Entity — описание предметной модели
  • Routing — описывает логику навигации между экранами

VIPER — это про SOLID, так что у нас так много компонент для того чтобы обеспечить S из SOLID — Single Responsibility Principle (принцип единственной ответственности: это когда каждый элемент отвечает за что-то одно).

Если рисовать, все это будет выглядеть примерно так:

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

  • Вид: наша любимая связка UIView+UIViewController.
    • знает о Презентере
      • посылает ему действия пользователя
      • получает от него запросы на обновление представлений
  • Презентер: содержит связанную с UI бизнес-логику (при этом не зависит от UIKit)
    • знает об Интеракторе
      • посылает ему запросы данных
      • получает от него события об обновлении данных
    • знает о Роутере
      • получает от него запросы на отображение Вида
    • влияет на Вид
      • посылает ему запросы на обновление представлений
  • Роутер: описывает навигацию между экранами (или VIPER модулями, если они не равны “экранам”)
    • влияет на Презентер
      • посылает ему запросы на отображение Вида
  • Интерактор: описывает взаимодействие с данными: что откуда взять и куда сохранить
    • влияет на Презентер
      • получает от него запросы данных
      • отправляет ему события об обновлении данных
    • знает о Моделях
      • использует Модели чтобы структурировать данные в принятый формат
  • Модель (Сущность, Entity): описывает структуру данных
    • больше ничего не умеет
    • это реально просто описание

Как мы видим, принципиально изменились две вещи:

  1. Модель стала тупым описанием данных, без какой-либо логики обработки или, боже упаси, CRUD
  2. Ответственность сильно поделилась между Презентером и Интерактором:
    1. Мама-Презентер отвечает за UI: просит данные у папы-Интерактора, подготавливает их для малыша-View и говорит малышу когда, как и что показывать
    2. Папа-Интерактор отвечает за данные: когда Презентер просит что-то показать, именно Интерактор идёт в базу, делает всякий CRUD, а потом отдает Презентеру готовый ответ (получилось или нет, “вот данные” или “вот ошибка”)

Так вот и выглядят VIPER модули. Если уж быть до конца честным, то в жизни они выглядят скорее вот так:

Интерактор может работать с несколькими Моделями, а Data Access Layer выделен в отдельный компонент, например сервис.

Глядя на диаграммы, несложно заметить, что собирать всё это дело так, чтобы соблюсти правило зависимостей — непросто. Настолько, что проще всего выделить под сборку еще один, отдельный компонент, который знает все обо всех (похоже на MPV+C, как и весь VIPER похож на MVP) и где можно легко “подставить” вместо старого, например, интерактора новый. Такие компоненты называются Builder [6] (или Assembly [3]).

Рисовать это довольно страшно, давайте я просто покажу пример кода:

Тем временем в роутере другого модуля по имени Main:

Не идеальный, но показательный пример из https://github.com/theswiftdev/tutorials

Можно вместо этого сделать полноценный ServiceLocator или использовать уже готовый в составе Swinject и других библиотек. Подробнее про DI можно почитать в этой крутой статье.

Если полазить по репозиториям с VIPER кодом (особенно по шаблонизаторам и тем, которые описывают туториалы, например [3, 6, 7, 8]), можно больно напороться на кучу протоколов, классы-интерфейсы и все вот это вот ООП-шно абстрактное.

С одной стороны, все это — велосипед, прикрывающий проблему сборки и неспособность VIPER соответствовать парадигме UIKit. С другой, каждый такой протокол и наследование — очередной непокрытый участок кода, который придется тестировать отдельно. А значит, хоть VIPER и testable out of the box — но тестов придется написать в X раз больше, чем хотелось бы.

Источники:

[1] Architecting iOS Apps with VIPER · objc.io

[2] iOS Architecture Patterns and Best Practices for Advanced Programming — 2021

[3] strongself/The-Book-of-VIPER: the one and the only

[4] Getting Started with the VIPER Architecture Pattern

[5] The Good, The Bad and the Ugly of VIPER architecture for iOS apps.

[6] The ultimate VIPER architecture tutorial

[7] https://github.com/BinaryBirds/swift-template

[8] https://github.com/infinum/iOS-VIPER-Xcode-Templates

RIBs

Описанная выше вариация VIPER с билдером на самом деле очень близка к другому архитектурному паттерну, который придумали в Uber и назвали RIBs Architecture.

И если VIPER пытается привнести в Android какие-то iOS-специфичные проблемы (например высокую связность View и ViewController), то RIBs — наоборот, привносит Android-специфичные заморочки в iOS 🙂 Впрочем, и то и другое позволяет нам с наименьшими потерями “переписывать” код с одной платформы под другую, и, если верить Uber, RIBs с этой задачей справляется лучше.

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

RIBs заключается в том, что существуют определенные RIB-блоки, которые взаимодействуют между собой.

Router — осуществляет навигацию между RIB-блоками

Interactor — содержит бизнес-логику RIB-блока

Builder — конструктор, который собирает RIB-блок

Вид и Презентер в этой иерархии опциональны: если в RIB-блоке нет необходимости рисовать UI, то Вид не нужен; если нет необходимости переводить данные из одного формата в другой — Презентер не нужен. Формально тут Презентер как правило является протоколом, по которому Интерактор общается с Видом.

Еще есть Компонент — это такая хитрая штука, которая управляет зависимостями RIB-блока. У RIB-блока есть определенные зависимости: такие вещи, которые спускаются ему указом сверху от родительского RIB-блока и хочется, чтобы они были корректно сформулированы. RIB Dependency — это протокол, описывающий такие зависимости. А RIB Component — это реализация такого протокола. (Если вы знакомы с Android и Dagger, то уже знаете о каких компонентах идёт речь ;))

То есть в Builder дочернего RIB-блока благодаря Component получаются от родителя все зависимости, которые этому ребёнку необходимы (и на шторы скинуться не забудьте!)

Выглядит немного запутанно, но на самом деле всё довольно просто. Presenter и View — это одна целая штуковина, при необходимости опущенная или разделённая. Связь Builder с Component отображает процесс Dependency Injection на этапе сборки RIB-модуля. Router просто делает своё навигационное дело, к нему вопросов нет.

В итоге мы получаем возможность строить дерево из RIB-блоков не так, как захочет Navigation Controller и левая пятка UIKit, а так, как того требует бизнес-логика. Это удобно, прозрачно и понятно, к тому же сохраняется чистота наследований: дети не знают ничего о своих родителях, а родители знают о своих детях.

Вот дурацкий пример приложения-будильника-с-погодой:

  • Корневой RIB может включить в себя RIB с флоу будильника либо RIB с флоу погоды
  • RIB будильника умеет добавлять новый будильник и редактировать существующий
  • а RIB погоды — показывать мою погоду и новости о погоде вообще

В итоге состояние приложения управляется и определяется тем, какие RIB-ы в текущий момент включены в дерево. К примеру если пользователь хочет добавить новый будильник, наше дерево (граф DI) будет выглядеть так:

Основная фича этого конструкта в том, что каждый RIB может управлять состоянием (навигироваться) только внутри своей “ветки”: к примеру из Alarm RIB нельзя попасть в News RIB. И он не принимает никаких решений, как только мы попали в Add new alarm RIB.

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

В итоге RIBs дает нам хорошо структурированный и высоко модуляризированный код, полную свободу в разрезе параллельной разработки, общий язык с Android-командой и кучу головной боли в разрезе DI.

Источники:

[1] uber/RIBs: Uber’s cross-platform mobile architecture framework.

[2] iOS Architecture: Exploring RIBs. Uber mobile architecture in details | by Stan Ostrovskiy | The Startup

[3] Как мы внедряли архитектуру RIBs. Доклад Яндекс.Такси

CleanSwift

CleanSwift — это хорошая альтернатива VIPER и ещё один способ переложить концепцию Чистой Архитектуры на iOS разработку.

В отличие от VIPER, в CleanSwift парадигма UIKit с центральным элементом в виде UIViewController остается нетронутой, что позволяет нам сделать чистую архитектуру, не выдумывая велосипеды, а естественно вырастая из MVC.

CleanSwift ориентируется на VIP-модули: это тройка View-Interactor-Presenter, которая общается друг с другом посредством специальных структур данных, привязанных к взаимодействию одной компоненты с другой.

Так, например, ViewController запрашивает данные для отображения у Интерактора с помощью структуры RequestИнтерактор передает данные в Презентер через Response, а Презентер преобразует полученные данные в вид, удобный для отображения Контроллером Вида, то есть собирает ViewModel и отправляет в ViewController:

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

Так, благодаря Request и Response, Интерактор ничего не знает о том как устроен изнутри Контроллер Вида. Ему нужны работники в виде массивов со сквозным индексированием, или в виде множества объектов? Интерактору все равно! Он отдаст Презентеру данные в том виде, в котором Презентер сможет их понять (Response), а уже Презентер приведет их в вид, нужный Контроллеру Вида (ViewModel).

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

Добавив в такой VIP модуль дополнительно компоненты для внешних взаимодействий (с другими модулями и с хранилищами данных) получим следующий концепт:

  • Контроллер Вида: делает всякие UIViewController штуки
    • знает об Интеракторе
      • посылает ему действия пользователя
      • запрашивает у него данные (с помощью Request)
    • знает о Роутере
      • запрашивает у него логику навигации, когда необходимо перейти с текущего экрана на другой
    • знает о Презентере:
      • получает от него запросы на отображение данных (via Viewmodel)
  • Интерактор: осуществляет логику приложения, знает как действовать в ответ на действия пользователя
    • знает о Презентере:
      • посылает ему запросы на отображение данных (с помощью Response)
    • знает о Воркере:
      • запрашивает у него данные из Data Storage (Persistence или Network API)
    • влияет на Контроллер Вида:
      • получает от него запросы данных (via Request)
      • получает от него действия пользователя
  • Презентер:
    • влияет на Интерактор:
      • получает от него данные для отображения
    • влияет на Контроллер Вида
      • передает ему данные, полученные от Интерактора (Response) в виде, удобном для отображения (ViewModel)
  • Роутер: осуществляет общение с другими модулями
    • влияет на Контроллер Вида:
      • получает от его запросы на навигацию и/или передачу данных другим модулям (другим ViewController)
  • Воркер: прослойка между Интерактором и Data Access Layer
    • влияет на Интерактор
      • получает от него запросы на получение данных из Data Storage (Persistence или Network API)

Такая структура позволяет нам полностью разделить непосредственно работу с UI (ViewController), адаптацию данных для вывода (Presenter), логику приложения (Interactor), работу с хранилищами данных (Worker), а также навигацию и сообщение между модулями (Router).

В итоге получаем в определённой степени лаконичные и понятные MVC-новичку классы-сателлиты UIViewController, которые он сам же и создает, когда Роутер из другого модуля создаёт его, чтобы отобразить на экране.

Источники:

[1] The Clean Swift Handbook

[2] Общее представление об архитектуре Clean Swift / Хабр

[3] Clean Swift · GitHub

И?

Глядя на паттерны чистой архитектуры очень легко обмануться и решить, что вот только эти паттерны ТРУ, а остальные — так, самописное что-то на коленочке. Это, разумеется, не так. И надо всегда четко понимать каких именно целей вы хотите достичь, выбирая архитектурный паттерн для своей кодовой базы (возможно что в разных частях приложения вы и вовсе захотите использовать разные паттерны?)

Паттерны Чистой Архитектуры хороши тем, что они создают чёткую структуру взаимодействия между компонентами (модулями/слоями/классами) — это во многом облегчает реализацию новых фич/экранов (бОльшая часть кода уже есть, осталось дописать логику и вёрстку), а также чтение кодовой базы в целом — всё написано в едином стиле и уже понятно где искать баги определённого рода.

С другой стороны, если команда действительно большая, то следить за соблюдением выбранного паттерна становится тяжелее (какой бы вы ни выбрали, если честно). Но с паттернами Чистой Архитектуры это доставляет особенное неудобство, связанное с тем, что при добавлении одного экрана надо проревьюить не один класс, а в среднем около пяти. А при недостаточном контроле выходит так, что каждый разработчик понимает паттерн “по-своему” и каждый модуль оказывается описан по-разному. И тогда весь смысл теряется :С

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

P.S. В своем предыдущем посте я рассмотрела MV(X) архитектуры — https://habr.com/ru/company/croc/blog/549590/

C оригиналом статьи можно ознакомиться на Хабре