Каждый iOS разработчик в своей жизни уходил с собеседования в расстроенных чувствах и мыслью «это что еще за новая аббревиатура?». Архитектурами пугают и джунов, и миддлов, и синьоров (и наверное даже синьорит). Важно не просто знать что стоит за названием, но еще и в каком случае какую использовать. Литературы по этому вопросу преступно мало, редкие обсуждения в интернете ограничиваются собственным опытом и какими-то поделками на гитхабе.

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

Первая часть посвящена MV(X) паттернам: самым известным и распространенным практикам в индустрии.

Это первая статья из цикла, посвящённого архитектурным паттернам в iOS разработке. Расскажу про плюсы и минусы, а также когда и где их лучше применять. В этой статье начну с основных и самых популярных MV(X) практик. Во второй части речь пойдёт о реализациях концепции Чистой Архитектуры (Clean Architecture), а в третьей — об FRP практиках и паттернах в iOS разработке.

Я Маша, ведущий инженер-разработчик iOS в КРОК, а также аспирант-препод в МЭИ. 

Быть iOS-разработчиком непросто. Сначала Xcode не хочет подключаться к устройству, потом ты возишься неделю с сертификатами, потом полгода изучаешь Swift и пытаешься понять когда использовать guard, а когда — if let. Или когда ты уже написал не один калькулятор и приходишь на собеседование в крутой проект, тебя с порога начинают закидывать какими-то страшными аббревиатурами. 

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

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

Что такое MV(X)?

Основные части MV(X) архитектур это, так или иначе, модели, виды и контроллеры. 

Модель – отвечает за использование предметных (бизнес, domain) данных. Это так же может быть data access layer. Модели бывают двух видов: активные и пассивные. Активные модели умеют уведомлять окружающих об изменениях в себе, а пассивные — нет. 

Вид (Представление, View) – отвечает за слой представления (GUI). В каком виде вам это представить? :) Вид не обязательно должен быть связан с UI отрисовкой: если вы хотите представлять данные в виде сменяющих друг друга диодов на arduino-плате — этим все равно будет заниматься Вид. Помимо представления пользователю данных, у Вида есть ещё одна важная задача: принять событие пользователя (нажатие на кнопку, например) и пнуть кого-то, кто знает что с этим делать.

Контроллер/Презентер/ViewModel – так или иначе отвечают за связь модели с контроллером. В основном занимаются тем, что пробрасывают события модели в вид, а события вида – в модель, соответствующим образом их преобразуя и обрабатывая. Например, изменяя модель в ответ на действия пользователя на экране (виде), или изменяют вид в ответ на изменения модели. Как правило являются пассивными участниками процесса и реагируют только на внешние стимулы (события от Вида или Модели).

Любой нормальный человек, только что закончивший изучать ООП, задаст себе вопрос: а нафига плодить эти сущности? Зачем мне так много классов/структур/файлов/сообщений? Какие такие офигенные бенефиты дает вся эта возня? Вон я писал все в одном файле на Паскале/Питоне/Си/Свифт-плейграуде — и работало же!

Хорошая архитектура нужна чтобы:

  1. Код становится читабельнее (как минимум для человека, знакомого с архитектурой, как максимум — для любого человека)

  2. Переиспользовать код было намного легче

  3. (проще и быстрее) покрыть код юнит-тестами

  4. Расширять (масштабировать) код без мигрени и убийств

Хотя скорее всего если вы нашли эту статью, то вы уже знаете зачем вам нужна архитектура :) 

Если же нет, и вы все еще задаетесь вопросом из предыдущего абзаца про «зачем плодить сущности» (а это, кстати говоря, очень важный и правильный вопрос!), то предлагаю сначала изучить классную статью на эту тему:

Создание архитектуры программы или как проектировать табуретку (обязательно походите по ссылкам внутри)

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

MVC

CocoaMVC это iOS архитектура, которую предложили Apple, создавая iOS Developer SDK. Но стоит оговориться, что, вообще говоря, видение Apple немного отличается от традиционного понимания MVC как архитектуры.

Традиционная MVC выглядит как-то так:

Сплошная стрелочка по заветам UML тут означает ассоциацию. Переводя на программерский: Контроллер владеет Моделью (например у класса Контроллера есть поле типа Модели). Я дальше вместо совершенно нетолерантного слова «владеть» буду говорить «Контроллер ЗНАЕТ О Модели», ведь знание — сила.

Пунктирная стрелочка означает зависимость. То есть когда Контроллер владеет Видом, то Вид оказывается у него в зависимости (то есть у Контроллера есть поле типа Вид). У нас в свифте мы зависимость часто видим на примере weak свойств на родительские объекты и/или делегаты. 

То есть:

  • Вид: рисует кнопочки

    • зависит от Контроллера

      • сообщает Контроллеру о событиях ввода

    • знает о Модели

      • запрашивает данные у Модели

      • получает изменения в данных от Модели

  • Контроллер: знает где взять текст для кнопочки и какой кнопочке его дать

    • знает о Виде

      • может менять конфигурацию Вида или заменять один Вид другим

    • знает о Модели

      • запрашивает изменения в данных Модели

  • Модель: знает как выглядят данные (типы, структуры), что делать с данными (бизнес-логика), где лежит БД и как в нее ходить (инкапсулирует доступ к данным)

    • зависит от Контроллера

      • меняет данные по запросу Контроллера

    • зависит от Вида

      • сообщает Виду об изменении в данных

Тут важный момент в том, что Вид не имеет состояний, то есть является stateless. Он просто отрисовывается заново, как только в Модели что-то меняется. Хороший пример: веб-страница в рунете нулевых, которая полностью перезагружается, если вы нажали на какую-то кнопку.

А ещё обратите внимание, что у View нет доступа на запись в Модель. Все изменения Модели производятся только через Контроллер, при этом права на чтение Модели у Вида не просто есть — он ими активно пользуется (чтобы обновлять себя и показывать актуальные данные на экране).

Вкратце достоинства классической MVC:

  1. К одной Модели можно присоединить несколько Видов так, чтобы не менять код Модели. Например, табличные данные хочется показать и таблицей, и гистограммой, и пай-чартом. 

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

  3. Разработка бизнес-логики не зависит от разработки представлений и vice versa. А значит, можно посадить несколько человек одновременно долбить код одной фичи.

  4. Однонаправленный поток данных (Вид -> Контроллер -> Модель -> Вид) делает дебаг проще и приятнее

Все это звучит очень классно, но немного запутанно и архаично. Apple предлагает вот такую реализацию Cocoa MVC:

Видим знакомую картину: Контроллер — это промежуточный слой между Видом и Моделью. Он принимает события из Вида и Модели, и посылает запросы на модификации туда и туда. В Контроллер можно положить всю логику, которая не помещается в Модель: например преобразует данные для «красивого» отображения — это не часть бизнес-логики, так что в Модели ей не место. 

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

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

ViewController и View настолько плотно завязаны друг на друга, что сложно вообще отличить где заканчивается одно и начинается другое. С одной стороны: ну вот же UIView — его можно отдельным классом написать, там экстеншены-категории все вот это вот, вы меня за дурака держите?

С другой: UIView не умеет отрабатывать IBAction (тк он не посылает никаких actions — это делают UIControl), а .xib-ы легче подключаются к контроллерам (наверняка же сталкивались с историей, что если xib не заканчивается на -Controller то он не подключается “из коробки”? :)). В итоге чтобы отделить UIView от UIViewController в терминах целого экрана, а не отдельного компонента — нужно пройти сквозь огонь, воду и LLVM.

Так как ходить через LLVM никому не нравится, все пихают логику Вида в UIViewController, что приводит к тому, что MVC элегантным жестом превращается… в Massive View Controller. Разобраться в этой мешанине делегатов, датасорсов, экшенов и просто вспомогательных функций — задача на любителя. 

Происходит это из-за того, что у Вида может быть достаточно большое количество различных состояний: представьте себе форму регистрации из серии “введите емейл, пароль, подтвердите пароль”. Логично, чтобы все поля имели валидацию ввода и цветовую индикацию: хорошо/плохо. Получается, что это три текстфилда, каждый с как минимум двумя состояниями. Записать это состояние в Вид нельзя — он stateless, в Контроллер тоже нельзя — он не должен управлять состояниями Вида, лишь передавать ему данные. В итоге, состояние Вида приходится хранить в Модели. Так у нас Модель получается перегружена: она хранит в себе и Domain Model (модель предметной области: данные, бизнес логика и тп) и View Model (модель вида: его состояния, хранимые данные, логика переключений состояний и тп).

Пример несколько утрированный, и опытные разработчики скорее всего не увидят проблему в реализации подобной фичи (ну сделай ты кастомный контрол и делов-то!). Но если говорить о формальном следовании архитектуре MVC/CocoaMVC, то видно как всё быстро становится достаточно запутанно. 

Ну а протестировать взаимодействие между View и ViewController — задача настолько нетривиальная, что в MVC приложениях как правило тестируют только модель и нетворк слой (что во многих случаях бывает так же сложно разделить как и UIView с UIViewController: представьте модель данных и интерфейс доступа к данным (к БД)).

В итоге, чтобы сделать действительно MVC-шный MVC на iOS придется исхитряться ломая либо Cocoa MVC (например давая UIView возможность самостоятельно ходить в модель), либо логику UIKit и делегирования (чтобы сообщения в контроллер приходили действительно от условных UIView, а не от других контролов и контроллеров).

MVP

Прозорливый читатель заметит, что всех проблем c Cocoa MVC можно было бы избежать, если пару UView+UIViewController воспринимать как View, а под слой Controller выделить отдельную сущность, которая не знает о UIKit и занимается только тасканием данных и событий между View и Model, как и было задумано.

То есть:

  • Презентер:

    • зависит от Вида

      • сообщает Виду о необходимости обновить отображение измененных данных

    • знает о Модели

      • запрашивает изменить данные

      • получает сообщения об изменившихся данных

  • Вид:

    • знает о Презентере

      • запрашивает у Презентера реакцию на действия пользователя («пользователь нажал на красную кнопку, куда его послать, сэр?»)

  • Модель:

    • зависит от Презентера

      • получает запросы Презентера на изменения данных

      • посылает Презентеру сообщения об изменившихся данных

Выглядит классно и вполне адекватно! Только вот…

Нам очень хочется, чтобы Вид ничего не знал о Модели, так? Если собирать эту диаграмму в код напрямую, то получится, что в условном Виде содержится полем объект Контроллера, Контроллер содержит внутри себя Модель, ну и при инициализации Вида у нас по цепочке через Контроллер будет инициализироваться управляющий объект Модели. Так знает Вид о Модели или нет? Как-то некрасиво получается, да и с концепцией не вяжется.

Эта проблема сборки (assembly problem) корнями растет напрямую из Cocoa MVC. Ведь именно Cocoa MVC заставляет нас в первую очередь ориентироваться на UIViewController как на единицу сборки и навигации. 

В идеальном мире мы бы сделали как:

let newModel = Model() // а возможно она уже где-то существует и мы на нее ссылаемся
let newPresenter = Presenter(model: newModel)
let newView = MyViewController(presenter: newPresenter
)

Получается красивая инъекция зависимостей через конструктор, и все бы хорошо, если бы было понятно а где собственно она должна происходить. По идее (согласно Cocoa MVC) мы вызываем новые виды (UIViewController) из других видов (других UIViewController). В итоге получается что-то вроде self.present(newViewController). Так значит какой-то другой Вид будет знать про модель нового Вида? Или этим должен заниматься Презентер? 

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

Вид: эй, Презентер, юзер хочет перейти на новый экран, что мне делать?

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

В большинстве примеров в интернете и в жизни вы найдете код, где ViewController спокойно себе инитит модель, а потом кладет её в параметры инициализатора Презентера. У такого подхода нет ничего плохого, только надо следить за ссылками и понимать, что это не чистый MVP «как на диаграмме», да и заменить модель при случае будет не так просто, как хотелось бы. 

Решается проблема сборки как правило тем, что создается ещё один управляющий слой: Роутер (Router), который собирает MVP-кусочки, управляет их жизненным циклом и передает их друг другу при необходимости. По этой же причине так часто встречается связка MVP+Coordinator (на эту архитектуру часто ссылаются как на MVP+C).

MVP+C

Выглядит это дело вот так:

Задача координатора — создавать MVP блоки и управлять тем какой UIViewController показывается на экране прямо сейчас (например через navigationController.pushViewController). 

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

Вид: эй, Презентер! Юзер Иванов хочет посмотреть на красную кнопку.

Презентер: *потея* ох, но у нас её нету, надо искать где-то в другом месте… Координатор, нужно показать красную кнопку Иванову!

Координатор: ага, ну понятно. Собираем, значит: ЭкранСКраснойКнопкой — есть, ПрезентерЭкранаСКраснойКнопкой ему вот, модель… модель для Иванова, ага… нашел, кладу в ПрезентерЭкранаСКраснойКнопкой. Собрано. Показываю ЭкранСКраснойКнопкой. Вас деиничу. Спасибо за содействие.

Презентер: *деинитится вместе с Видом и, возможно, Моделью*

Это не единственный способ решить проблему сборки в MVP, но остальные методы не слишком-то элегантны:

Так что подведем итоги:

  • При грамотном подходе все три компоненты (Вид, Презентер и Модель) растут примерно в одинаковых масштабах, что здорово помогает справиться с проблемой Massive View Controller.

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

  • Покрыть тестами логику ответа на действия пользователя теперь тоже намного проще: Презентер хорошо отделён от Вида

  • Правда, как это ни парадоксально, переиспользовать Презентер вряд ли получится: всё же, хоть он и отделен, но заточен на работу с конкретным представлением [2] Впрочем, тут все действительно зависит от вас и ваших навыков в SOLID

MVP оказывается хорошей альтернативой MVC, если выбран осознанно для решения конкретной проблемы. В противных случаях он ощущается как «слишком много лишнего кода» (намёк на роутер) и какой-то громоздкий костыль к MVC («нафига ещё презентер, если ViewController это всё умеет?»)

MVP хорошо подходит небольшим командам, однако в крупном проекте с большим количеством разработчиков может стать настоящим яблоком раздора в GIT: разработчики могут постоянно править одни и те же переиспользуемые классы (например, роутеры или модели), работая над разными фичами, что неизменно приводит к большому количеству конфликтов при мержах, разгребать которые никому не нравится :)

Источники:

MVVM

MVVM очень похож на MVP, но совсем другой :) Он был создан в Microsoft для работы с WPF и Silverlight, но благодаря своей элегантной модуляризации приобрел популярность и далеко за пределами WPF приложений.

Давайте начнём с картинки:

Как видно, картинка в точности повторяет MVP, только стрелочки почему-то двунаправленные стали, а Презентер превратился в Модель Вида (ViewModel). Ну и как читать это безобразие?

Начнём с того, что тут View — это, как и в MVP, связка UIView+UIViewController. Мы вроде уже достаточно взрослые на данном этапе чтобы понять почему. Дальше, кстати, будет так же.

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

Всё это звучит очень круто и удобно, но потом приходит iOS-ник и такой:

И действительно, штатных (предоставленных Apple) средств организовать биндинг в iOS SDK (вне SwiftUI) нет, но зато есть другие инструменты, с помощью которых можно симулировать такое поведение.

Так как делать биндинги вообще? Есть четыре способа:

  • KVO (Key-Value Programming) — очень крутая и мощная штука прямиком из Cocoa, которая позволяет подписаться и наблюдать (observe) за переменной по её имени (keyPath) и получать уведомления об изменении её значения (value). Кажется, то что надо!

  • FRP (Functional Reactive Programming) — парадигма программирования (заметьте: не язык и не фреимворк, а целая пАрАдИгМа) для обработки событий и данных как потоков. Ну, например представьте, что у вас есть какой-то поток данных и вы хотите для каждого объекта в этом потоке что-то предпринять. Эдакий forEach, только вы не знаете когда появится новый объект.let lowercaseNames = cast.map { $0.lowercased() } видали? Вот вам и FRP, где поток — это статичный массив (cast).Реализация FRP в Swift обычно отдается на откуп RxSwift и ReactiveSwift, но есть ещё и Combine от Apple — сейчас можно говорить, что он лучше всех, но это конечно не точно.

  • Delegate — делегирование можно использовать чтобы передавать сообщения об изменении данных.

  • Boxing — если вы первым делом подумали про didSet — то это для вас! Похоже на KVO, кстати, но придется каждое свойство оборачивать в кастомный дженерик класс (Box). Подробнее детали реализации можно посмотреть в крутом туторе вот тут.Ну а если вы пришли из Objective-C, то с паттерном «коробки» у вас и подавно проблем возникнуть не должно! Это тот же самый паттерн, что используется в Obj-C (и не только) для оборачивания скалярных типов в ссылочные (например float в NSNumber), только теперь мы ещё и листенер туда навесить пытаемся. По сути — обычная обертка, совсем не страшно.

Пятый, бонусный способ: SwiftUI и его @State и @BindingУдивительное дело, но в SwiftUI Apple решила отказаться от концепции ViewController как такового! Вместо этого роль передатчика данных между View и Model на себя берет FRP фреймворк Combine (а вы думали отвертитесь от его изучения?). Самая простая иллюстрация для тех кто совсем в танке:- в описании View (читайте его как ViewModel) переменная помечается аннотацией @State- в описании Компонента (CounterButton, читайте это уже View) эта же переменная, привязываемая к UI контролу, помечается аннотацией @Binding - вуа-ля! Очень реактивно и роскошно :)

Тут есть конечно свои нюансы (например то, что SwiftUI больше подходит для Redux-like паттернов), но да ладно.

Что же мы видим в итоге?

  • Модель Вида:

    • зависит от Вида

      • биндится с данными в Виде, которые могут обновляться в Модели или в GUI

    • знает о Модели

      • запрашивает изменить данные

      • получает сообщения об изменившихся данных

  • Вид:

    • знает о Модели Вида

      • биндится с данными в Модели Вида, которые могут обновляться в Модели или в GUI

  • Модель:

    • зависит от Модели Вида

      • получает запросы Модели Вида на изменения данных

      • посылает Модели Вида сообщения об изменившихся данных

При этом функции компонент очень строго разделены и немного отличаются от оных в MVC/MVP:

Модель:

  • Содержит описание данных 

  • Реализует CRUD [2]

  • Оповещает Модель Вида о произошедших изменениях в данных

  • Содержит бизнес-логику [1]

  • Содержит логику валидации данных [1] 

  • Вообще говоря MVVM никак не описывает реализацию Модели, и она может быть реализована любым способом, хоть VIPER модулем [3]

Вид:

  • Определяет структуру, расположение и внешний вид элементов интерфейса (как в MVC и MVP)

  • Содержит логику Вида: анимации, переходы между Видами и манипуляции с дочерними Видами

  • Передаёт действия пользователя дальше (в Модель Вида) 

Модель Вида:

  • Хранит состояние Вида 

  • Знает о Модели и может менять её состояние (вызывая соответствующие методы Модели) [1]

  • Как правило образует связь «один ко многим» с несколькими Моделями [3]

  • Преобразует данные, полученные от Модели в формат, удобный для отображения Видом и обратно

  • Ничего не знает о Виде и может с ним коммуницировать только благодаря биндингам

  • Может содержать логику валидации данных [2] 

  • Может включать в себя бизнес-правила работы с действиями пользователя [2,3]

Как видим, Модель Вида — какой-то монстр, который всё обо всех знает и всеми погоняет. Это и достоинство, и недостаток MVVM: 

  • С одной стороны, Модель Вида — удобный медиатор, оторванный от мира UI и Data Access, а значит её удобно тестировать и можно переиспользовать на других платформах, если вдруг понадобилось сделать еще и приложение для tvOS, например

  • с другой стороны, Модель Вида занимается и управлением состояниями своего Вида и зависимостей, и различного рода логикой. А значит её код разрастается и мы возвращаемся к проблеме Massive View Controller. Чтобы этого избежать, необходимо крайне осторожно подходить к проектированию Моделей Вида

И, хоть Модель Вида и легко протестировать, то дебажить приложение на MVVM совсем непросто: ведь благодаря биндингам данные меняются мгновенно. И, что бы это не значило со стороны пользователя, для разработчика это означает огроменный call stack подписок и ивентов, в котором чёрт ногу сломит:

Иллюстрация из [4]
Иллюстрация из [4]

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

MVVM хорошо подходит опытным командам среднего размера, которые работают над проектом с большим количеством представлений (экранов). Особенно хорошо MVVM себя показывает в проектах, где надо сильно отделить бизнес-логику от логики отрисовки представлений — такое часто встречается в кросс-платформенных приложениях, или приложениях, которым в будущем может понадобиться клиент-сателлит на другую платформу (tvOS, watchOS, ...).

Из-за сильного разделения MVVM позволяет нескольким разработчикам работать над одной фичей одновременно, практически не пересекаясь в коде — это самая «простая» для понимания и внедрения архитектура, которая обладает таким замечательным свойством.

Источники

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

На больших проектах со сложной бизнес-логикой требуются более основательные подходы к проектированию кодовой базы приложения. Один из таких подходов — концепция Чистой Архитектуры, о которой мы поговорим в следующей статье. Обсудим VIPER, CleanSwift, RIBs и относительно малоизвестную штуковину под названием Elements.

Не переключайтесь! :)