HandsAppMVP: iOS-архитектура для студии аутсорс разработки

    image

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

    image

    В 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 по известной схеме:

    image
    [Схема классического MVP]

    Здесь и далее на схемах каждый компонент взаимодействия (синий квадрат) — это класс, время жизни которого совпадает с временем жизни View. Сплошная стрелка обозначает владение другим объектом, строгую ссылку, а пунктирная — слабую ссылку. С помощью слабых ссылок мы предотвращаем циклические зависимости и утечку памяти.

    Интерфейсы


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

    image
    [Добавление интерфейсов ViewInput и ViewOutput]

    Маленький прямоугольник — интерфейс.

    Внимательный разработчик спросит, где интерфейсы для Model? Сейчас к ним переходим.

    Работа с данными


    Модель данных в мобильных архитектурах — собирательное понятие. Стандартный пример: приложение стучится в сеть для взаимодействия с сервером, затем сохраняет данные в CoreData для офлайн-работы, некоторую простую информацию записывает в UserDefaults и хранит JWT в Keychain. Все эти данные, с которыми ведется взаимодействие, составляют Model.

    Класс, который отвечает за взаимодействие с контейнером данных конкретного типа, мы называем сервисом данных. Для каждого контейнера (удаленная база данных, локальная база данных, UserDefaults и пр.) в HandsAppMVP добавляется сервисный класс, который взаимодействует с презентером. Теперь можно также добавить интерфейсы input/output для каждого сервиса данных:

    image
    [Добавление сервисов для работы с данными]

    Не каждый сервисный класс необходимо подключать к презентеру с помощью интерфейса, как, например, при использовании Moya. Moya — open-source-библиотека для работы с сетью. Moya предоставляет готовый сервисный класс (MoyaProvider), и при написании тестов нам не приходится делать mock-объект, заменяющий ApiProvider. В Moya предусмотрен специальный тестовый режим, при включении которого MoyaProvider не стучится в сеть, а возвращает тестовые данные (подробнее можно почитать по ссылке). Презентер при этом ссылается не на абстракцию MoyaProvider, а на реализацию. А обратную связь от этого сервиса мы получаем с помощью замыканий. Пример реализации можно посмотреть в демопроекте.

    Этот пример скорее исключение, чем правило, и показывает, что беспрекословное соблюдение SOLID не всегда лучшее решение.

    Навигация


    Навигацию в приложении мы рассматриваем как отдельную ответственность. Для нее в HandsAppMVP используется специальный класс — Router. Router содержит weak-ссылку на View, с помощью которой может показать новый экран или закрыть текущий. Router также взаимодействует с презентером c помощью интерфейса RouterInput:

    image
    [Добавление компонента для навигации (Router)]

    Сборка компонентов


    Последнее дополнение классического MVP, которое мы используем, это Assembly — класс-сборщик. Он используется для инициализации View и остальных компонентов HandsAppMVP, а также для внедрения зависимостей. Assembly содержит единственный открытый метод — `assemble() -> UIViewController`, результатом выполнения которого является нужный UIViewController (или UIView) c необходимым графом зависимостей.

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

    Кодогенерация


    Для экономии времени мы автоматизировали процесс создания классов HandsAppMVP с помощью Generamba. Используемые шаблоны для Generamba можно найти в нашем репозитории. Пример конфига для Generamba есть в демопроекте.

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

    Что получилось


    Если сравнить лоб-в-лоб HandsAppMVP и VIPER, то можно заметить, что они очень похожи и первая отличается только отсутствием компонента Interactor. Но, избавившись от прослойки между сервисами и презентом (интерактора), а также упростив взаимодействие с сетью с помощью Moya, мы получили ощутимый прирост скорости разработки.

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

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

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

    1. Архитектурные паттерны в iOS
    2. iOS Swift: MVP Architecture
    3. Разбор архитектуры VIPER на примере небольшого iOS-приложения на Swift 4
    4. Реализация MVVM в iOS с помощью RxSwift

    Также очень помогла и вдохновила открытая документация компании SurfStudio.

    Наконец, прикладываем ссылку на демопроект, написанный на HandsAppMVP, который мы не раз упоминали в статье.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 6

      0
      По поводу плохой тестируемости реактивного кода не соглашусь. На RayWenderlich есть классная статья, а также недавно подъехал перевод этой статьи на хабре.
      Реактивный код вполне себе хорошо и удобно тестируется. Возможно, не так удобно как слои в VIPER, но в 2016 году, когда я писал вот эту статью, было гораздо неудобней.
        0
        Да, статья на raywenderlich действительно полезная, спасибо!
        RxTest позволяет довольно просто писать тесты для реактивных объектов. Но как ни крути это сторонний фреймворк, для его использования необходимо хорошо понимать тонкости rx и строго заключать всю тестируемую логику в реактивных операторах. Если этого не делать (что лично у меня на практике сплошь и рядом встречалось), то придется смешивать тесты через RxTest и обыкновенные unit-тесты открытых методов.
        Мы не стали здесь раскрывать эту тему, потому что для нее нужна как минимум отдельная статья)
          0
          Спасибо за ответ!

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

          А зачем такое жесткое разделение на реактивные и нереактивные тесты? Понятно что реактивная и нереактивная логика всегда будут рядом, т.к. обмазываться Rx'ом по всему проекту практика достаточно странная. Что вам мешает это смешивать?
          Нвпример, у нас в команде используется RxTest, RxBlocking, SwiftyMocky для генерации моков и тестирования вызовов функций в моках, iOSSnapshotTestCase тесты от убера, ну и родные UI тесты и XCTest. Итого 6 тестов различных видов, которые очень сильно автоматизируют нашу работу по созданию моков и работу QA при регрессе/смоках приложений. Все эти тесты вполне себе хорошо живут рядом и не мешают друг другу
        0
        Спасибо за статью!
        Не считаете ли вы, протоколы ViewInput и ViewOutput излишни, так как связь между View м Presenter слишком сильная? Мне не удалось придумать пример, когда бы пригодилось отсутствие явной зависимости между Presenter и View. Можете привести пример когда это помогло?
          0
          Мы уделяем достаточно внимания тестированию логики представления, а для этого необходима возможность вырвать презентер из контекста всех его зависимостей и заменить их тестовыми дублёрами. Использование зависимостей на абстракции, в частности — протоколов ViewInput и ViewOutput, позволяет это сделать. Пример тестов можно посмотреть в нашем демо-проекте — пример.

          Если не писать тесты, то вполне можно и не использовать эти протоколы и ссылаться на презентер явно. Все идет от потребностей)
            0
            Помимо тестов такая система неплохо помогает, если есть весьма похожие внешне экраны с практически или совершенно разной логикой: ViewInput/Output протоколы позволяют легко и непринужденно подсунуть к готовой View другой Presenter с новой логикой.
            В качестве примера, из последнего, когда такой прием применял — экран карты неких объектов. В одном кейсе экран представлял собой просто View с минимум логики, где даже запрос не выполнялся, в другом — самостоятельный полноценный экран -> View одна и она переиспользуется, просто к ней подсовывается в каждом случае свой Presenter

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

        Самое читаемое