Данная статья - моя попытка разобраться и объяснить архитектурный паттерн MVP with Router.

Про сам паттерн MVP в на просторах интернета можно найти довольно много информации, например по следующей ссылкам:

Архитектурные паттерны в iOS

Архитектурные паттерны в iOS: страх и ненависть в диаграммах. MV(X)

А вот про разновидность данного паттерна, которая решает проблему сборки, возникающую при использовании MVP информации не так уж и много. Давайте попробуем разобраться что такое Router применительно к паттерну MVP, зачем он нужен и как его использовать.

Начнем с основ

Рассмотрение паттерна MVC with Router предлагаю рассмотреть на практике на примере создания простого приложения из двух экранов. Распределим все сущности нашего приложения согласно паттерну MVP,  осознаем возникающую проблему сборки и применим сущности Assembley и Router для решения данной проблемы.

Model

Для начала создадим простую модель. Она будет содержать свойство someData типа String? и методы получения и установки данных. Создадим протокол, а затем и саму модель, соответствующую указанным требованиям.

protocol FirstModelProtocol {
    func getData() -> String
    func setData(data: String)
}

final class FirstModel {
    private var someData: String?
}

extension FirstModel: FirstModelProtocol {
    func getData() -> String {
        guard let someData = self.someData else { return "" }
        return someData
    }
    
    func setData(data: String) {
        self.someData = data
    }
}

View

В терминах MVP под View мы понимаем как отдельные View так и ViewController. Для начала создадим протокол, которому должна соответствовать сущность View.

protocol FirstViewProtocol: UIView {
    var onTouchedHandler: (() -> Void)? { get set }
    var goToSecondHandler: (() -> Void)? { get set }

    func update(data: String)
    func getTextFieldData() -> String
}

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

Создадим нашу View. Добавим на нее две кнопки, текстовое поле и лейбл. В расширении View реализуем требования протокола.

final class FirstView: UIView {
    var onTouchedHandler: (() -> Void)?
    var goToSecondHandler: (() -> Void)?

    private lazy var button: UIButton = {
        let obj = UIButton(frame: CGRect(x: 100, y: 300, width: 200, height: 50))
        obj.backgroundColor = .white
        obj.setTitleColor(.darkGray, for: .normal)
        obj.layer.cornerRadius = 10
        obj.layer.borderWidth = 5
        obj.layer.borderColor = UIColor.orange.cgColor
        obj.setTitle("Push me", for: .normal)
        obj.addTarget(self, action: #selector(self.touchedDown), for: .touchDown)
        return obj
    }()
    
    private lazy var button2: UIButton = {
        let obj = UIButton(frame: CGRect(x: 100, y: 400, width: 200, height: 50))
        obj.backgroundColor = .white
        obj.setTitleColor(.darkGray, for: .normal)
        obj.layer.cornerRadius = 10
        obj.layer.borderWidth = 5
        obj.layer.borderColor = UIColor.orange.cgColor
        obj.setTitle("Go to second", for: .normal)
        obj.addTarget(self, action: #selector(self.touchedDownGoToSecond), for: .touchDown)
        return obj
    }()

    private lazy var textField: UITextField = {
        let obj = UITextField(frame: CGRect(x: 100, y: 100, width: 200, height: 50))
        obj.backgroundColor = .systemGray6
        obj.placeholder = "Type something cool"
        return obj
    }()

    private lazy var label: UILabel = {
        let obj = UILabel(frame: CGRect(x: 100, y: 200, width: 200, height: 50))
        obj.backgroundColor = .systemGray6
        return obj
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.configView()
        self.backgroundColor = .white
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

// MARK: Private extension

private extension FirstView {
    
    private func configView() {
        self.addSubview(self.button)
        self.addSubview(self.button2)
        self.addSubview(self.label)
        self.addSubview(self.textField)
    }

    @objc private func touchedDown() {
        self.onTouchedHandler?()
    }
    
    @objc private func touchedDownGoToSecond() {
        self.goToSecondHandler?()
    }
}

// MARK: FirstViewProtocol

extension FirstView: FirstViewProtocol {
    
    func getTextFieldData() -> String {
        guard let text = self.textField.text else { return  "" }
        return text
    }

    func update(data: String) {
        self.label.text = data
    }
}

Router

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

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

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

final class Router {
    private var controller: UIViewController?
    private var targertController: UIViewController?
    
    func setRootController(controller: UIViewController) {
        self.controller = controller
    }
    
    func setTargerController(controller: UIViewController) {
        self.targertController = controller
    }
    
    func next() {
        guard let targertController = self.targertController else {
            return
        }
        
        self.controller?.navigationController?.pushViewController(targertController, animated: true)
    }
}

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

Presenter

Теперь у нас есть все для создания презентера. Давайте создадим его.

protocol FirstPresenterProtocol {
    func loadView(controller: FirstViewController, view: FirstViewProtocol)
}

final class FirstPresenter {
    private let model: FirstModelProtocol
    private let router: Router
    private weak var controller: FirstViewController?
    private weak var view: FirstViewProtocol?

    struct Dependencies {
        let model: FirstModelProtocol
        let router: Router
    }

    init(dependencies: Dependencies) {
        self.model = dependencies.model
        self.router = dependencies.router
    }
}


private extension FirstPresenter {
    private func onTouched() {
        guard let view = view else { return }

        let modelData = view.getTextFieldData()
        self.model.setData(data: modelData)

        let viewModel = "The data: " + self.model.getData()
        self.view?.update(data: viewModel)
    }
    
    private func onTouchedGoToSecondVC() {
        self.router.next()
    }

    private func setHandlers() {
        self.view?.onTouchedHandler = { [weak self] in
            self?.onTouched()
        }
        self.view?.goToSecondHandler = { [weak self] in
            self?.onTouchedGoToSecondVC()
        }
    }
}

extension FirstPresenter: FirstPresenterProtocol {
    func loadView(controller: FirstViewController, view: FirstViewProtocol) {
        self.controller = controller
        self.view = view

        self.setHandlers()
    }
}

В протоколе мы определяем метод loadView, который необходим нам для передачи в презентер ViewController и View.

Мы определяем в презентере свойства модель, контроллер, вью и роутер. При этом модель и роутер мы будем передавать при инициализации презента через структуру Dependencies.

Мы определяем два метода, которые будут вызываться в ответ на нажатие кнопок View и в методе setHandlers связываем их с соответствующими методами View.

В методе loadView, который мы реализуем в расширении как требование протокола мы устанавливаем контроллер и вью.

Assembley

Компоненты нашего MVP готовы. В данный момент мы и сталкиваемся с проблемой сборки. Сейчас нам необходимо собрать все компоненты воедино. Сделать что то наподобие:

let model = Model()
let view = View()
let presenter = Presenter(view: view, model: model)
view.presenter = presenter

Классический MVP не определяет кто отвечает за сборку и где она должна происходить.

Тут нам на помощь приходит отдельная сущность Assembley в которую мы и вынесем нашу сборку.

final class FirstScreenAssembley {
    static func build() -> UIViewController {
        let model = FirstModel()
        let router = Router()

        let presenter = FirstPresenter(
            dependencies: .init(model: model, router: router)
        )
        
        let controller = FirstViewController(
            dependencies: .init(presenter: presenter)
        )
        
        let targetController = SecondScreenAssembley.build()

        router.setRootController(controller: controller)
        router.setTargerController(controller: targetController)

        return controller
    }
}

Внутри Assembley в методе build мы создаем модель, презентер, контроллер, роутер и соединяем все воедино возвращая контроллер инициализированный со всеми зависимостями.

В классе SceneDelegate мы вызываем метод build у FirstScreenAssembley для того чтобы получить ViewController и сделать рутовым у NavigationController.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    var navigationVc: UINavigationController?
    var vc: UIViewController?


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        
        guard let windowScene = (scene as? UIWindowScene) else { return }
        self.window = UIWindow(windowScene: windowScene)
        
        self.vc = FirstScreenAssembley.build()
        guard let vc = self.vc else { return }
        self.navigationVc = UINavigationController(rootViewController: vc)
        
        self.window?.rootViewController = self.navigationVc
        self.window?.makeKeyAndVisible()
    }
}

В целом, наше приложение с Router и Assembley готово.

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

class SecondViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.backgroundColor = .systemMint
    }
}
final class SecondScreenAssembley {
    static func build() -> UIViewController {
        SecondViewController()
    }
}

Запустим наше приложение.

При нажатии на кнопку Push me текст лейбла у нас отображает данные, введенные в текстовое поле:

А при нажатии на кнопку Go to second мы переходим на второй экран.

Надеюсь данная статья окажется полезной и поможет понять как мы можем применять сущности Router и Assembley в наших проектах с архитектурой MVP.

Полный код проекта можно найти по ссылке.

Комментарии (3)


  1. FreeNickname
    22.07.2023 20:45
    +1

    Спасибо, полезно!

    Несколько моментов:

    Во-первых, targert -> target :) Это опечатка, а об опечатках в личку, но тут это в коде, так что это практически pull request :)

    Во-вторых, небольшое упрощение:

    self.view?.onTouchedHandler = { [weak self] in
                self?.onTouched()
    }
    // вроде как, можно заменить на
    self.view?.onTouchedHandler = self?.onTouched
    // ну и так же для `goToSecondHandler`

    И немного по структуре статьи:

    А вот про разновидность данного паттерна, которая решает проблему сборки, возникающую при использовании MVP информации не так уж и много. Давайте попробуем разобраться что такое Router применительно к паттерну MVP

    Из этого абзаца не понятно, в чём заключается "проблема сборки", и при этом создаётся впечатление, что её решает добавление роутера в классический MVP.

    осознаем возникающую проблему сборки и применим сущности Assembley и Router для решения данной проблемы

    Эта строка не помогает ситуации :)

    Как вы догадываетесь, это озадачивает, в результате я очень внимательно вчитывался в статью, пытаясь понять, что, как и почему, а в итоге остался несколько разочарован, что суть "просто в добавлении сущности Assembly")

    Ну и тут возникает проблемный вопрос всех туториалов – насколько глубоко нужно уходить в детали реализации "базовых" / "фоновых" вещей. Наверняка Вы тоже видели множество обучалок по какой-нибудь теме типа "создание локальных пуш-уведомлений", которые начинаются с "откройте Xcode, кликните New -> Project..." :) Я это к чему: из процитированного параграфа, вроде как, следует, что главная "вишенка" статьи – это та самая Assembly. Но чтобы до неё добраться, приходится аккуратно пройти через все шаги создания типового MVP(+R), с полным кодом и всеми деталями. В сочетании с тем, что проблематика обозначена не вполне чётко, как мы выяснили выше, воспринимается немного тяжело, т.к. нужно всю статью держать в голове полный контекст, ибо не знаешь, что из него дальше понадобится.

    Я бы, наверное, постарался почётче обозначить проблематику в начале – расписать чуть подробнее, какую проблемы мы, собственно, пытаемся решить. Дальше, как вариант, можно использовать подход, который Apple очень любит в своих обучалках и документации – "сверху вниз". Изложить всю концепцию очень поверхностно, потом всё то же самое чуть более подробно, потом то же самое совсем подробно, уже с кодом и т.д. При этом код самого MVP может вообще уехать вод спойлеры, как вариант, хотя это дело вкуса, конечно.


    P.S. Не поймите меня неправильно, я благодарен за статью, и критика – это просто моё личное мнение в надежде, что оно покажется Вам разумным и, возможно, следующие статьи будут ещё лучше) Моё мнение – не истина в последней инстанции)


    1. svgnovosibirsk Автор
      22.07.2023 20:45
      +1

      Большое спасибо, за обратную связь. Она действительно полезная. ???? Обязательно возьму на вооружение при написании будущих материалов.


  1. varton86
    22.07.2023 20:45
    +1

    В private extension можно не писать private func, она уже private.