Мобильные приложения в последнее время стали по-настоящему большими — не только в смысле своей значимости для нас с вами, но и в прямом смысле.
По своей функциональности они бывают просто огромными. Некоторые приложения состоят из десятков, сотен экранов и переходов между ними. И пока пользователь открывает очередной экран с деталями заказа, наслаждаясь плавной анимацией, в мире MVVM происходит много всего интересного: вью-контроллер — создается, вью-модель — создается, аргументы — пробрасываются, зависимости — резолвятся.
Кто делает всю эту важную, но незаметную работу? Советую запастись чаем и печеньками: это мини-сериал о том, как я ни в чем себе не отказывал, реализуя MVVM в одном из своих домашних проектов. Сегодня заключительная серия — про слой роутинга в iOS-приложении.
Введение
Спешу сообщить, что у меня для вас две новости: первая и вторая. Начну с первой: прежде чем читать эту статью, придется прочитать предыдущие две (первая, вторая), иначе будет совершенно ничего не понятно. Вторая новость заключается в том, что в этот раз я в самом начале статьи покажу конечный результат. А вот и он:
Весь остаток статьи мы будем разбираться, как работает эта строчка кода и зачем вообще она нужна.
Напомню также, что у меня есть некоторые правила и я стараюсь их придерживаться:
- Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
- Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
- Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
- Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.
Традиционно в начале статьи будет ее содержание.
Традиционное содержание
- Статья 1. Бюджетный DI на антипаттернах
- Статья 2. Доступный MVVM на хакнутых экстеншенах
- Статья 3. Легковесный роутинг на микросервисах
В чем проблема?
В прошлой статье про 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), а значит, доступна из 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) и просто отображает текст, который подготовила для него вью-модель. Для тестовых целей нам этого вполне достаточно.
Чтобы научить 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
.
Выносить функциональность в отдельный сервис — очень экологичная практика, потому что это разгружает код вью-модели и позволяет повторно использовать такой сервис в других местах приложения. Из-за потенциальной возможности повторного использования при создании сервисов было бы неплохо соблюдать принцип единой ответственности.
Если сервис берет на себя слишком много, скорее всего, переиспользовать его будет проблематично. Можно вдохновиться отважными бэкэндерами с их микросервисной архитектурой и сделать вид, что мы тоже пишем микросервисы.
В реальном приложении сервисов, подобных OrdersProvider
, может быть великое множество. Одни будут заниматься получением данных, другие — их обработкой, реализуя полезную бизнес-логику. Некоторые сервисы могут делать для вью-моделей часть работы по подготовке данных для отображения: преобразование, форматирование и т. п. Все они будут находиться в весьма запутанных взаимоотношениях друг с другом: одни сервисы будут зависеть от других, а те будут зависеть еще от каких-то, образуя разветвленный граф зависимостей.
Композиция сервисов — очень удобный и мощный механизм организации и переиспользования кода. Здесь очень пригождается DI-контейнер, о котором мы говорили в самой первой статье. Когда количество сервисов в вашем приложении начнет исчисляться десятками, это не станет проблемой, потому что DI-контейнер сможет создать для вас любую сущность, попутно разрешив все ее зависимости.
В общем, смысл этого многословного раздела можно уместить в три простые, но настоятельные рекомендации:
- Декомпозируйте функциональность приложения на (микро)сервисы с четко определенной зоной ответственности.
- Активно используйте композицию сервисов для повторного использования кода.
- Используйте DI-контейнер для разрешения зависимостей.
Некоторые сервисы могут заниматься роутингом, помогая вью-моделям осуществлять навигацию на новые экраны приложения.
Обязательно нужен отдельный сервис для роутинга?
Действительно, если действие пользователя, такое как тап в ячейку, прилетает сразу в контроллер, почему бы из этого контроллера не показать новый экран простым вызовом present(_:animated:completion:)
. Я голосую против, потому что такой подход удобнее только на первый взгляд:
- Не всегда переход на другой экран — результат действия пользователя. Например, мы можем захотеть показать новый VC по окончании какого-то асинхронного запроса, который будет происходить во вью-модели.
- Решение о том, какой экран показать, не всегда тривиальное. Это может быть результат работы сложной бизнес-логики, поэтому удобнее запустить показ экрана в том месте, где вся эта бизнес-логика происходит.
- На логику, упомянутую в предыдущем пункте, могут быть написаны тесты. Роутер можно замокать и производить проверки относительно того, какой экран мы собираемся открывать в том или ином случае.
- В целях отладки кода удобно иметь единую точку входа для всей навигации в приложении — роутер. Это позволяет поставить брейкпоинт в нужном месте и проследить, откуда осуществляется тот или иной переход.
Такие образом, будет полезно вынести функциональность роутинга в отдельный сервис или в несколько сервисов. За показ модальных экранов мог бы отвечать, скажем, 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). Нам всего лишь нужно знать ее тип и аргументы, от которых она зависит. Тип вью-модели известен, потому что все вью-контроллеры предоставляют свойствоviewModel
в рамках реализации протоколаIHaveViewModel
(про это была статья про MVVM). Кроме того, у нас имеются необходимые аргументы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
, нужно будет написать для всех контроллеров, которые являются контейнерами для других контроллеров — как для стандартных типа UITabBarController
, так и для кастомных. Группа таких сервисов образует слой роутинга в вашем приложении.
Мне не подходит реализация роутинга. Что делать?
Весь код в этой и предыдущих статьях — очень простой, в нем важна скорее идея, а не реализация. Напишите свою версию MVVM, роутинга, используйте другой DI-контейнер — все это категорически неважно. Важны основополагающие принципы, важны прямоугольники со скругленными углами и стрелочки между ними:
Вьюха (контроллер) должна держать вью-модель сильной ссылкой. Вью-модель должна каким-то образом уведомлять вьюху об изменении своего состояния. Вью-модель должна зависеть от многочисленных сервисов, один из которых — роутер. Используйте роутер для навигации между экранами. Точка навигации — точка большого взрыва, который приводит к созданию нового экрана и всех его зависимостей. Роутер должен уметь создавать пары «вьюха — вью-модель» и все сервисы, от которых они зависят. Для этого он вынужден держать ссылку на DI-контейнер.
Развивайте архитектуру дальше. Например, если у вас длинные цепочки экранов, между которыми передаются полезные данные, попробуйте координатор. Координатор будет держать ссылку на роутер и осуществлять навигацию через него — композиция сервисов в действии. Экспериментируйте, пусть ничто не ограничивает вашу фантазию. Веселитесь и получайте удовольствие от своей работы.
Заключение
Сегодня мы говорили про роль (микро)сервисов в мобильных приложениях на примере роутинга. Сервисы роутинга — мостик между миром MVC и миром MVVM. Они помогают вью-моделям осуществлять навигацию на новые экраны и имеют право напрямую обращаться к DI-контейнеру для создания пар «вьюха — вью-модель».
Реализация PresenterService
, рассмотренная в этой статье, — последний кусочек пазла, необходимый для полноценной работы паттерна MVVM в вашем мобильном приложении. PresenterService
глубоко интегрирован с конкретными реализациями MVVM и DI-контейнера, про которые мы говорили в предыдущих статьях, и только в связке с ними раскрывается весь его потенциал.
Весь код из этой статьи можно скачать в виде Swift Playground.