- PVSM.RU - https://www.pvsm.ru -
Мобильные приложения в последнее время стали по-настоящему большими — не только в смысле своей значимости для нас с вами, но и в прямом смысле.
По своей функциональности они бывают просто огромными. Некоторые приложения состоят из десятков, сотен экранов и переходов между ними. И пока пользователь открывает очередной экран с деталями заказа, наслаждаясь плавной анимацией, в мире MVVM происходит много всего интересного: вью-контроллер — создается, вью-модель — создается, аргументы — пробрасываются, зависимости — резолвятся.
Кто делает всю эту важную, но незаметную работу? Советую запастись чаем и печеньками: это мини-сериал о том, как я ни в чем себе не отказывал, реализуя MVVM в одном из своих домашних проектов. Сегодня заключительная серия — про слой роутинга в iOS-приложении.
Спешу сообщить, что у меня для вас две новости: первая и вторая. Начну с первой: прежде чем читать эту статью, придется прочитать предыдущие две (первая [1], вторая [2]), иначе будет совершенно ничего не понятно. Вторая новость заключается в том, что в этот раз я в самом начале статьи покажу конечный результат. А вот и он:
Весь остаток статьи мы будем разбираться, как работает эта строчка кода и зачем вообще она нужна.
Напомню также, что у меня есть некоторые правила и я стараюсь их придерживаться:
Традиционно в начале статьи будет ее содержание.
В прошлой статье про MVVM мы написали приложение, которое отображает список заказов (с кем не бывает). У нас есть OrdersVC
, у которого имеется личная вью-модель — OrdersVM
. Предположим, что мы хотим при нажатии на ячейку таблицы отображать экран с информацией о деталях соответствующего заказа:
Для тех, кто предпочитает больше конкретики, вот примитивная до неприличия реализация вью-модели нового экрана:
final class OrderDetailsVM: IPerRequest {
typealias Arguments = Order
let title: String
required init(container: IContainer, args: Order) {
self.title = "Details of (args.name) #(args.id)"
}
}
Модель представления деталей заказа реализует IPerRequest
(подробности — в статье про DI [1]), а значит, доступна из DI-контейнера. В качестве аргументов она принимает модель заказа и формирует из нее строковый заголовок, пригодный для отображения пользователю. Контроллер этого экрана будет выглядеть не намного сложнее:
final class OrderDetailsVC: UIViewController, IHaveViewModel {
typealias ViewModel = OrderDetailsVM
private lazy var titleLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
view.addSubview(titleLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.centerXAnchor
.constraint(equalTo: view.centerXAnchor)
.isActive = true
titleLabel.topAnchor
.constraint(equalTo: view.topAnchor, constant: 24)
.isActive = true
}
func viewModelChanged(_ viewModel: OrderDetailsVM) {
titleLabel.text = viewModel.title
}
}
Контроллер OrderDetailsVC
реализует IHaveViewModel
(подробности — в статье про MVVM [2]) и просто отображает текст, который подготовила для него вью-модель. Для тестовых целей нам этого вполне достаточно.
Чтобы научить OrdersVC
реагировать на тап по ячейке таблицы, дополним его экстеншеном:
extension OrdersVC: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
viewModel?.showOrderDetails(forOrderIndex: indexPath.row)
}
}
Кстати, этот короткий кусок кода наглядно показывает, как паттерн MVC, который безраздельно властвует в мире iOS, уживается с паттерном MVVM на территории одного приложения. Действие пользователя (тап по ячейке таблицы) обрабатывается контроллером, потому что в iOS по-другому быть не может. Однако контроллер ничего самостоятельно не делает, вместо этого он делегирует всю грязную работу своей модели представления, снабдив ее необходимой информацией в виде индекса интересующего нас заказа.
Напомню, что OrdersVM
, которую мы реализовывали в прошлой статье, выглядит так:
final class OrdersVM: IPerRequest, INotifyOnChanged {
typealias Arguments = Void
var orders: [OrderVM] = []
private let ordersProvider: OrdersProvider
required init(container: IContainer, args: Void) {
self.ordersProvider = container.resolve()
}
func loadOrders() {
ordersProvider.loadOrders() { [weak self] model in
self?.orders = model.map { OrderVM(order: $0) }
self?.changed.raise()
}
}
func showOrderDetails(forOrderIndex index: Int) {
let order = orders[index].order
// Что было дальше?
// ...
}
}
Эта модель представления реализует IPerRequest
, а значит, доступна из контейнера. Также из контейнера она извлекает OrdersProvider
, с помощью которого осуществляет загрузку заказов. По окончании загрузки список заказов заботливо складывается в массив orders
, а вью-контроллер получает соответствующее уведомление посредством вызова changed.raise()
.
В методе showOrderDetails(forOrderIndex:)
мы находим нужный заказ и должны открыть новый экран, который отображает детали этого заказа. Чтобы модально показать экран в iOS, нужно создать контроллер этого экрана и воспользоваться методом present(_:animated:completion:)
, который следует вызвать на текущем контроллере.
В мире MVC это делается очень просто, но в мире MVVM такая элементарная задача вызывает затруднения: вью-модель абсолютно ничего не знает о текущем контроллере. Кроме того, вью-модель понятия не имеет, как создавать новые вью-контроллеры и вью-модели для них. Выпутаться из этой неприятной истории нам поможет отдельный сервис — роутер, который осуществит навигацию на нужный экран.
Скорее всего, вы заметили, что модель представления OrdersVM
не занимается загрузкой заказов самостоятельно, эту работу она поручает сервису OrdersProvider
.
Выносить функциональность в отдельный сервис — очень экологичная практика, потому что это разгружает код вью-модели и позволяет повторно использовать такой сервис в других местах приложения. Из-за потенциальной возможности повторного использования при создании сервисов было бы неплохо соблюдать принцип единой ответственности. [11]
Если сервис берет на себя слишком много, скорее всего, переиспользовать его будет проблематично. Можно вдохновиться отважными бэкэндерами с их микросервисной архитектурой [12] и сделать вид, что мы тоже пишем микросервисы.
В реальном приложении сервисов, подобных OrdersProvider
, может быть великое множество. Одни будут заниматься получением данных, другие — их обработкой, реализуя полезную бизнес-логику. Некоторые сервисы могут делать для вью-моделей часть работы по подготовке данных для отображения: преобразование, форматирование и т. п. Все они будут находиться в весьма запутанных взаимоотношениях друг с другом: одни сервисы будут зависеть от других, а те будут зависеть еще от каких-то, образуя разветвленный граф зависимостей.
Композиция сервисов — очень удобный и мощный механизм организации и переиспользования кода. Здесь очень пригождается DI-контейнер, о котором мы говорили в самой первой статье. Когда количество сервисов в вашем приложении начнет исчисляться десятками, это не станет проблемой, потому что DI-контейнер сможет создать для вас любую сущность, попутно разрешив все ее зависимости.
В общем, смысл этого многословного раздела можно уместить в три простые, но настоятельные рекомендации:
Некоторые сервисы могут заниматься роутингом, помогая вью-моделям осуществлять навигацию на новые экраны приложения.
Действительно, если действие пользователя, такое как тап в ячейку, прилетает сразу в контроллер, почему бы из этого контроллера не показать новый экран простым вызовом present(_:animated:completion:)
. Я голосую против, потому что такой подход удобнее только на первый взгляд:
Такие образом, будет полезно вынести функциональность роутинга в отдельный сервис или в несколько сервисов. За показ модальных экранов мог бы отвечать, скажем, PresenterService
.
Вот три простых шага на пути к модальному открытию нового экрана:
UIViewController
, с которого будет осуществляться переход.Начнем с того, что объявим сам класс и сделаем его доступным из контейнера:
final class PresenterService: ISingleton {
private unowned let container: IContainer
public required init(container: IContainer, args: Void) {
self.container = container
}
}
Мы предусмотрительно сохранили ссылку на контейнер в инициализаторе, он нам еще понадобится. Стоит сказать, что обычно сохранять ссылку на контейнер, чтобы потом в какой-то момент что-то из него извлечь, не очень хорошая практика: это размазывает код создания зависимостей как в пространстве, так и во времени, делая этот код более запутанным, а ваших коллег — более раздражительными. Однако, так как PresenterService
занимается роутингом и собирается создавать новые экраны, у него нет другого выбора и владеть ссылкой на контейнер — его классовая привилегия.
Первый пункт — поиск контроллера — можно сделать очень просто с помощью нескольких строк не самого элегантного рекурсивного кода:
var topViewController: UIViewController? {
let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }
return findTopViewController(in: keyWindow?.rootViewController)
}
func findTopViewController(in controller: UIViewController?) -> UIViewController? {
if let navigationController = controller as? UINavigationController {
return findTopViewController(in: navigationController.topViewController)
} else if let tabController = controller as? UITabBarController,
let selected = tabController.selectedViewController {
return findTopViewController(in: selected)
} else if let presented = controller?.presentedViewController {
return findTopViewController(in: presented)
}
return controller
}
Метод findTopViewController(in:)
врывается в иерархию контроллеров, как товарищ майор с обыском, и пытается найти там контроллер, который в данный момент отображается на экране. Возможно, это не самый универсальный способ решить задачу и, если в вашем приложении используется более запутанная структура экранов, потребуются некоторые правки, но идея, думаю, понятна.
Мы подбираемся к кульминации и сейчас реализуем метод, который я показывал в самом начале статьи. Он будет состоять буквально из нескольких строк, но по-настоящему важна только вторая строка, в которой и происходит вся магия. Сложно поверить, но ради того, чтобы нормально объяснить, что происходит в этой одной строчке кода, мне потребовалось написать аж три статьи:
func present<VC: UIViewController & IHaveViewModel>(
_ viewController: VC.Type,
args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {
let vc = VC()
vc.viewModel = container.resolve(args: args) // Тут вся магия
topViewController?.present(vc, animated: true, completion: nil)
}
Давайте разбираться. Этот метод невероятно тесно интегрирован с нашей реализацией MVVM и с DI-контейнером и состоит, как вы наверняка заметили, всего из трех строк.
IResolvable
(про это была статья про DI [1]). Нам всего лишь нужно знать ее тип и аргументы, от которых она зависит. Тип вью-модели известен, потому что все вью-контроллеры предоставляют свойство viewModel
в рамках реализации протокола IHaveViewModel
(про это была статья про MVVM [2]). Кроме того, у нас имеются необходимые аргументы VC.ViewModel.Arguments
и доступ к контейнеру прямо из сервиса. При создании экземпляра вью-модели с помощью магии DI-контейнера самым удобным образом разрешаются все ее зависимости. Прочувствуйте момент: DI-контейнер, MVVM и роутинг сходятся здесь и сейчас в одной точке, и эта точка — одна строчка кода. Ух!present(_:animated:completion:)
. Чтобы пазл сложился, давайте еще раз взглянем на весь код PresenterService
, который до этого мы разбирали по кусочкам:
final class PresenterService: ISingleton {
private unowned let container: IContainer
private var topViewController: UIViewController? {
let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }
return findTopViewController(in: keyWindow?.rootViewController)
}
required init(container: IContainer, args: Void) {
self.container = container
}
func present<VC: UIViewController & IHaveViewModel>(
_ viewController: VC.Type,
args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {
let vc = VC()
vc.viewModel = container.resolve(args: args)
topViewController?.present(vc, animated: true, completion: nil)
}
func dismiss() {
topViewController?.dismiss(animated: true, completion: nil)
}
private func findTopViewController(
in controller: UIViewController?) -> UIViewController? {
if let navigationController = controller as? UINavigationController {
return findTopViewController(in: navigationController.topViewController)
} else if let tabController = controller as? UITabBarController,
let selected = tabController.selectedViewController {
return findTopViewController(in: selected)
} else if let presented = controller?.presentedViewController {
return findTopViewController(in: presented)
}
return controller
}
}
Единственный незнакомый метод, который здесь добавился, — это dismiss()
, позволяющий закрыть текущий модальный экран. Окончательная реализация OrdersVM
, которая с помощью PresenterService
научилась отображать детали заказа, выглядит так:
final class OrdersVM: IPerRequest, INotifyOnChanged {
typealias Arguments = Void
var orders: [OrderVM] = []
private let ordersProvider: OrdersProvider
private let presenter: PresenterService
required init(container: IContainer, args: Void) {
self.ordersProvider = container.resolve()
self.presenter = container.resolve()
}
func loadOrders() {
ordersProvider.loadOrders() { [weak self] model in
self?.orders = model.map { OrderVM(order: $0) }
self?.changed.raise()
}
}
func showOrderDetails(forOrderIndex index: Int) {
let order = orders[index].order
// Открываем экран с деталями заказа
presenter.present(OrderDetailsVC.self, args: order)
}
}
Как видно, в инициализаторе мы без лишней суеты достаем из контейнера наш PresenterService
и используем его по назначению в методе showOrderDetails(forOrderIndex:)
.
Для работы с UINavigationController
придется написать отдельный сервис. Назовем его, например, NavigationService
. Вот три простых шага, которые нужно сделать, чтобы запушить новый экран:
UINavigationController
, который сейчас виден на экране.Как видно, эти шаги очень похожи на таковые для PresenterService
, а значит, и код будет аналогичным. Я его даже под спойлер уберу, чтобы под ногами не мешался.
final class NavigationService: ISingleton {
private unowned let container: IContainer
private var topNavigationController: UINavigationController? {
let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }
let root = keyWindow?.rootViewController
let topViewController = findTopViewController(in: root)
return findNavigationController(in: topViewController)
}
required init(container: IContainer, args: Void) {
self.container = container
}
func pushViewController<VC: UIViewController & IHaveViewModel>(
_ viewController: VC.Type,
args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {
let vc = VC()
vc.viewModel = container.resolve(args: args)
topNavigationController?.pushViewController(vc, animated: true)
}
func popViewController() {
topNavigationController?.popViewController(animated: true)
}
private func findTopViewController(
in controller: UIViewController?) -> UIViewController? {
if let navigationController = controller as? UINavigationController {
return findTopViewController(in: navigationController.topViewController)
} else if let tabController = controller as? UITabBarController,
let selected = tabController.selectedViewController {
return findTopViewController(in: selected)
} else if let presented = controller?.presentedViewController {
return findTopViewController(in: presented)
}
return controller
}
private func findNavigationController(
in controller: UIViewController?) -> UINavigationController? {
if let navigationController = controller as? UINavigationController {
return navigationController
} else if let navigationController = controller?.navigationController {
return navigationController
} else {
for child in controller?.children ?? [] {
if let navigationController = findNavigationController(in: child) {
return navigationController
}
}
}
return nil
}
}
Сервисы, подобные NavigationService
и PresenterService
, нужно будет написать для всех контроллеров, которые являются контейнерами [13] для других контроллеров — как для стандартных типа UITabBarController
, так и для кастомных. Группа таких сервисов образует слой роутинга в вашем приложении.
Весь код в этой и предыдущих статьях — очень простой, в нем важна скорее идея, а не реализация. Напишите свою версию MVVM, роутинга, используйте другой DI-контейнер — все это категорически неважно. Важны основополагающие принципы, важны прямоугольники со скругленными углами и стрелочки между ними:
Вьюха (контроллер) должна держать вью-модель сильной ссылкой. Вью-модель должна каким-то образом уведомлять вьюху об изменении своего состояния. Вью-модель должна зависеть от многочисленных сервисов, один из которых — роутер. Используйте роутер для навигации между экранами. Точка навигации — точка большого взрыва, который приводит к созданию нового экрана и всех его зависимостей. Роутер должен уметь создавать пары «вьюха — вью-модель» и все сервисы, от которых они зависят. Для этого он вынужден держать ссылку на DI-контейнер.
Развивайте архитектуру дальше. Например, если у вас длинные цепочки экранов, между которыми передаются полезные данные, попробуйте координатор. [14] Координатор будет держать ссылку на роутер и осуществлять навигацию через него — композиция сервисов в действии. Экспериментируйте, пусть ничто не ограничивает вашу фантазию. Веселитесь и получайте удовольствие от своей работы.
Сегодня мы говорили про роль (микро)сервисов в мобильных приложениях на примере роутинга. Сервисы роутинга — мостик между миром MVC и миром MVVM. Они помогают вью-моделям осуществлять навигацию на новые экраны и имеют право напрямую обращаться к DI-контейнеру для создания пар «вьюха — вью-модель».
Реализация PresenterService
, рассмотренная в этой статье, — последний кусочек пазла, необходимый для полноценной работы паттерна MVVM в вашем мобильном приложении. PresenterService
глубоко интегрирован с конкретными реализациями MVVM и DI-контейнера, про которые мы говорили в предыдущих статьях, и только в связке с ними раскрывается весь его потенциал.
Весь код из этой статьи можно скачать [15] в виде Swift Playground.
Автор: Александр Волохин
Источник [16]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/ios-development/355105
Ссылки в тексте:
[1] первая: https://habr.com/ru/company/tinkoff/blog/508452/
[2] вторая: https://habr.com/ru/company/tinkoff/blog/509014/
[3] Введение: #1.1
[4] В чем проблема?: #1.2
[5] Стоп, что за сервисы вообще такие?: #1.3
[6] Обязательно нужен отдельный сервис для роутинга?: #1.4
[7] Окей, автор, как мне реализовать роутер?: #1.5
[8] Не хочу модальные экраны, хочу пушить экраны в стэк. Как быть?: #1.6
[9] Мне не подходит реализация роутинга. Что делать?: #1.7
[10] Заключение: #1.8
[11] принцип единой ответственности.: https://en.wikipedia.org/wiki/Single-responsibility_principle
[12] микросервисной архитектурой: https://en.wikipedia.org/wiki/Microservices
[13] контейнерами: https://developer.apple.com/documentation/uikit/view_controllers/creating_a_custom_container_view_controller
[14] координатор.: https://www.raywenderlich.com/158-coordinator-tutorial-for-ios-getting-started
[15] скачать: https://github.com/volokhin/Playgrounds/tree/master/Router.playground
[16] Источник: https://habr.com/ru/post/510286/?utm_source=habrahabr&utm_medium=rss&utm_campaign=510286
Нажмите здесь для печати.