Данная статья - моя попытка разобраться и объяснить архитектурный паттерн MVP with Router.
Про сам паттерн MVP в на просторах интернета можно найти довольно много информации, например по следующей ссылкам:
Архитектурные паттерны в 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.
Полный код проекта можно найти по ссылке.
FreeNickname
Спасибо, полезно!
Несколько моментов:
Во-первых,
targert
->target
:) Это опечатка, а об опечатках в личку, но тут это в коде, так что это практически pull request :)Во-вторых, небольшое упрощение:
И немного по структуре статьи:
Из этого абзаца не понятно, в чём заключается "проблема сборки", и при этом создаётся впечатление, что её решает добавление роутера в классический MVP.
Эта строка не помогает ситуации :)
Как вы догадываетесь, это озадачивает, в результате я очень внимательно вчитывался в статью, пытаясь понять, что, как и почему, а в итоге остался несколько разочарован, что суть "просто в добавлении сущности Assembly")
Ну и тут возникает проблемный вопрос всех туториалов – насколько глубоко нужно уходить в детали реализации "базовых" / "фоновых" вещей. Наверняка Вы тоже видели множество обучалок по какой-нибудь теме типа "создание локальных пуш-уведомлений", которые начинаются с "откройте Xcode, кликните New -> Project..." :) Я это к чему: из процитированного параграфа, вроде как, следует, что главная "вишенка" статьи – это та самая Assembly. Но чтобы до неё добраться, приходится аккуратно пройти через все шаги создания типового MVP(+R), с полным кодом и всеми деталями. В сочетании с тем, что проблематика обозначена не вполне чётко, как мы выяснили выше, воспринимается немного тяжело, т.к. нужно всю статью держать в голове полный контекст, ибо не знаешь, что из него дальше понадобится.
Я бы, наверное, постарался почётче обозначить проблематику в начале – расписать чуть подробнее, какую проблемы мы, собственно, пытаемся решить. Дальше, как вариант, можно использовать подход, который Apple очень любит в своих обучалках и документации – "сверху вниз". Изложить всю концепцию очень поверхностно, потом всё то же самое чуть более подробно, потом то же самое совсем подробно, уже с кодом и т.д. При этом код самого MVP может вообще уехать вод спойлеры, как вариант, хотя это дело вкуса, конечно.
P.S. Не поймите меня неправильно, я благодарен за статью, и критика – это просто моё личное мнение в надежде, что оно покажется Вам разумным и, возможно, следующие статьи будут ещё лучше) Моё мнение – не истина в последней инстанции)
svgnovosibirsk Автор
Большое спасибо, за обратную связь. Она действительно полезная. ???? Обязательно возьму на вооружение при написании будущих материалов.