
Хороший код начинается с архитектуры, и iOS-приложения не исключение. Есть много стандартных паттернов, но цель этой статьи рассказать не о них, а об опыте адаптации одного из них и выработке собственного. Мы назвали эту адаптацию HandsAppMVP.

В iOS-разработке архитектура в первую очередь определяет организацию классов и зависимостей для одного конкретного ViewController. Впрочем, центральным компонентом может выступать не только он, но и просто UIView. Выбор зависит от конкретной задачи.
Сравнение архитектур
Существует несколько стандартных архитектурных шаблонов для iOS: MVC, MVP, MVVM, VIPER (ссылки на описание каждого можно найти в конце статьи).
Выбирая архитектуру для разработки, мы выделили основные параметры, которым она должна соответствовать: скорость разработки, гибкость и низкий порог входа. Далее мы занялись сравнением трех известных архитектур с учетом этих параметров (шаблон MVC iOS-комьюнити давно закопало из-за грубого несоблюдения single responsibility).
Для аутсорс-команды особенно важна скорость разработки. VIPER — самая сложная и «медленная» архитектура, быстрее разработка идет с применением чистого MVP или MVVM, так как в них меньше компонентов.
Гибкость подразумевает безболезненное добавление или удаление функционала в приложении. Этот параметр сильно коррелирует со скоростью разработки на всех этапах жизни приложения, кроме начального. Также гибкость тесно связана с простотой тестирования — автоматические тесты дают разработчику уверенность в том, что он ничего не сломает, и позволяют избежать багов. Классическая MVP плохо покрывается тестами, особенно если не использовать рассмотренные далее интерфейсы классов. MVVM с точки зрения тестирования также имеет плохие показатели, потому что тестирование реактивного кода занимает значительно больше времени. VIPER отлично подходит для написания тестов, потому что в нем максимально соблюдается принцип единственной ответственности и классы зависят от абстракций.
И последний параметр, который мы рассматривали, — порог входа. Он показывает, насколько быстро новые разработчики (в первую очередь — джуны) вникают в архитектуру. Здесь MVVM с применением сторонних реактивных библиотек (RxSwift, PromiseKit и т. п.) занимает почетное последнее место по очевидным причинам. VIPER также довольно сложная архитектура в силу большого количества компонентов. MVP имеет самый низкий порог входа.
Взвесив все за и против, мы пришли к выводу, что нам нужно что-то такое же простое, как MVP, и такое же гибкое, как VIPER. Так и родилась идея создать на их основе свою архитектуру — HandsAppMVP.
Расширяем MVP
Основные компоненты нашей архитектуры — Model, View, Presenter. Они выполняют те же функции, что и в классической MVP по известной схеме:

[Схема классического MVP]
Здесь и далее на схемах каждый компонент взаимодействия (синий квадрат) — это класс, время жизни которого совпадает с временем жизни View. Сплошная стрелка обозначает владение другим объектом, строгую ссылку, а пунктирная — слабую ссылку. С помощью слабых ссылок мы предотвращаем циклические зависимости и утечку памяти.
Интерфейсы
Первым делом мы добавили в эту классическую схему интерфейсы ViewInput и ViewOutput. Учли пятый принцип SOLID — принцип инверсии зависимостей. Он является скорее не дополнением, а уточнением для MVP. Зависимость от абстракций помогает избавиться от строгой связанности компонентов и позволяет нормально писать тесты. Схема с учетом интерфейсов выглядит так:

[Добавление интерфейсов ViewInput и ViewOutput]
Маленький прямоугольник — интерфейс.
Внимательный разработчик спросит, где интерфейсы для Model? Сейчас к ним переходим.
Работа с данными
Модель данных в мобильных архитектурах — собирательное понятие. Стандартный пример: приложение стучится в сеть для взаимодействия с сервером, затем сохраняет данные в CoreData для офлайн-работы, некоторую простую информацию записывает в UserDefaults и хранит JWT в Keychain. Все эти данные, с которыми ведется взаимодействие, составляют Model.
Класс, который отвечает за взаимодействие с контейнером данных конкретного типа, мы называем сервисом данных. Для каждого контейнера (удаленная база данных, локальная база данных, UserDefaults и пр.) в HandsAppMVP добавляется сервисный класс, который взаимодействует с презентером. Теперь можно также добавить интерфейсы input/output для каждого сервиса данных:

[Добавление сервисов для работы с данными]
Не каждый сервисный класс необходимо подключать к презентеру с помощью интерфейса, как, например, при использовании Moya. Moya — open-source-библиотека для работы с сетью. Moya предоставляет готовый сервисный класс (MoyaProvider), и при написании тестов нам не приходится делать mock-объект, заменяющий ApiProvider. В Moya предусмотрен специальный тестовый режим, при включении которого MoyaProvider не стучится в сеть, а возвращает тестовые данные (подробнее можно почитать по ссылке). Презентер при этом ссылается не на абстракцию MoyaProvider, а на реализацию. А обратную связь от этого сервиса мы получаем с помощью замыканий. Пример реализации можно посмотреть в демопроекте.
Этот пример скорее исключение, чем правило, и показывает, что беспрекословное соблюдение SOLID не всегда лучшее решение.
Навигация
Навигацию в приложении мы рассматриваем как отдельную ответственность. Для нее в HandsAppMVP используется специальный класс — Router. Router содержит weak-ссылку на View, с помощью которой может показать новый экран или закрыть текущий. Router также взаимодействует с презентером c помощью интерфейса RouterInput:

[Добавление компонента для навигации (Router)]
Сборка компонентов
Последнее дополнение классического MVP, которое мы используем, это Assembly — класс-сборщик. Он используется для инициализации View и остальных компонентов HandsAppMVP, а также для внедрения зависимостей. Assembly содержит единственный открытый метод — `assemble() -> UIViewController`, результатом выполнения которого является нужный UIViewController (или UIView) c необходимым графом зависимостей.
Мы не будем показывать Assembly на схеме архитектуры, так как он не связан с компонентами MVP и его жизненный цикл заканчивается сразу после их создания.
Кодогенерация
Для экономии времени мы автоматизировали процесс создания классов HandsAppMVP с помощью Generamba. Используемые шаблоны для Generamba можно найти в нашем репозитории. Пример конфига для Generamba есть в демопроекте.
В результате генерации конкретного экрана получаем набор классов, соответствующий схеме HandsAppMVP, набор unit-тестов для создания и внедрения компонентов, а также шаблонный класс для тестов презентера.
Что получилось
Если сравнить лоб-в-лоб HandsAppMVP и VIPER, то можно заметить, что они очень похожи и первая отличается только отсутствием компонента Interactor. Но, избавившись от прослойки между сервисами и презентом (интерактора), а также упростив взаимодействие с сетью с помощью Moya, мы получили ощутимый прирост скорости разработки.
Советуем уделять архитектуре достаточно внимания на стадии проектирования, чтобы в дальнейшем избежать глобальных ошибок, споров с заказчиками и мучений разработчиков, а вместо всего этого грамотно и прогнозируемо вести процесс разработки.
Помните, что любая архитектура может не подойти конкретно вашему проекту, поэтому не спешите слепо цепляться за готовые шаблоны и успешные истории их применения. Не бойтесь разрабатывать и применять свои решения, — они могут стать для вас более ценными и гибкими, чем уже готовые.
В заключении порекомендуем несколько хороших статей на тему архитектуры iOS-приложений, которые помогли нам разобраться в тонкостях и определиться с выбором:
- Архитектурные паттерны в iOS
- iOS Swift: MVP Architecture
- Разбор архитектуры VIPER на примере небольшого iOS-приложения на Swift 4
- Реализация MVVM в iOS с помощью RxSwift
Также очень помогла и вдохновила открытая документация компании SurfStudio.
Наконец, прикладываем ссылку на демопроект, написанный на HandsAppMVP, который мы не раз упоминали в статье.
svyat_reshetnikov
По поводу плохой тестируемости реактивного кода не соглашусь. На RayWenderlich есть классная статья, а также недавно подъехал перевод этой статьи на хабре.
Реактивный код вполне себе хорошо и удобно тестируется. Возможно, не так удобно как слои в VIPER, но в 2016 году, когда я писал вот эту статью, было гораздо неудобней.
Mol0ko
Да, статья на raywenderlich действительно полезная, спасибо!
RxTest позволяет довольно просто писать тесты для реактивных объектов. Но как ни крути это сторонний фреймворк, для его использования необходимо хорошо понимать тонкости rx и строго заключать всю тестируемую логику в реактивных операторах. Если этого не делать (что лично у меня на практике сплошь и рядом встречалось), то придется смешивать тесты через RxTest и обыкновенные unit-тесты открытых методов.
Мы не стали здесь раскрывать эту тему, потому что для нее нужна как минимум отдельная статья)
svyat_reshetnikov
Спасибо за ответ!
Rx уже стал стандартом де-факто в разработке — сложно найти проекты, которые его не используют, поэтому он имеет огромное комьюнити, которое его поддерживает в актуальном состоянии. Я к тому, что хоть это действительно сторонний фреммворк, он имеет огромный вес на рынке и его понимание прямо сейчас является необходимостью в большинстве вакансий.
А зачем такое жесткое разделение на реактивные и нереактивные тесты? Понятно что реактивная и нереактивная логика всегда будут рядом, т.к. обмазываться Rx'ом по всему проекту практика достаточно странная. Что вам мешает это смешивать?
Нвпример, у нас в команде используется RxTest, RxBlocking, SwiftyMocky для генерации моков и тестирования вызовов функций в моках, iOSSnapshotTestCase тесты от убера, ну и родные UI тесты и XCTest. Итого 6 тестов различных видов, которые очень сильно автоматизируют нашу работу по созданию моков и работу QA при регрессе/смоках приложений. Все эти тесты вполне себе хорошо живут рядом и не мешают друг другу