Разбор архитектуры VIPER на примере небольшого iOS приложения на Swift 4 +13



«У каждого свой VIPER». Автор неизвестен
В данной статье я хотел бы рассмотреть архитектуру VIPER на небольшом конкретном примере, который в того же время показывал всю мощь этой архитектуры и был написан на последнем Swift 4. Для тех, кто хочет сразу глянуть код, не читая всю статью, ссылка на реп в самом низу.



Оглавление




Вступление


Про архитектуру VIPER писали уже достаточно много, в том числе и на хабре (1, 2). Поэтому я решил не отставать от других и написать очередное «полезное» руководство. Все началось с того, что эппловская MVC архитектура оказалась не очень удачной, о чем более подробно рассказывается в этой статье. Если вкратце, то MVC превратился в Massive View Controller, т.е. огромный вьюконтроллер, в котором ему позволялось очень много. В нем было много UI и бизнес-логики, и, как следствие, такой код почти невозможно было тестировать, отлаживать и поддерживать.

Поэтому разработчикам понадобилась другая архитектура, которая была бы более-менее гибкой, соответствовала SOLID принципам (особенно "Принципу единственной ответственности") и чистой архитектуре (обзор на русском).

Rambler подхватили эту тему и посвятили этому целую конференцию и даже написали книгу. Кстати, если вы не знакомы с VIPER, я бы порекомендовал прочитать именно эту книгу, как знакомство с архитектурой. В ней хорошо описано и разжевано, для чего нужен этот VIPER и как появился. Также в книге рассматриваются проблемы классического VIPER, и что разработчики Rambler в ней немного изменили. К сожалению, книга была написана в 2016 году и примеры в ней на Objective-C, как и их опенсорсный проект, который на момент написания этой статьи не компилировался и вообще показался слишком сложным для первого изучения. Проект увешан многими дополнительными штуками, чрезмерной абстракцией и чересчур большим количеством классов. Что на первый взгляд может показаться слишком сложным и отталкивающим.

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

Попрошу сильно не придираться, если стилистически на Swift что-то можно было написать более элегантно. Все же статья про архитектуру, а не про красоту самого Swift. Также я намеренно не стал использовать сторонние библиотеки и зависимости. Весь пример написан, используя только родные для iOS библиотеки.


Глава 0. Схема архитектуры VIPER


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



Вероятно, вы видели другие схемы.

Эту, к примеру:

Каждая буква из аббревиатуры VIPER на ней что-то обознает: View–Interactor–Presenter–Entity–Router. Но реальность такова, что в модуль входят не только эти компоненты, а Entity вообще в понятие модуля может не входить, т.к. является самодостаточным классом, который может использоваться в любом модуле или сервисе. На сложных экранах модуль можно делить на подмодули, где у каждого будут свои презентеры и интеракаторы.

В отличии от классического VIPER в моем нет Wireframe, потому что он выполнял 2 роли: выполнял сборку модуля и осуществлял переход на другой экран (модуль). На схеме показано, что за сборку модуля будет отвечать Configurator, а за переходы Router. Такую логику я перенял у Rambler, с той лишь разницей, что вместо Configurator у них Assembly. Но суть такая же.

Configurator знает о всех зависимостях внутри модуля. В нем устанавливается, что у ViewController будет Presenter, у Presenter будет Interactor и т.д. Более подробно будет рассматриваться далее в примере.

Также в классическом VIPER отказались от Segue, поэтому вы не сможете использовать сториборды для переходов между экранами. В нашем же случае, как и у Rambler, переходы через Segue работают и являются рекомендуемыми для использования, как того хотела Apple.

Так уж получилось, что на 100% пассивную View из вьюконтроллера сделать не получится. Сама Apple заложила для нее определенную роль со своим циклом жизни и вызываемыми методами (viewDidLoad, viewDidAppear и др.), поэтому мы должны это учитывать и строить свою архитектуру, исходя из этого. Сборка модуля запускается из viewDidLoad, когда вьюконтроллер уже загрузился, а не просто инициализировался. Также это дает нам возможность задавать Initial View Controller из сториборда, а не в AppDelegate, как это сделано в классическом варианте. Это гораздо удобней, потому что нет жесткой привязки к какой-то конкретной точке входа, и ее легко можно поменять.

После сборки модуля дальнейшее поведение модуля довольно классическое. View/ViewController не отвечает за логику нажатий на кнопки, ввода текста или какое-либо другое взаимодействие с UI. Все это сразу передается в Presenter. View может быть как в составе модуля, так и быть общей View, и использоваться в разных модулях.

Presenter решает, куда перенаправить действие – на Router или Interactor. Router будет либо закрывать текущий экран, либо открывать новый. Конкретная реализация перехода осуществляется в нем. Interactor решает, что делать дальше с поступившими событиями и какой сервис вызвать. В нем содержится логика модуля.

Но более важной функцией Presenter является подготовка и передача визуальных данных для View/ViewController, которые будут видны для пользователя. Presenter является сердцем нашего модуля, он знает, какие данные будут отображаться и в каком виде. Даже на разных схемах он всегда посередине. (А Interactor, наверно, мозгами)

Interactor является фасадом для других сервисов. Также Interactor может и сам содержать логику. В MVC его можно сравнить с контроллером, но который ничего не знает о том, как будут отображаться данные.

Сервисом в нашей интерпретации называются различные хелперы и другие классы, которые могут быть доступны из разных модулей и частей приложения (логика авторизации, работа с базой, работа с сервером, шифрование и т.п.). Сервисы могут взаимодействовать друг с другом и с Entity. Entity – это просто пассивные сущности (пользователь, книга, слово). Как и говорили ранее, Entity не является компонентом модуля VIPER. Вообще, изначально архитектура называлась VIP.

Если вы ничего не поняли, не беда. Дальше на примере все станет ясно, это было лишь поверхностное описание.


Глава 1. Пример очень простого модуля


Как ни странно, но рассматривать архитектуру я начну не с первого более сложного экрана, а с экрана «О приложении», который очень простой. Сам экран имеет пару лейблов, кнопку «Закрыть» и кнопку со ссылкой на сайт. При нажатии на «Закрыть» текущий экран закроется и будет показан предыдущий главный экран, а при нажатии на ссылку она откроется в Сафари. Лейблы пассивные и не меняются.

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

Названия модуля желательно выбирать коротким, потому что внутри модуля для классов к этому названию будут прибавляться дополнительные слова. К примеру, модуль «О приложении» назовем About. Вьюконтроллер будет называться AboutViewController. Остальные классы AboutPresenter, AboutInteractor, AboutConfigurator и т.д.

Если инициализация модуля начинается с вьюконтроллера, то и рассматривать модуль надо начинать с него. Создадим классы AboutViewController и AboutConfigurator. Класс AboutConfigurator должен соответствовать протоколу AboutConfiguratorProtocol и будет иметь лишь один метод:

protocol AboutConfiguratorProtocol: class {
    func configure(with viewController: AboutViewController)
}

class AboutConfigurator: AboutConfiguratorProtocol {
    func configure(with viewController: AboutViewController) {

    }
}

В дальнейшем внутри этого метода я буду конфигурировать модуль. AboutViewController будет иметь свойство configurator, который во viewDidLoad будет конфигурироваться, и свойство presenter, который будет соответствовать протоколу AboutPresenterProtocol.

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

AboutPresenterProtocol должен содержать метод configureView(), который будет инициализировать и конфигурировать первоначальные данные для визуальных элементов во вьюконтроллере. На данном этапе AboutViewController будет выглядеть так:

class AboutViewController: UIViewController {
        
    var presenter: AboutPresenterProtocol!
    let configurator: AboutConfiguratorProtocol = AboutConfigurator()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configurator.configure(with: self)
        presenter.configureView()
    }
}

Presenter будет иметь также router и методы, которые будут вызываться при нажатии на кнопку «Закрыть» и кнопку со ссылкой на сайт. AboutPresenterProtocol будет выглядеть так:

protocol AboutPresenterProtocol: class {
    var router: AboutRouterProtocol! { set get }
    func configureView()
    func closeButtonClicked()
    func urlButtonClicked(with urlString: String?)
}

Модуль этот очень простой, поэтому вся конфигурация вьюконтроллера будет заключаться в том, что подпись к кнопке с URL будет устанавливаться из кода, а не из визуального редактора. Для AboutViewController такой протокол:

protocol AboutViewProtocol: class {
    func setUrlButtonTitle(with title: String)
}

Внутри AboutPresenter реализовываем метод:

func configureView() {
     view.setUrlButtonTitle(with: interactor.urlRatesSource)
}

Теперь подошла очередь и интерактора. Логику и хранение/извлечение данных всегда надо переносить туда. В нашем случае интерактор будет иметь свойство, которое будет хранить URL сайта и метод, который будет открывать этот URL:

protocol AboutInteractorProtocol: class {
    var urlRatesSource: String { get }
    func openUrl(with urlString: String)
}

А как же обработка события нажатия на кнопку «Закрыть», можете подумать вы? Здесь презентер решает, что это событие связано с переходом между экранами, поэтому обработка будет передаваться в роутер. Для этого модуля роутер умеет только закрывать текущий вьюконтроллер.

Его протокол:

protocol AboutRouterProtocol: class {
    func closeCurrentViewController()
}

А протокол презентера будет выглядеть так:

protocol AboutPresenterProtocol: class {
    var router: AboutRouterProtocol! { set get }
    func configureView()
    func closeButtonClicked()
    func urlButtonClicked(with urlString: String?)
}

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

@IBOutlet weak var urlButton: UIButton!

@IBAction func closeButtonClicked(_ sender: UIBarButtonItem) {
    presenter.closeButtonClicked()
}
    
@IBAction func urlButtonClicked(_ sender: UIButton) {
    presenter.urlButtonClicked(with: sender.currentTitle)
}
        
func setUrlButtonTitle(with title: String) {
    urlButton.setTitle(title, for: .normal)
}

Вьюконтроллер понятия не имеет, что делать после нажатия на кнопки, но он точно знает, что делать, когда у него вызвали метод setUrlButtonTitle(with title: String). Вьюконтроллер только обновляет, передвигает, перекрашивает, скрывает UI-элементы на основе данных, с которыми презентер вызвал этот метод. В то же время презентер не знает, как именно все эти данные располагаются во View/ViewController.

Полный класс презентера выглядет так:

class AboutPresenter: AboutPresenterProtocol {
    
    weak var view: AboutViewProtocol!
    var interactor: AboutInteractorProtocol!
    var router: AboutRouterProtocol!
    
    required init(view: AboutViewProtocol) {
        self.view = view
    }
    
    // MARK: - AboutPresenterProtocol methods
    
    func configureView() {
        view.setUrlButtonTitle(with: interactor.urlRatesSource)
    }
    
    func closeButtonClicked() {
        router.closeCurrentViewController()
    }
    
    func urlButtonClicked(with urlString: String?) {
        if let url = urlString {
            interactor.openUrl(with: url)
        }
    }
}

Мы совсем забыли про наш конфигуратор. Ведь без него ничего работать не будет. Его код:

class AboutConfigurator: AboutConfiguratorProtocol {
    
    func configure(with viewController: AboutViewController) {
        let presenter = AboutPresenter(view: viewController)
        let interactor = AboutInteractor(presenter: presenter)
        let router = AboutRouter(viewController: viewController)
        
        viewController.presenter = presenter
        presenter.interactor = interactor
        presenter.router = router
    }
}

Понятное дело, чтобы не получить Reference cycle, презентер у вьюконтроллера указывается как strong, а вьюконтроллер у презентера как weak, интерактор у презентера указывается как weak, ну и так далее. Во всей этой цепочке самым главным остается ViewController. Поэтому говорить о пассивном View здесь неуместно. При закрытии ViewController все остальные элементы тоже уничтожаются, потому что никто не может иметь strong ссылку на ViewController. В противном случае мы бы получали утечку памяти (memory leak).

Класс интерактора выглядет так:

class AboutInteractor: AboutInteractorProtocol {
    
    weak var presenter: AboutPresenterProtocol!
    let serverService: ServerServiceProtocol = ServerService()
    
    required init(presenter: AboutPresenterProtocol) {
        self.presenter = presenter
    }
    
    var urlRatesSource: String {
        get {
            return serverService.urlRatesSource
        }
    }
    
    func openUrl(with urlString: String) {
        serverService.openUrl(with: urlString)
    }
}

Код довольно простой, поэтому комментарии излишни. Стоит обратить внимание на ServerService. Это сервис, который будет отдавать URL для нашей кнопки на вьюконтроллере и открывать ссылку в Сафари (или как-нибудь по другому). Код ServerService и его протокола выглядет так:

protocol ServerServiceProtocol: class {
    var urlRatesSource: String { get }
    func openUrl(with urlString: String)
}

class ServerService: ServerServiceProtocol {
    
    var urlRatesSource: String {
        return "https://free.currencyconverterapi.com"
    }
    
    func openUrl(with urlString: String) {
        if let url = URL(string: urlString) {
            UIApplication.shared.open(url, options: [:])
        }
    }
}

Здесь тоже все просто. Остался только роутер:

class AboutRouter: AboutRouterProtocol {
    
    weak var viewController: AboutViewController!
    
    init(viewController: AboutViewController) {
        self.viewController = viewController
    }
    
    func closeCurrentViewController() {
        viewController.dismiss(animated: true, completion: nil)
    }
}

Еще раз повторю, что весь исходный код есть в репозитории. Ссылка в конце статьи.


Глава 2. Пример более сложного модуля


Настало время рассмотреть архитектуру на более сложном примере и подытожить правила для каждого слоя.



Сториборд со всеми экранами выглядет так. Главный экран позволяет выбирать валюту, из которой конвертируем и в которую конвертируем. Также можно вводить сумму, из которой надо сконвертировать в другую валюту. Под полем ввода отображается сконвертированная в другую валюту сумма. А в самом низу курс конвертации и кнопка перехода на экран "О приложении".

Данные о всех валютах и их курсе запрашиваются с бесплатного сайта https://free.currencyconverterapi.com. Данные для простоты примера будем хранить в UserDefaults, но запросто можно переделать все лишь один класс, чтобы хранить их в Core Data или любым другим способом.

Теперь, когда мы знаем, как выглядет каркас модуля VIPER, мы запросто сможем то же самое сделать для главного экрана. Справа показаны файлы модуля Main (главного экрана). Небольшим неудобством архитектуры является то, что для каждого модуля нужно создавать много файлов, а вручную на это уходит много времени. Поэтому в Rambler придумали генератор кода Generamba, который выполняет рутинную работу за нас. Если покопаться, то его можно настроить под себя. Либо же можно использовать шаблоны для Xcode, примеров в сети предостаточно. Например, https://github.com/Juanpe/Swift-VIPER-Module или https://github.com/infinum/iOS-VIPER-Xcode-Templates. Более подробно эти темы рассматриваться не будут, т.к. это выходит за рамки статьи.

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

По традиции также начнем рассматривать модуль с вьюконтроллера. Важным правилом для View/ViewController является то, что в них не передаются Entity напрямую. Для этого должны создаваться дополнительные слои/сервисы.

Метод viewDidLoad будет идентичен реализации из модуля About. Вызовется конфигурирование модуля и будет дана команда интерактору сконфигурировать View (ViewController в нашем случае).

Конфигурирование модуля почти такое же, как в модуле «About». Но на главном экране понадобиться дополнительный View-компонент CurrencyPickerView, он создается как отдельный класс и может быть переиспользован в других местах или даже приложениях. В сториборде на главном экране добавляется обычный UIView и выставляется класс CurrencyPickerView для него.

Весь код CurrencyPickerView рассматриваться не будет. Хотя IBOutlet для него находится во вьюконтроллере, его логика будет обрабатываться в презентере. Поэтому в конфигураторе прописывается ссылка на него. У CurrencyPickerView также есть делегат, и им будет не вьюконтроллер, а презентер. В конфигураторе дописываем следующие вызовы:

class MainConfigurator: MainConfiguratorProtocol {
    
    func configure(with viewController: MainViewController) {
        ...
        presenter.currencyPickerView = viewController.currencyPickerView
        viewController.currencyPickerView.delegate = presenter
    }
}

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

func configureView() {
    view?.setInputValue(with: inputValue)
    view?.setOutputValue(with: outputValue)
    view?.setInputCurrencyShortName(with: inputCurrencyShortName)
    view?.setOutputCurrencyShortName(with: outputCurrencyShortName)
    view?.addDoneOnInputCurrencyKeyboard()
    updateRateText()
    interactor.getAllCurrencies()
}

Помимо установки начальных значений для UI-компонентов, в интерактор посылается запрос о получении списка всех валют. Презентер не знает, откуда будут получены эти данные, но он знает, что они ему нужны. Также значения inputValue, outputValue, inputCurrencyShortName и outputCurrencyShortName запрашиваются у интерактора, т.к. только он знает, откуда взять эти сохраненные данные:

var inputValue: String? {
    set {
        if let value = newValue {
            interactor.inputValue = Double(value) ?? 0.0
        }
    }
    get {
        var input = String(interactor.inputValue)
        if input.hasSuffix(".0") {
            input.removeLast(2)
        }
        return input
    }
}
var outputValue: String? {
    get {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.maximumFractionDigits = 2
        formatter.roundingMode = .down
        formatter.usesGroupingSeparator = false
        let number = NSNumber(value: interactor.outputValue)
        var output = formatter.string(from: number)!
        
        if output.hasSuffix(".00") {
            output.removeLast(2)
        }
        return output
    }
}
var inputCurrencyShortName: String {
    get {
        return interactor.inputCurrencyShortName
    }
}
var outputCurrencyShortName: String {
    get {
        return interactor.outputCurrencyShortName
    }
}

В комментариях к VIPER я встречал такое мнение, что презентер особо ничего не делает и просто передает данные от вьюконтроллера к интерактору и обратно. Из кода выше становится ясно, что презентер не просто запрашивает данные у интерактора и отдает «как есть», а также выполняет подготовку и форматирование данных в нужном виде. Запомните, что презентер отвечает за то, какие именно данные и в каком виде будут переданы вьюконтроллеру. Вьюконтроллеру уже не надо заботится об их форматировании, он лишь присвоит их нужным UI-компонентам.

Презентер ничего не знает о UIKit, он не знает об UIButton, UILabel и никаких других визуальных компонентах. Это очень важно. Вся работа с UIKit происходит во вьюконтроллерах и других View-компонентах. Также и при нажатии на кнопку нельзя передавать параметром UIButton, презентер не должен знать об этом. Поэтому нажатия на кнопки и ввод текста в поле ввода обрабатываются во вьюконтроллере таким образом:

@IBAction func inputCurrencyButtonClicked(_ sender: UIButton) {
    presenter.inputCurrencyButtonClicked()
}

@IBAction func outputCurrencyButtonClicked(_ sender: UIButton) {
    presenter.outputCurrencyButtonClicked()
}

func textFieldDidBeginEditing(_ textField: UITextField) {
    presenter.textFieldDidBeginEditing()
}

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    
    if textField == inputTextField {
        if textField.availableAdding(string: string) {
            textField.addString(string)
            self.presenter.inputValueChanged(to: textField.text ?? "")
        }
        return false
    }
    return true
}

func textFieldShouldClear(_ textField: UITextField) -> Bool {
    
    if textField == inputTextField {
        textField.clear()
        self.presenter.inputValueCleared()
        return false
    }
    return true
}

Допустим, надо написать кастомный UI-элемент или дописать extension для готового. Рассмотрим, например, UITextField из главного модуля. Компонент может содержать свою внутреннюю логику, касающуюся только его. К примеру, поле ввода суммы валюты может быть дробным, но нельзя ввести 2 нуля подряд в начале («00») или вводить несколько точек («0.11.2»), запятая преобразуется в точку, вводить можно только числа, добавляется дополнительная кнопка к клавиатуре и т.д. В таком случае разрешается эту логику выносить в сам элемент. Ведь эта логика не затрагивает логику других компонентов приложения, а относится только к нему самому. Например, вот так:

extension UITextField {
    
    func availableAdding(string: String) -> Bool {
        switch string {
        case "":
            return self.text != ""
        case "0"..."9":
            return self.text != "0"
        case ".", ",":
            return self.text!.count > 0 && self.text!.range(of: ".") == nil && self.text!.range(of: ",") == nil
        default:
            return false
        }
    }
    
    func addString(_ string: String) {
        var newValue: String = self.text ?? ""
        var addingString = string
        if addingString == "", newValue.count > 0 {
            newValue.removeLast()
        } else if addingString != "" {
            if addingString == "," {
                addingString = "."
            }
            newValue.append(addingString)
        }
        self.text = newValue
    }
    
    func clear() {
        self.text = ""
    }
    
    func addDoneOnKeyboard() {
        let keyboardToolbar = UIToolbar()
        keyboardToolbar.sizeToFit()
        let flexBarButton = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
        let doneBarButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(dismissKeyboard))
        keyboardToolbar.items = [flexBarButton, doneBarButton]
        self.inputAccessoryView = keyboardToolbar
    }
    
    @objc func dismissKeyboard() {
        self.resignFirstResponder()
    }
}

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

Рассмотрим, как происходит получение данных о валютах с сервера. В презентере произошел вызов метода getAllCurrencies() и все. Что должно происходить дальше, презентер не знает. Дальнейшая логика пишется в интеракторе и там, по необходимости, посылаются команды презентеру, что нужно делать. Реализация такая:

func getAllCurrencies() {
    presenter.showHUD()
    serverService.getAllCurrencies { (dict, error) in
        
        if let error = error {
            self.presenter.hideHUD()
            self.presenter.showLoadCurrenciesButton()
            self.presenter.showAlertView(with: error.localizedDescription)
            return
        }
        
        if let dictResponse = dict {
            self.currencyService.saveAllCurrencies(with: dictResponse, completion: { (error) in
                
                if let error = error {
                    self.presenter.hideHUD()
                    self.presenter.showAlertView(with: error.localizedDesc)
                    return
                }
                self.currencyService.sortAndUpdateCurrentCurrencies()
                self.getOutputCurrencyRatio(newCurrency: nil)
            })
        }
    }
}

В этом интеракторе задействованы уже 2 сервиса: CurrencyService, который отвечает за работу с валютами, и ранее известный ServerService, который отвечает за работу с сервером. Конечно, ServerService было бы правильней разбить на несколько классов-сервисов, чтобы переиспользовать методы работы не с конкретным сервером, а с любым. Но я ограничился здесь одним классом, чтобы упростить и не создавать по миллион классов на все, ведь для архитектуры модулей VIPER это не повредит.

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

Сервис CurrencyService будет помогать работать с такой структурой данных, как Currency (валюта). Он будет отдавать все доступные валюты, текущее введенное значение для валюты, типы валют, а также уметь сохранять, сортировать и получать курс относительно двух валют. Его протокол выглядет так:

protocol CurrencyServiceProtocol: class {
    var currencies: [Currency] { set get }
    var currencyNames: [String] { set get }
    var inputValue: Double { set get }
    var outputValue: Double { get }
    var inputCurrency: Currency { set get }
    var outputCurrency: Currency { set get }
    func saveAllCurrencies(with dict: [String: Any], completion: @escaping (CurrencyError?) -> Swift.Void)
    func sortAndUpdateCurrentCurrencies()
    func saveOutputCurrencyRatio(with dict: [String: Any], completion: @escaping (CurrencyError?) -> Swift.Void)
} 

Сервис CurrencyService запрашивает данные у другого сервиса StorageService, который сохраняет данные в UserDefaults, а интерактор даже и не подозревает, что данные вообще сохраняются, не говоря о том, как они сохраняются. Интерактор даже не знает, что существует сервис StorageService, потому что сервис-хелпер CurrencyService выполняет всю работу сам и только отдает данные интерактору.

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

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

Apple заложила этот функционал для вьюконтроллера, поэтому и нам надо исходить из такого подхода, а не переходить через navigationController?.pushViewController(vc, animated: true). После нажатия на кнопку «Инфо» должен открыться экран «О приложении». Поэтому событие нажатия кнопки презентер передает роутеру, а он вызывает следующий метод:

func showAboutScene() {
    viewController.performSegue(withIdentifier: viewController.selfToAboutSegueName, sender: nil)
}

Вьюконтроллер вызывает системный prepare(for segue...), а он уже напрямую передает это событие в роутер:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    presenter.router.prepare(for: segue, sender: sender)
}

Получилось, что презентер минуется в этом случае. Если кому-то не нравится такое нарушение, то надо либо пропускать метод через презентер, передавая как параметр UI-элемент, что будет тоже нарушением. Либо придумать что-нибудь еще. Лучшего варианта я сам пока не нашел. Если бы можно устанавливать делегат для prepare(for: segue, sender: sender), то, конечно, им был бы роутер.


Заключение


Указанный вариант архитектуры не является идеальным. Недаром в начале статьи я привел изречение, что у каждого свой VIPER. Сколько людей, столько и мнений. Я встречал варианты, когда несколько модулей группировали в рамках одного юзер-стори и писали один роутер для нескольких модулей. Или в другом варианте один интерактор на несколько модулей. Многие используют классический вариант с Wireframe, другие придумывают что-то еще. Кто-то передает во вьюконтроллер Entity. Последнее, конечно, неправильно.

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

В статье я не затронул Dependency Injection в модулях для iOS, например, Typhoon. И еще много других свистоперделок дополнительных и полезных вещей, облегчающих разработку. Общее поведение для модулей можно было вынести в абстрактные классы и протоколы, а потом наследоваться от них. В общем, любой проект и код можно улучшать до бесконечности и он все-равно не будет идеальным.

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

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


Ссылка на репозиторий.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (47):

  1. alexwillrock
    /#11356614

    опять же и опять же — на небольших приложениях любая архитектура себя отлично проявляет, но в крупных enterprise проектах ломается об многие процессы разработки…

    • IbrahimKZ
      /#11356628

      Не совсем согласен, я больше года работал на проекте, который писали до меня больше 1,5 года 5 разных разных разработчиков и в разное время. 50+ экранов. Там не соблюдается даже элементарный MVC.

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

      • iStaZzzz
        /#11356858

        «Лишь ситхи все возводят в абсолют» (с)

        Между VIPER и отсутствием архитектуры есть тот же MVC, или MVP, или MVVM. И на некоторых проектах они могут быть куда удобнее VIPER.

        • IbrahimKZ
          /#11356878

          VIPER можно назвать MVPI: Model (или те же сервисы), View понятно, Presenter и Interactor. Убери отсюда Interactor, получится MVP. Сделай из ViewController-а контроллер, а не View, получится MVC. Лично мне понравился VIPER хоть для маленького, хоть для большого приложения. Если вам по душе другая архитектура проблем нет. Статья же про рабочий пример на VIPER, а не про то, что это лучшая архитектура на свете.

  2. khlopko
    /#11356638

    Почему Вы решили, что VIPER это не MVC? Визуальное представление как было, так и осталось. Контроллер как был, так и остался. В конце концов, без модели вы никак не обойдетесь попросту. Вам не кажется что проблема не в MVC изначально? Ведь VIPER и есть MVC, а точнее один из вариантов реализации MVC.

    • IbrahimKZ
      /#11356658

      Как понять, что контроллер, как был, так и остался? В VIPER ViewController это не контроллер, а View. В MVC от эппл это контроллер. Визуальное представление в VIPER разделяется на презентер с логикой отображения и самим отображением во View/ViewController. В VIPER больше слоев и ролей. Я в статье привел ссылку, где подробней сравниваются разные архитектуры.

      • khlopko
        /#11356704

        Что мешает выделить логику из «эпловского view controller-a» в отдельные сущности? Вы сильно привязались к самим названиям, а речь в первую очередь о роли класса. Чем презентер в VIPER не контроллер описанный в MVC? Он как раз является посредником между данными и отображением, вполне себе контроллер.

        • IbrahimKZ
          /#11356738

          Ну так VIPER и выделяет слои из вьюконтроллера в презентер и интерактор. Названия тут не важны, я про это тоже указывал в статье.

          • khlopko
            /#11356834

            Но то что VIPER говорит КАК выделить слои не делает MVC плохим, ведь MVC не запрещает выделять эти слои. Я не понимаю зачем ругать подход, который ничем не ограничивает разработчика, при этом используя его же.

            • IbrahimKZ
              /#11356842

              Так ругают то, что расхлябанность в MVC приводит к гигантским вьюконтроллерам. Расскажите, что можно писать во вьюконтроллере, а что нет?

              • khlopko
                /#11356922

                Что писать, а что нет — решение сугубо индивидуальное. Можно просто следовать хотя бы только SRP и это уже подскажет что и куда. И не придется себя ограничивать различными реализациями и писать лишние слои, когда можно сделать проще. И от этого не пострадает гибкость и тестируемость кода.

                • IbrahimKZ
                  /#11356946

                  SRP и является причиной появления этих слоев в VIPER. Если у вас полностью статичный вьюконтроллер, можете удалить ненужные слои для этого модуля. Каждый модуль может иметь разное количество файлов и классов. Часто в сложных вьюконтроллерах их даже больше, чем перечисленные в VIPER.

                  • alexwillrock
                    /#11357016

                    но сложные VC можно так же поделить на множество маленьких простых VC (с помощью чайдов), сложные сервисы сложить и закрыть простыми фасадами, анимацию вынести в классы Animator, реализации протоколов в отдельные extension .swift файлы, за навигацию будет отвечать класс Координатор, и получится ViewController толщиной до 300 строк со всеми отступами по код стайл.

                    ИМХО, Вайпер интересно смотрелся в мире Objc с обязательным .h/.m разделением, но в Swift можно делать и проще и удобнее и лаконичнее.

                    • IbrahimKZ
                      /#11357250

                      Ну так я и не говорю, что разделение на слои и модули можно сделать только с помощью VIPER. Если у вас соблюдаются принципы SOLID, Clean Architecture и остального, то почему нет.

                      VIPER – один из способов решить проблему Massive VC и «лапшекода» и я попытался показать это на примере. Напишете свою реализацию архитектуры с примером. Я бы с удовольствием посмотрел.

                      • DnV
                        /#11357956

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

                        • IbrahimKZ
                          /#11358086

                          Можете привести пример кода, как это реализовывается на практике. Модели – это логика модулей/вьюконтроллеров или это сервисы/хелперы всего приложения? Вьюконтроллер сам хранит в себе данные и, получается, тогда выполняет 2 роли: вьюшки и контроллера?

                          • khlopko
                            /#11358194

                            Пример с пылу-жару: функционал в чатах hold-to-talk. Можно написать в контроллере это и ругать за «массивность», можно сделать то же самое в размазав логику по презентеру и интерактору, а можно выделить модель которая будет описывать поведение и использовать ее, а то что касается UI части, как и писали выше, описать в отдельном controllere и сделать его чайлдом. Сразу и тестируемость, и распределние по отвественности, и отличная переиспользуемость кода.

                          • alexwillrock
                            /#11358202

                            По идее — у тебя ViewController будет выполнять функцию View слоя, и только он делает import UIKit.
                            Cервисы должны быть чистыми функциями первого порядка — не могут хранить никаких данных.
                            Для данных у тебя есть отдельный слой Model, модели могут быть пассивными — это старый вариант, и активными — этот путь как раз и описывается в MVC по гайдлайну Apple. Один из хороших примеров M слоя — это CoreData, просто закрой ее за сервис, возвращающий Plain Object.

                            • IbrahimKZ
                              /#11358244

                              А логика работы конкретного вьюконтроллера где описывается?

                              • alexwillrock
                                /#11358284

                                логику работы можно описать в слое Controller, который знает о фасадах с бизнес логикой, и возвращает новое состояние во View слой (в данном случае выступает XXXViewController), output protocol того, что будет рисовать View.

                                Мне нравится из концепции REDUX возвращать один метод func update(_ state: MyState) и через switch - case обновлять весь UI под новые данные.

  3. alexwillrock
    /#11356668

    Раскрою подробнее свою мысль.
    Когда говорят про VIPER, то почти первым же предложением добавляют — тестируемость и модульность. Но странным образом, когда берутся за реализацию, то unit тесты сразу отпадают, их просто не хотят писать ни по TDD, ни по BDD техникам. Где то была обширная статейка про статистику количество багов на проект, и процент Crash Free от смены богомерского MVC на правильный VIPER не дал перфоманса, даже малейшего.

    Следующим пунктом — это модульность и независимость классов друг от друга. Идея, которая должна порадить массу маленьких самодостаточных фреймворков, наподобие компонентов, которые можно использовать между проектами и соблюдая Code Style и UI Style, оказалась утопической и нереализуемой в продакшене.

    И чисто из технических подходов — это двунаправленная связь через протоколы легко ломает бизнес логику и приводит к взаимоисключающим состояниям, Presenter раздувается до невообразимых размеров (Massive View Controller перекочевал в слой Presenter ?), а Router вообще плохо ложится на UIKit.

    Описывать проблемы использования VIPER в контексте IOS можно бесконечно…

    Другими словами — VIPER это один из видов MV(X) архитектуры, но с более дробленном контроллером.

    • IbrahimKZ
      /#11356734

      Лень писать тесты – это не проблема VIPER. У меня в кривой архитектуре с несоблюдением MVC даже Crash Free больше 99.5%. Проблема в поддержке такого кода.

      На модули же разделяют не для того, чтобы потом сделать из них маленькие фреймворки и переиспользовать. А больше для того, чтобы легче было тестировать и отлаживать каждый слой в модуле. Проблема с UI? Значит ищем во View или презентере. И т.д. Знаем, в каком слое что искать.

      По поводу «протоколы легко ломает бизнес логику и приводит к взаимоисключающим состояниям» не совсем понял. Довольно абстрактно без конкретного примера.

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

      По поводу дробления контроллера я абсолютно согласен. Про это и статья. Как разбить Massive View Controller.

  4. alexwillrock
    /#11356870 / +1

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

    Буду очень признателен (и не я один), если продемонстрируете свой пример использования Роутера, так как удачный и функциональный способ будет очень полезен для сообщества.

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

  5. ned19
    /#11356886 / +1

    Наиболее близкий вариант VIPER-а какой я использую у себя на проекте :)

  6. storoj
    /#11357692

    Massive View Controller это давно устаревший анекдот, хватит уже.

    • IbrahimKZ
      /#11357718

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

      • storoj
        /#11357722

        В том-то и дело, что имхо не стоит заражать интернет этой болезнью. Неопытный разработчик увидит все эти навороты, решит, что раз непонятно, значит круто, и будет дальше распространять эпидемию. А через месяц такую же статью напишет, "потому что massive view controller". В итоге куча каких-то хрен пойми зачем слоёв, протоколы, и view.setInputValue(with:...) вместо view.inputValue = ...

        • IbrahimKZ
          /#11357734

          view.setInputValue(with:...) потому что у inputValue нет геттера и вообще нет такого свойства. И это метод, который обновит UI компонент. А зачем эти хрен пойми какие слои в статье и рассказывается.

      • storoj
        /#11357732

        Или вот extension на UITextField. Там ни одна строчка кода не относится к UITextField, это просто манипуляции над строками. После такого ни на какие вайперы даже смотреть не хочется.

        • IbrahimKZ
          /#11357736

          Так это в вайперу вообще отношения не имеет :-)

          • storoj
            /#11357762

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

            view?.prop1 = val1
            view?.prop2 = val2
            // хороший программист уже распаковал бы view
            view?.setInputValue(with: inputValue) // зачем with:?
            

            func urlButtonClicked(with urlString: String?)
            // вместо
            func urlButtonClicked(with url: URL)
            // и вместо какого-нибудь
            func handle(url: URL)
            


            В `getAllCurrencies` если не дай бог придёт не dictionary, то HUD никогда не скроется. и в файле `MainInteractor.swift` вообще сложно понять кто и когда показывает и скрывает HUD, там явно несбалансированные вызовы.

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

            • storoj
              /#11357774 / +1

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

              • IbrahimKZ
                /#11357782

                Чистота кода и чистота архитектуры – разные вещи. Я про архитектуру больше хотел сказать и заранее вот это написал в статье:

                • storoj
                  /#11357798

                  Я не понимаю барьера между «чистым кодом» и «чистой архитектурой». Что такое архитектура? Это как, я не знаю, ссать в подъезде, но строго в один и тот же угол. Но ни в коем случае не в другой.

                  Я не пытаюсь оскорблять, скорее хочу донести мысль о том, что обратив внимание на те «мелочи», которые по сравнению с АРХИТЕКТУРОЙ кажутся уже незначительными, можно сильно улучшить качество приложения. Как кода – так и самого приложения, как следствие. Обратить внимание на поток данных, сделать его простым и понятным. Использовать подходящие структуры данных. Я уже обращал внимание на `urlString?` вместо `URL`. Зачем N слоям приложения мучаться с optional строкой, когда там на самом деле URL, ещё и потенциально невалидный? Зачем добавлять код в extension UI элемента, если код только лишь преобразует одни данные в другие? Самое место сделать отдельную функцию, которая принимает String и возвращает String – это уже даст гораздо больше для SRP, тестируемости и прочего XYZ. Начнёшь с маленького, а там и большое не захочется портить.

                  • IbrahimKZ
                    /#11357804

                    Чистота архитектура – это то, где вы будете писать ту или иную часть логики. ГДЕ, а не как. Чистота код – это то, КАК конкретно будет писаться сам код. Это то, к чему вы начали придираться.

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

                    В данном примере по правильной и чистой архитектуре вообще нельзя ссать в подъезде, это надо делать в туалете. А как конкретно и чем вы ссыте – это правильные конструкции кода или чистый код.

                    Я хотел показать, ГДЕ надо писать определенную часть логики, т.е. в каком слое.

                    • storoj
                      /#11357828

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

                      • IbrahimKZ
                        /#11358098

                        Какой такой код? У меня нормальный код в рамках примера. Если вы бы написали по-другому, то другой написал бы еще и третьим способом.

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

                        • alexwillrock
                          /#11359754

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

            • IbrahimKZ
              /#11357776 / -1

              Вот и промолчал бы лучше. Я уже сказал, почему использовал view?.setInputValue вместо свойства. with: — это просто частность, я же не учу именно так писать.

              func urlButtonClicked(with urlString: String?) тут String потому что вьюконтроллер не должен заниматься преобразованием в URL, это делается в интеракторе, а View-компонент знает только строки.

              func handle(url: URL) нельзя писать во вьюконтроллере. Это уже логика заточенная будет. Мы должны только сообщать презентору, что нажалась кнопка.

              В getAllCurrencies идет проверка на dict внутри saveAllCurrencies есть completion(CurrencyError(description: «Currencies' data format is wrong»)). Есть, конечно, опасность, что ни error ни ответа не придет, но там дописать один else только. Но это рабочий пример, а не приложение для продакшна.

  7. offyura
    /#11358618

    Презентер ничего не знает о UIKit, он не знает об UIButton, UILabel и никаких других визуальных компонентах. Это очень важно.

    и далее
    presenter.currencyPickerView = viewController.currencyPickerView

    • IbrahimKZ
      /#11358622

      Вы, видимо, проглядели это:

      weak var currencyPickerView: CurrencyPickerViewProtocol?

      Презентер знает только это:
      protocol CurrencyPickerViewProtocol: class {
          var arrayCurrencyNames: [String] { set get }
          var title: String { set get }
          var selectedCurrencyIndex: Int? { set get }
          func reload()
      }

      • offyura
        /#11358652

        хм, я думал что ни сам презентер, ни протоколы которые он реализует не должны быть зависимыми от UIKit…

        • IbrahimKZ
          /#11358660

          Они и не зависят. presenter.currencyPickerView и presenter.view в презенторе известны лишь как некие объекты, соответствующие протоколам, о которых знает презентор. Сами currencyPickerView и view уже зависят от UIKit. Но в тестах вам необязательно создавать будет реальные
          CurrencyPickerView: UIView, UIPickerViewDataSource, UIPickerViewDelegate, CurrencyPickerViewProtocol и MainViewController: UIViewController, MainViewProtocol, UITextFieldDelegate

          Их можно заменить моками, соответствующим протоколам.

          • offyura
            /#11358694

            Всё понял. Просто в файле MainPresenter.swift у вас есть import UIKit, что меня и смутило. Видимо в процессе разработки забыли убрать

  8. Krypt
    /#11358768

    Все мои попытки использовать VIPER закончились не так хорошо, как хотелось бы. Основная проблема — неопределённая область ответственности за выполнение переходов. Зачастую router не имеет нужных данных, чтобы принять решение.