Через MVP к VIPER. Часть первая: MVP


Не так давно я публиковал перевод Почему VIPER это плохой выбор для вашего следующего приложения. И, как это часто бывает, мнение переводчика не совпало с мнением автора оригинальной статьи. Поэтому в этом посте я коротко расскажу как я пытаюсь (пока еще пытаюсь) внедрить VIPER в свои проекты.

Когда я начал работу над своим предыдущим проектом, в команде было ровно два целых ноль десятых мобильных разработчика: один писал версию под Андроид, второй – под iOS.

Естественно, iOS версия создавалась на классическом, рекомендуемом самим Apple, паттерне MVC.

У меня была View: «любимый» сториборд, в котором было over9000 довольно много экранов, и который был похож на это:

У меня была модель – классы, для хранения настроек, данных пользователя, некоторых данных, которые сохранялись во внутренней БД.

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

Networking.request(.POST, withURL: serverAddress, andParameters: parameters) { (dictResult, error) in
    if let dictResult = dictResult {

        // some actions with view

        if let rates = dictResult["rates"] as? [AnyObject] {
            for rate in rates {
                if let rateDictionary = rate as? JSONDictonary {
                    let result = Result(jsonDictionary: rateDictionary)
                    self.resultsArray.append(result)
                }
            }
        }

        // some actions with view

    } else {
        if let error = error {
            DispatchQueue.main.async(execute: {
                self.showError(error)
            })
        }
    }
}

В общем: я уверенно двигался от Model View Controller к Massive View Controller и с этим нужно было что-то делать.

Я начал изучать возможные архитектуры для мобильных приложений, и конечно, мне сразу же понравился паттерн Clean architecture (он же, по сути – VIPER):

картинка из The Book of VIPER

Я смело потратил несколько выходных на изучение этого паттерна. Просмотрел несколько видео-туториалов и написал немало простых приложений-примеров на нем. Также нашел удобные скрипты для генерации фалов модулей, ведь один модуль это – около десятка файлов (протоколы, конфигураторы, непосредственно классы, сториборды, юнит-тесты). Но довольно быстро пришло понимание, что если я сейчас попробую внедрить VIPER “в лоб”, то потеряю массу времени. Ведь с непривычки, у меня будут слишком большие временные затраты на правильное разделение ответственности между всеми слоями. И я решил пойти путем попроще и разделять ответственность постепенно.

А что если я скажу тебе, что UIViewController — это View.

Именно поэтому я взялся за MVP. Самое сложное и в то же время – самое простое – понять, что UIViewController – относится к слою View. Он должен работать с UI и только с ним: принимать от пользователя действия связанные с UI (нажатия кнопок, ввод текста, etc.) и менять отображение самого UI. Сначала это казалось сложным, но я вывел для себя несколько простых правил, которые помогают положить во вью-контроллер все необходимое, и при этом не положить туда ничего лишнего:

  1. Все методы и алгоритмы вью-контроллера должны работать с UI. Если вы видите какой-то метод или кусок кода, который может работать без использования UIKit, знайте: этому методу/блоку кода здесь не место.

  2. Для презентера, все в точности наоборот: там не должно быть ни одного метода для выполнения которого нужен UIKit. Если такие методы обнаружены – нужно внимательно изучить эти участки кода и вынести их во вью-контроллер.

  3. У вью-контроллера и презентера есть протоколы на которые они подписаны, это их внешние интерфейсы. Там определены методы, которые можно вызывать для них извне. Поэтому, если вдруг, вы видите во вью-контроллере вызов метода из интерфейса (протокола) этого же вью-контроллера – этот метод должен находиться не во вью-контроллере, а в презентере.

Переход на MVP, научил меня главной вещи: разделять ответственность между сущностями. Пока что, я только отделил работу с UI от всего остального, но впереди, меня все-таки ждет VIPER. Возможно классический, а возможно и более многослойный.

Я надеюсь, что скоро я до него доберусь и продолжу свой рассказ.

Почему VIPER это плохой выбор для вашего следующего приложения

Этот пост является вольным переводом статьи Why VIPER is a bad choice for your next application by Sergey Petrov

За последний год о VIPER писали все кому не лень. Эта архитектура реально вдохновляет разработчиков. Но большинство статей, на самом деле, довольно предвзяты. Они лишь показывают крутизну этого архитектурного паттерна, умалчивая о его негативных сторонах. А ведь проблем у него вовсе не меньше (а может даже и больше) чем у других. И в этой статье я постараюсь объяснить, почему VIPER вовсе не так хорош как о нем говорят, и почему он не подойдет для большинства ваших приложений.

Некоторые статьи о сравнении архитектур, как правило, утверждают что VIPER совершенно не похож на другие MVC-архитектуры. Но на самом деле, VIPER — это просто нормальный MVC, где контроллер разделен на две части: interactor и presenter. View остался на месте, а модель переименована в entity. Router заслуживает особого внимания: да, другие архитектуры не упоминают эту часть в своих аббревиатурах, но он присутствует и в них: в неявном виде (когда вы вызываете pushViewController — вы создаете простой маршрутизатор) или более очевидном (как пример — FlowCoordinators).

Поговорим о «плюшках», которые предлагает нам VIPER (я буду ссылаться на эту книгу). Посмотрим на цель номер два, в которой говорится о SRP (принцип единой ответственности). Это прозвучит грубо, но каким чудаком нужно быть, чтобы считать это преимуществом? Вам платят за решение задач, а не за соответствие модным словам. Да, вы до сих пор используете TDD, BDD, юнит-тестирование, Realm или SQLite, внедрение зависимостей и много-много других вещей, но вы используете все это не просто ради использования, а для решения проблем клиента.

Тестирование.

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

Одна из главных причин в том, что нет хороших примеров. Вы можете найти довольно много статей на тему того как написать юнит-тест assert 2 + 2 == 4, но реальных примеров не найдете (тем не менее Artsy держит в опен-сорсе свои приложения, и вам следует взглянуть на их проекты).

VIPER предлагает отделить всю логику во множество мелких классов с разделенными обязанностями. Это должно бы облегчить тестирование, но так не всегда. Да, написать юнит-тест для простого класса легко, но большинство из таких тестов ничего не тестируют. Давайте посмотрим, например, на большинство методов презентера: они лишь прокси между вью и другими компонентами. Вы можете написать тесты для этого прокси, это увеличит тестовое покрытие вашего кода, но эти тесты бесполезны. И у вас будет побочный эффект: вы должны обновлять эти бесполезные тесты после каждого внесения изменений в код.

Правильный подход к тестированию должен бы включать в себя тестирование интерактора и презентера сразу, ведь эти две части сильно связаны друг с другом. Кроме того, поскольку мы разделяем логику на два класса, нам нужно намного больше тестов по сравнению с одним классом. Это простая комбинаторика: у класса A есть 4 возможных состояния, а у класса B — 6, соответственно их комбинация имеет 24 возможных состояния, и вам нужно их протестировать.

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

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

Оверинжиниринг в проектировании.

VIPER — то, что происходит, когда бывшие джависты врываются в мир iOS. — n0damage, комментарий на reddit

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

Представьте себе простую задачу: есть кнопка, которая запускает обновление с сервера и есть вью, с полученными от сервера данными. Угадайте-ка сколько классов/протоколов будут задеты таким изменением? Да, как минимум 3 класса и 4 протокола будут изменены для реализации такой простой функции. Кто-нибудь помнит как Spring начали с некоторых абстракций и закончили с AbstractSingletonProxyFactoryBean? Я всегда мечтал об «удобном суперклассе прокси-фабрики для прокси-фабрик, которые создают только синглтоны» в моем коде.

Избыточные компоненты


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

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

«DI-френдли» количество протоколов


(Некрасивый код легко распознать и его стоимость легко оценить. Это не так если абстракция неверна.)

Существует общая путаница с этим сокращением: VIPER реализует принципы SOLID, где DI — это «инверсия зависимостей», а не «внедрение» («dependency inversion«, а не «injection«). Внедрение зависимостей — это особый случай паттерна «инверсия управления» («Inversion of Control»), который, конечно связан, но отличается от инверсии зависимостей.

Инверсия зависимостей — это про отделение модулей разных уровней путем ввода абстракций между ними. Для примера, модуль UI не должен напрямую зависеть от сетевого модуля. Инверсия управления — это другое. Это когда модуль (обычно из библиотеки, которую мы не можем менять) делегирует что-нибудь другому модулю, который обычно предоставлен первому модулю как зависимость. Да, когда вы реализуете data source для вашей UITableView вы используете принцип IoC. Использование схожих названий для различных высокоуровневых вещей — источник путаницы.

Вернемся к VIPER. Есть много протоколов (как минимум 5) между классами внутри одного модуля. И во всех них нет необходимости. Презентер и интерактор не являются модулями из разных слоев. Применение принципа IoC может иметь смысл, но спросите себя: как часто у вас бывает хотя бы два презентера для одного вью? Я уверен, что большинство из вас ответит «никогда». Так почему же необходимо создавать эту кучу протоколов, которые мы никогда не будем использовать?

Кроме того, из-за этих протоколов, вы не можете легко перемещаться по коду в IDE. Ведь cmd+click будет вас выбрасывать в протокол, вместо реализации.

Проблемы с производительностью

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

Я не буду говорить о фреймворке Typhoon (который очень популярен для внедрения зависимостей в мире objective-c). Конечно, он имеет некоторое влияние на производительность, особенно когда используется автоматическое внедрение, но VIPER не требует его использования. Вместо этого, я бы поговорил о рантайнме и запуске приложения, и как VIPER замедляет ваше приложение буквально повсюду.

Время запуска приложения. Эта тема редко обсуждается, но она важна. Ведь если ваше приложение стартует очень медленно, то пользователи не будут им пользоваться. На последней WWDC как раз говорили об оптимизации времени запуска приложений. Время старта вашего приложения напрямую зависит от количества классов в нем. Если у вас есть 100 классов — это нормально, задержка будет незаметной. Однако, если в вашем приложении только 100 классов — вам действительно нужна эта сложная архитектура? Но если ваше приложение огромно, например, вы работаете над приложением Facebook (18К классов), то разница будет ощутима: около одной секунды. Да, «холодный старт» вашего приложения займет 1 секунду только на то, чтоб подгрузить все метаданные классов и больше ничего, вы правильно поняли.

Рантайм вызовы. Тут все сложнее и в основном применяется только для Swift-компилятора (поскольку рантайм Objective-C располагает бОльшими возможностями и компилятор не может безопасно выполнять оптимизации). Давайте поговорим о том, что происходит «под капотом» когда вы вызываете какой-нибудь метод (я говорю «вызываете», а не «отправляете сообщение» потому, что второе не всегда корректно для Swift). Есть три вида вызовов в Swift (от быстрого к медленному): статический, таблица вызовов и отправка сообщений. Последний — единственный, который используется в Objective-C, и он используется в Swift когда необходима совместимость с кодом Objective-C, или когда метод объявлен как dynamic. Конечно, эта часть рантайма будет весьма оптимизирована и записана в ассемблере для всех платформ. Но что если мы сможем избежать этих накладных расходов, дав компилятору понятие о том, что именно будет вызываться, во время компиляции? Это именно то, что Swift-компилятор делает со статикой и таблицей вызовов. Статические вызовы быстры, но компилятор не может их использовать без 100% уверенности в типах. И когда тип нашей переменной является протоколом, компилятор вынужден использовать вызовы с помощью таблиц. Это не слишком медленно, но одна миллисекунда здесь, одна — там, и теперь общее время выполнения вырастает более чем на одну секунду, по сравнению с тем, чего можно было бы добиться с чистым Swift-кодом. Этот пункт связан с предыдущим о протоколах, но я думаю, что лучше отделить беспокойство по поводу количества неиспользуемых протоколов от возни с компилятором.

Слабое разделение абстракций

Должен быть один и, желательно, только один очевидный способ сделать это.

Один из самых популярных вопросов VIPER-сообщества: «куда мне следует отнести X?» Получается, что с одной стороны есть много правил, как нужно делать все правильно, а с другой — многие решения основаны на чьем-нибудь мнении. Это могут быть сложные случаи, например обработка CoreData с помощью NSFetchedResultsController, или UIWebView. Но даже общие случаи, такие как использование UIAlertController — являются темой для обсуждения. Давайте взглянем: здесь с нашим алертом взаимодействует роутер, а здесь его показывает вью. Вы можете ответить, что этот простой алерт является частным случаем алерта без каких-либо действий, кроме закрытия.

Особые случаи недостаточно особы, чтобы нарушать правила.

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

Генерация кода

Читаемость имеет значение.

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

Заключение.

Я не преследую цель отговорить вас от использования VIPER вообще. Вполне могут быть приложения, которые от него только выигрывают. Однако, прежде чем начать разработку своего приложения вам следует задать себе пару вопросов:

  1. Будет ли у этого приложения длительный жизненный цикл?

  2. Достаточно ли стабильны требования? В противном случае вы столкнетесь с бесконечным рефакторингом даже при внесении мелких изменений.

  3. Вы действительно тестируете свои приложения? Будьте честны с собой.

Только если вы ответили «да» на все три вопроса, VIPER мог бы быть хорошим выбором для вашего приложения.

И, наконец, последнее: вы должны самостоятельно принимать решения. Не просто слепо доверять какому-то парню с Медиума (или Хабра), который говорит «Используйте X, X — это круто.» Этот парень тоже может ошибаться.