Pull to refresh

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

Reading time 18 min
Views 89K
«У каждого свой 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-модулей рассмотреть уже не получится, но для такого кода их писать будет намного легче.

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


Ссылка на репозиторий.
Only registered users can participate in poll. Log in, please.
Какую архитектуру вы используете / будете использовать для презентейшн слоя в iOS?
20.58% VIPER. Я его уже активно использую 57
22.38% Наверно, попробую VIPER в следующем приложении 62
23.47% MVVM 65
9.03% MVP 25
13.72% MVC (Model-View-Controller) 38
5.78% MVC (Massive ViewController) 16
5.05% Другое (укажу в комментариях) 14
277 users voted. 43 users abstained.
Tags:
Hubs:
+13
Comments 47
Comments Comments 47

Articles