Я продолжаю цикл статей о библиотеке RouteComposer которую мы используем, и сегодня я хочу поговорить о паттерне Координатор. К написанию этой статьи меня побудило обсуждение одной из статей о паттерне Координатор тут на Хабре.
Паттерн Координатор, будучи представленным не так давно, набирает все большую популярность в кругах IOS разработчиков, и, в целом, понятно почему. Потому что средства из коробки, которые предоставляет UIKit представляют собой довольно не универсальное месиво.
Я уже поднимал вопрос раздробленности способов композиции вью контроллеров в стеке, и что бы избежать повторения — вы можете просто прочитать о нем тут.
Давайте будем честны. В какой то момент и Эпол поняла, что поставив вью контроллеры в центр разработки приложения, она не предложила никакого толкового способа их создания или передачи данных между ними, и, поручив решение этой проблемы разработчикам автокомплишена из Xcode, а, может разработчикам UISearchConnroller-а, в какой то момент представила нам storyboards и segues. Потом Эпол сообразила, что приложения состоящие из 2х экранов она пишет только сама, и в следующей итерации предложила возможность разбивать сториборды на несколько составных частей, так как Xcode начинал крэшиться по достижении сторибордом определенных размеров. Segues менялись вместе с этой концепцией, в несколько не очень совместимых между собой итераций. Их поддержка намертво вшита в массивный класс UIViewController
, и, в конечном итоге, мы получили то что получили. Вот это:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "showDetail" {
if let indexPath = tableView.indexPathForSelectedRow {
let object = objects[indexPath.row] as! NSDate
let controller = (segue.destination as! UINavigationController).topViewController as! DetailViewController
controller.detailItem = object
controller.navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem
controller.navigationItem.leftItemsSupplementBackButton = true
}
}
}
Количество форс тайпкастов в данном блоке кода поражает, как и строковые константы в самих сторибордах, для отслеживания которых Xcode не предлагает ровным счетом никаких средств. И малейшее желание что то изменить в процессе навигации позволит вам без всяких усилий скомпилировать проект и он с веселым треском крэшнется в рантайме без малейшего предупреждения со стороны Xcode. Вот такой вот WYSIWYG в конечном итоге получился. Что вы видите, то собственно и получите.
Можно долго спорить о прелестях этих серых стрелочек в сторибордах якобы кому-то показывающих связи между экранами, но, как показала и моя практика, и я намеренно опросил несколько знакомых разработчиков из разных компаний, как только проект разрастался за 5-6 экранов, люди пытались найти более надежное решение и начинали, наконец, держать структуру стека вью контроллеров в голове. А если добавлялась поддержка iPad и другой модели навигации или поддержка пушей — то там вообще было все грустно.
С тех пор было предпринято несколько попыток решения этой задачи, какие то из них вылились в отдельные фреймворки, какие то в отдельные архитектурные паттерны, так как создание вью контроллеров внутри вью контроллера делало этот массивный и неповоротливый кусок кода еще больше.
Вернемся к паттерну Координатор. По понятным причинам вы не найдете его описание в википедии потому что стандартным паттерном программирования/проектирования он не является. Он, скорее, является некой абстракцией, которая предлагает спрятать под капот весь этот “некрасивый” код создания и вставки в стек нового вью контроллера, сохранения ссылок на контейнер контроллеры и проталкивания данных между контроллерами. Наиболее годной статьей описывающей этот процесс я бы назвал статью на raywenderlich.com. Популярным он начинает становиться после конференции NSSpain 2015 года, когда о нем было рассказано широкой публике. Более подробно что было рассказано можно найти тут и тут.
Я же кратко опишу в чем он заключается прежде чем двинуться дальше.
Паттерн Координатор во всех интерпретациях приблизительно вписывается вот в такую картинку:
То есть координатор представляет собой протокол
protocol Coordinator {
func start()
}
И весь “некрасивый” код предполагается спрятать в функции start
. Координатор, кроме того, может иметь ссылки на дочерние координаторы, то есть обладают некоторой возможностью композиции, и, например, можно подменить одну реализацию другой. То есть звучит довольно изящно.
Однако, неизящности начинаются довольно скоро:
- Некоторые реализации предлагают превратить Координатор из некоего порождающего паттерна в нечто более разумное, следящее за стеком вью контроллеров и сделать его делегатом контейнера, например
UINavigationController
-а, чтобы обрабатывать нажатие кнопки Back или свайп назад и удалять дочерний координатор. По естественным причинам, делегатом может быть только один объект, что ограничивает возможность контроля самого контейнера и приводит к тому что эта логика либо ложится на координатор, либо создает необходимость делегировать эту логику дальше еще кому то дальше по списку. - Зачастую логика создания следующего контроллера зависит от бизнес логики. Например, что бы перейти к следующему экрану, пользователь должен быть залогинен в систему. Понятно, это асинхронный процесс, который включает в себя порождение некоторого промежуточного экрана с формой ввода логина, сам процесс входа в систему может закончиться успешно или нет. Что бы избежать превращения Координатора в Массивный Координатор (по аналогии с Массивным Вью Контроллером), нам требуется декомпозиция. То есть, по факту, надо создать Координатор Координатора.
- Еще одной проблемой с которой сталкиваются координаторы, это то что они по сути являются обертками контейнер вью контроллеров, таких как
UINavigationController
,UITabBarController
и так далее. А ссылки на эти контроллеры им кто то должен предоставить. Если с дочерними координаторами еще все более менее понятно, то с начальными координаторами цепочки не все так однозначно. Плюс, при изменении навигации, например для A/B теста, рефакторинг и адаптация таких координаторов выливается в отдельную головную боль. Особенно если изменяется тип контейнера. - Все это усложняется еще больше когда приложение начинает поддерживать внешние события, которые порождают вью контроллеры. Такие как push-уведомления или универсальные ссылки (пользователь кликает на ссылку в письме и продолжает в соответствующем экране приложения). Тут возникают другие неопределенности, на которые у паттерна Координатор точного ответа нет. Нужно точно знать, на каком экране сейчас находится пользователь, для того что бы показать ему следующий экран, затребованный внешним событием.
Простейшим примером является приложение чата состоящие из 3-х экранов — список чатов, собственно чат который пушится в навигейшен контроллер списка чатов и экран настроек показываемый модально. Пользователь может находиться на одном из этих экранов когда получает пуш уведомление и тапает на него. И тут начинается неопределенность, если он в списке чатов, надо запушить чат с этим конкретным пользователем, если он уже в чате, то его нужно переключить, а если он в чате c этим пользователем уже — то ничего не делать и обновить, если пользователь на экране настроек — его, видимо надо закрыть и проделать предыдущие шаги. А может не закрывать и просто показать чат модально над настройками? А если настройки в другом табе, а не модально? Этиif/else
начинают или размазываться по координаторам или уходят в другой Мега-Координатор в виде куска спагетти. Плюсом к этому идут или активные итерации по стеку вью контроллеров и попытка определить где же пользователь в данный момент, или попытка построить некое приложение которое следит за своим состоянием, но это не слишком простая задача, просто исходя из природы самого стека вью контроллеров. - И вишенкой на торте являются глюки UIKit. Банальный пример:
UITabBarController
у которого во втором табеUINavigationController
с каким то ещеUIViewController
-ом. Пользователь в первом табе вызывает некое событие, которое требует переключить таб и запушить в егоUINavigationController
еще один вью контроллер. Это все требуется делать в именно такой последовательности. Если пользователь ни разу не открывал до этого второй таб и уUINavigationController
не был вызванviewDidLoad
методpush
не сработает оставив лишь невнятное сообщение в консоли. То есть координаторы нельзя просто сделать слушателями событий в данном примере, они должны работать в определенной последовательности. А значит должны обладать знаниями друг о друге. А это уже противоречит первому утверждению паттерна Координатор, что координаторы не знают ничего о порождающих координаторах и связаны только с дочерними. А также ограничивает их взаимозаменяемость.
Данный список можно продолжать, но в целом понятно, что паттерн Координатор является довольно ограниченным, плохо масштабируемым решением. Если посмотреть на него без розовых очков, то он является способом декомпозиции части логики, которую обычно пишут внутри массивных UIViewController
-ов, в другой класс. Все попытки сделать его чем то больше, чем некой порождающей фабрикой и привнести туда другую логику, заканчиваются не слишком удачно.
Стоит отметить, что есть библиотеки основанные на этом паттерне, которые с тем или иным успехом позволяют частично нивелировать перечисленные недостатки. Я бы отметил XCoordinator и RxFlow.
Что сделали мы?
Поигравшись в проекте, который достался нам от другой команды для поддержки и развития, с координаторами и их упрощенной “прабабушкой” Router-ами в архитектурном подходе VIPER, мы откатились к подходу который хорошо зарекомендовал себя в предыдущем большом проекте нашей компании. У этого подхода нет какого то названия. Он лежит на поверхности. Когда же у нас было свободное время, он был вычленен в отдельную библиотеку RouteComposer которая вполне заменила нам координаторы и показала себя более гибкой.
В чем заключается этот подход? В том, что бы положиться на стек (дерево) вью контроллеров как он есть. Что бы не создавать лишние сущности, за которыми нужно следить. Не сохранять и не отслеживать состояний.
Давайте посмотрим на сущности UIKit внимательнее и попробуем разобраться что мы имеем в сухом остатке и с чем можно работать:
- Стек вью контроллеров представляет собой некоторое дерево. Есть коренной вью контроллер, у которого есть дочерние вью контроллеры. Вью контроллеры презентованные модально являются частным случаем дочерних вью контроллеров, так как тоже имеют привязку к порожденному вью контроллеру. Это все доступно из коробки.
- Сущности вью контроллеров нужно создавать. У них у всех разные конструкторы, они могут быть созданы с помощью Xib-файлов или Storyboards. У них разные входные параметры. Но они объединены тем что их нужно создавать. А значит тут нам подойдет паттерн фабрика (Factory), который знает как создать нужный вью контроллер. Каждую фабрику легко покрыть исчерпывающими юнит тестами и она не зависима от других.
- Разделим вью контроллеры на 2 класса: 1. Просто вью контроллеры, 2. Контейнер вью контроллеры (Container View Controller). Контейнер вью контроллеры отличаются от обычных тем что могут содержать дочерние вью контроллеры — тоже контейнеры или простые. Такие вью контроллеры доступны из коробки:
UINavigationController
,UITabBarController
и так далее, но могут быть и созданы пользователем. Если абстрагироваться, то можно обнаружить у всех контейнеров следующие свойства: 1. Они имеют список все контроллеров которые они содержат. 2. Один или несколько контроллеров являются видимыми в данный момент. 3. Их можно попросить сделать один из этих контроллеров видимым. Это все что умеют вью контроллеры UIKit. Просто у них для этого разные методы. Но задачи только 3. - Что бы встроить созданный фабрикой вью контроллер, используется метод родительского вью контроллера
UINavigationController.pushViewController(...)
,UITabBarController.selectedViewController = ...
,UIViewController.present(...)
и так далее. Можно заметить, что всегда требуется 2 вью контроллера, один уже в стеке, и один, который нужно встроить в стек. Обернем это в обертку и назовем экшеном (Action). Каждый экшен легко покрыть исчерпывающими юнит тестами и каждый является независимым от других. - Из описанного выше получается, что можно используя подготовленные сущности выстроить цепочку конфигурации Фабрика -> Экшен -> Фабрика -> Экшен -> Фабрика и, выполнив ее, можно построить дерево вью контроллеров любой сложности. Нужно только указать входную точку. Такими входными точками обычно являются или rootViewController принадлежащий UIWindow или текущий вью контроллер, который является самой крайней веткой дерева. То есть такую конфигурацию правильно записать как: Starting ViewController -> Action -> Factory -> … -> Factory.
- Помимо конфигурации потребуется некая сущность которая знает как запустить и построить предоставленную конфигурацию. Назовем ее Рутер (Router). Он не обладает состоянием, он не держит никаких ссылок. У него есть один метод, в который передается конфигурация и он последовательно выполняет шаги конфигурации.
- Добавим роутеру ответсвенности, добавив в цепочку конфигурации классы перехватчики (Interceptors). Перехватчики возможны 3х типов: 1. Запускаемые перед началом навигации. Уберем в них задачи аутентификации пользователя в систему и прочие асинхронные задачи. 2. Выполняемые в момент создания вью контроллера для установки значений. 3. Выполняемые после навигации и выполняющие различные аналитические задачи. Каждая сущность легко покрывается юнит-тестами и не знает как ее будут использовать в конфигурации. У нее есть только одна ответственность и она ее выполняет. То есть конфигурация для сложной навигации может выглядеть [Pre-navigation Task…] -> Starting ViewController -> Action -> (Factory + [ContextTask…]) -> … -> (Factory + [ContextTask…]) -> [Post NavigationTask…]. То есть, все задачи будут выполняться рутером последовательно, выполняя по очереди маленькие, легко читаемые, атомарные сущности.
- Остается последняя задача которая не решается конфигурацией — это состояние приложения в данный момент. Что если нам нужно строить не всю цепочку конфигурации, а только ее часть, потому что пользователь частично прошел ее? На этот вопрос всегда может однозначно ответить дерево вью контроллеров. Потому что если часть цепочки уже построена, она уже находится в дереве. Значит, если каждая фабрика в цепочке сможет отвечать на вопрос, построена она или нет — то роутер сможет понять, какую часть цепочки необходимо достроить. Конечно это не задача фабрики, поэтому вводится еще одна атомарная сущность — поисковик (Finder) и любая конфигурация выглядит следующим образом: [Pre-navigation Task…] -> Starting ViewController -> Action -> (Finder/Factory + [ContextTask…]) -> … -> (Finder/Factory + [ContextTask…]) -> [Post NavigationTask…]. Если роутер начнет читать ее с конца, то один из Finder-ов скажет ему что он уже построен, и роутер от этой точки начнет строить цепочку обратно. Если же не один из них не найдет себя в дереве — значит надо строить всю цепочку от начального контроллера.
- Конфигурация должна быть строго типизирована. Поэтому каждая сущность работает только с одним типом вью контроллеров, одним типом данных и конфигурации полностью ложится на возможности swift работать с associatedtypes. Мы хотим полагаться на компилятор, а не на рантайм. Разработчик может намеренно ослабить типизацию, но не наоборот.
Пример такой конфигурации:
let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory())
.add(LoginInterceptor<UUID>()) // Have to specify the context type till https://bugs.swift.org/browse/SR-8719 is fixed
.add(ProductViewControllerContextTask())
.add(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance))
.using(UINavigationController.push())
.from(NavigationControllerStep())
.using(GeneralActions.presentModally())
.from(GeneralStep.current())
.assemble()
Описанные выше пункты охватывают всю библиотеку и описывают подход. От нас остается лишь предоставить конфигурации цепочек, которые роутер будет выполнять, когда пользователь нажмет кнопку или произойдет внешнее событие. Если это разные типы устройств, например iPhone или iPad, то мы предоставим разные конфигурации перехода, используя полиморфизм. Если у нас A/B тестирование — тоже самое. Нам не нужно задумываться о состоянии приложения в момент начала навигации, нам нужно убедиться что конфигурация написана корректно изначально, и, мы уверены, что роутер так или иначе ее построит.
Описаный подход сложнее чем некая абстракция или паттерн, но мы еще не столкнулись с задачей где его было бы недостаточно. Разумеется, RouteComposer требует определенного изучения и понимания принципов работы. Впрочем, это куда проще, чем изучение основ AutoLayout или RunLoop. Никакой высшей математики.
Библиотека, как и предоставляемая ей реализация роутера, не использует никаких трюков с objective c рантаймом и полностью следует всем концепциям Cocoa Touch, лишь помогая разбить процесс композиции на шаги и выполняет их в заданной последовательности. Библиотека протестирована с версиями iOS с 9 по 12.
Более подробно можно прочесть в предыдущих статьях:
Композиция UIViewController-ов и навигация между ними (и не только) / Хабр
Примеры конфигурации UIViewController-ов используя RouteComposer / Хабр
Спасибо за внимание. С удовольствием отвечу на вопросы в комментариях.
Комментарии (8)
bonyadmitr
04.04.2019 15:32+1Выглядит круто, пользоваться ею вроде не сложно.
Однако поддерживать это стороннему человеку будят тяжко, если потребуется какая-то особенность.
Также сильно возрастает входной порог в проект.
Я бы не рискнул взять это в проект ибо кода достаточно много.
Так же есть вопросы к производительности и кастомным анимационным переходам(не успел еще найти их).
Однако как сделать лучше я тоже не знаю.
spiceginger Автор
04.04.2019 16:19Попробую уточнить каждый пункт.
1. Входной порог в любой проект возрастает, если вы используете что то отличное от Massive View Сontroller.
2. Любая особенность расширяется или новой фабрикой, или фаиндером. Роутер лишь выполнит их по очереди.
3. Кастомные переходы остаются на вас и выполняются по всем тем же правилам, которые прописаны в UIKit. Делаете своего делегата презентации и, вперед, как по учебнику. Библиотека ничего не закрывает и не содержит кастомных оберток. Она лишь выполняет шаги по очереди и умеет решать какие из них нужны, а какие нет. По этой же причине не может быть вопросов к производительности. К тому же, итератор дерева вынесен в отдельный компонент и может быть заменен кастомной реализацией в любой момент не затрагивая основную функциональность.
4. Мы адаптировали ее поверх проекта который к тому времени был уже 3 года в релизе именно по причине полнейшей каши в диплинках. И еще в этот момент надо было сделать A/B тест гамбургер меню против таб бара. Тут, как говорится, попробуйте руками. Можно адаптировать по одному экрану или по экранам которым требуется диплинк, а потом все остальное. У нас до сих пор не все 100% покрыты, просто потому что есть куски которые никто не трогал, потому что и так работает. Но все новые скрины автоматом навешиваются на библиотеку. Сейчас в работе A/B/C тест, где и как разместить поиск и подход показывает себя с лучшей стороны.
5. Какой то совсем нестандарт (я не могу себе представить правда), в самом крайнем случае, вы можете исключить роутер и сделать этот переход руками. Прелесть подхода в том что он работает не зависимо от текущего состояния приложения.
6. Риск оправдан опять же тем что библиотека изымается и вы все снова делаете руками при желании. Только вам надо будет самому принимать решения о том показан пользователю экран и как его показать.
Надеюсь, я смог дать однозначный ответ по всем пунктам.bonyadmitr
04.04.2019 19:33Да, спасибо!
Хорошо, что уже оно в в реальных проектах работает.
Входной порог в любой проект возрастает
Ну использование Rx сильно повышает порог, а стандартный GCD нет. я об этом.
Massive View Сontroller
Это люди так пишут, можно писать нормальный MVC, но не будем этом тут :)
A/B тест гамбургер меню против таб бара
Интересная задача на самом деле. Одна если нет переходов из любого места в них) ибо много чего придется оборачивать, что по идеи у вас и делается.
У вас интересные подходы, возможно когда-то на досуге разберусь.
Все таки хочется найти чего-то более простого, более легковестного.
Чтобы было easy to learn hard to master как UIKit :)spiceginger Автор
04.04.2019 19:37Даже MVVM повысит :) Но, впрочем, это уже совсем другая история.
Удачи в этом нелегком поиске.
Malum consilium est, quod mutari non potest
EugeneRash
Не совсем понятно, куда выносится логика по обработке «сложных» случаев, когда пришел push
и нам например нужно открыть определенный чат, а пользователь в этот момент где-то в глубине settings экранов или в соседнем чате.
Или например еще вариант — приходит тот-же push, а аппка не запущена и нужно сначала запуститься, пройти аутентификацию(провалидировать токены и тому подобное), а уже потом
перекинуть пользователя в соответствующий чат
spiceginger Автор
Она никуда не выносится. Остается в цепочке.
1. Вы сами решаете в начале как вы хотите обрабатывать когда приходит пуш когда пользователь в глубине сеттингов. Если сеттинги в другом табе, роутер переключит таб, потому что он знает что список чатов в другом табе. Если сеттинги показаны модально, зависит от вашей конфигурации, роутер может закрыть сеттинги (простой вариант), показать альтернативно час с этим пользователем модально над сеттингами, цепочка может иметь ветвления или вы можете подменить конфигурацию в момент входа пользователя в сеттинги (менее красиво)
2. С этим вообще нет проблем, LoginInterceptor или отрабатывает или нет. Так что этот вопрос даже не стоит.
Оба примера более менее охвачены в Example app. Первый в ProductConfiguration.swift, 2. Любая навигация закрытая LoginInterceptor. Приложение-пример также поддерживает простейший диплинки. Вы можете проверить интересующие вас сценарии имитируя пуш через мобайл сафари.
EugeneRash
Спасибо, буду пробовать, потому как диплинкинг в текущих проектах уже превратился в боль :)
spiceginger Автор
Не за что. И если будут еще вопросы — не стесняйтесь. Как я написал выше. Там не все сразу очевидно.