Хороший код начинается с архитектуры, и 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, который мы не раз упоминали в статье.