Год назад мы пересмотрели свою реализацию роутинга в iOS-приложениях hh.ru. Тогда она больше походила на простой слой сборки экранов, чем на роутинг как таковой. Смирившись с этим печальным фактом, мы принялись исследовать тему навигации: пересмотрели много подходов в iOS, примерили каждое в песочнице нашего проекта и даже дошли до Cicerone из мира Android. Взяв лучшее из всех изученных решений, мы переработали всё это дело в собственную реализацию, которая теперь идеально подходит под наши требования к навигации. Недавно мы начали выносить свои наработки в отдельный open-source проект — Nivelir. Эта статья поможет в нём разобраться и покажет, как устроен роутинг в наших проектах. Нам хотелось отделить описание навигации от её выполнения, чтобы единожды объявленный переход можно было переиспользовать и для выполнения навигации, и для объявления другого перехода. Так мы выявили ряд основных требований к нашему роутингу: Увы, стандартными средствами невозможно описать навигацию отдельно от её выполнения. Объекты UIStoryboardSegue не в счёт, они используются только в сторибордах, а передачу данных между экранами в этом случае сложно назвать удобной и безопасной. Плюс ко всему из коробки отсутствует возможность композиции объектов навигации в любом виде. Наши требования к навигации удалось реализовать созданием ёмкого DSL, который может быть интересен и другим проектам. В любом случае, чужой опыт всегда полезен, поэтому в статье много деталей реализации базовой структуры, с неё и начнём обзор. Nivelir базируется на 6 основных сущностях:  Вкратце общая схема выглядит так: навигатору на вход поступает набор действий навигации в виде роута, эти действия выполняются в определённых контейнерах и могут создавать новые с помощью сборщика модуля. При этом создаваемые контейнеры могут быть специализированы по месту использования с помощью декораторов. Кроме этих высокоуровневых сущностей есть также внутренние слои. По мере рассмотрения базы их мы тоже затронем более детально. Любая навигация в Nivelir выполняется в так называемых “контейнерах экрана”, для UIKit можно разделить их на несколько типов: Так как и UITabBarController, и UINavigationController — наследники UIViewController, они также выступают и модальными контейнерами. В коде контейнеру соответствует пустой протокол ScreenContainer: И UIWindow, и UIViewController и, соответственно, все его наследники подписаны под этот протокол. Но чтобы использовать эти контейнеры, их нужно где-то получить — это уже ответственность сборщика модуля. Хорошим тоном считается выделение отдельной сущности для сборки модуля экрана. В ней внедряются внешние зависимости, создаются слои модуля и связываются между собой. Результатом выполнения сборки является контейнер для навигации, в случае UIKit — это чаще всего контроллер. Nivelir определяет протокол Screen для таких сборщиков: Каждый сборщик ассоциирован с типом создаваемого им контейнера. На практике это обычно UIViewController, но возможны модули и c UINavigationController или UITabBarController в качестве контейнера. В методе build(navigator:) необходимо реализовать сборку модуля и вернуть контейнер соответствующего типа. Для собственной навигации экрана следует использовать экземпляр навигатора, который передаётся в параметре navigator. У каждого экрана есть имя, которое определено в свойстве name. Реализация по умолчанию протокола Screen возвращает название своего типа для этого свойства, и нет необходимости определять его самостоятельно. Свойство traits определяет набор отличительных признаков экрана. Например, если в стеке находится два экрана чата с разными собеседниками, то их отличительным признаком будет идентификатор чата. Из свойств name и traits формируется ключ экрана в свойстве key, который определен в расширении протокола Screen: Этот ключ экрана может быть использован для поиска контейнера в иерархии, если он реализует протокол ScreenKeyedContainer: Достаточно подписать контейнер под этот протокол и объявить свойство screenKey, значение которого можно передать из сборщика модуля. Для примера возьмём простой экран чата, который принимает только один внешний параметр — идентификатор чата. Тогда его контроллер будет иметь такой вид: Контроллер соответствует протоколу ScreenKeyedContainer, следовательно его можно будет найти в иерархии. Кроме ключа экрана, инициализатор принимает экземпляр навигатора, который контроллер будет использовать для собственной навигации. Реализация сборщика модуля этого экрана будет выглядеть так: В этом примере было бы удобнее, если бы сборщик соответствовал протоколу Equatable, вместо ручного определения идентификатора чата в свойстве traits. Тогда для поиска контейнера можно было бы использовать сам экземпляр сборщика модуля. Но в реальных проектах в его полях будет много других сущностей, не соответствующих протоколу Equatable. Например, сервисы. В этом случае пришлось бы писать реализацию сравнения вручную, а вынуждать выделять отдельную структуру для каждого экрана с его данными нам тоже не хотелось, поэтому был придуман удобный компромисс в виде свойства traits. Для совсем простых экранов, состоящих только из контейнера, отдельный сборщик может быть избыточным. На этот случай можно подписать сам контроллер под протокол Screen: Тогда реализация по умолчанию протокола Screen вернёт себя в методе build(navigator:), а экземпляр этого контроллера можно будет использовать в действиях навигации. Так как сборщик модуля ассоциирован с типом контейнера, его невозможно использовать по типу протокола Screen. Чтобы не передавать сборщик по конкретному типу, можно воспользоваться структурой AnyScreen. Для удобства в реализации по умолчанию любого сборщика присутствует метод eraseToAnyScreen(), который оборачивает экземпляр в AnyScreen. А чтобы конструкции были максимально компактными, есть алиасы для AnyScreen под тип его контейнера: Таким образом метод, возвращающий сборщик модуля чатов, может выглядеть так: На этом собирать экраны научились, теперь пора разобраться со структурой навигатора. Навигатор служит точкой входа для роутинга. Он выполняет действия навигации, логирует их ошибки и сообщения, ищет контейнеры в иерархии окна. Но всё это он делает не сам, а делегирует трём составляющим:  Каждую из этих сущностей можно заменить на собственную реализацию, но в этом чаще всего нет необходимости: стандартному инициализатору навигатора достаточно передать только экземпляр окна в параметре window. Предполагается, что на каждый экземпляр UIWindow в приложении будет свой навигатор, поэтому лучше создавать его вместе с окном: Действия навигации вызывают метод build(navigator:) сборщика модуля с тем экземпляром навигатора, который их и выполняет. Модуль должен использовать полученный навигатор для собственного роутинга — это необходимо, поскольку навигация часто связана с поиском контейнера, и важно гарантировать, что этот поиск производится в иерархии правильного окна. Большинство методов навигатора наиболее полезны для реализации действий навигации, поэтому сначала разберёмся с ними, а потом посмотрим на примеры использования. Самый масштабируемый слой в Nivelir — это как раз действия навигации, и для них мы выработали ряд свойств. Действия могут: Все действия навигации соответствуют протоколу ScreenAction: Этот протокол ассоциирован с типом контейнера, к которому применяется действие, и с типом возвращаемого значения. Метод perform принимает контейнер и возвращает результат выполнения, вызывая замыкание completion. Чтобы действия могли объединяться в требованиях протокола присутствует метод combine, который возвращает новое действие со стёртым типом в случае успешного объединения. Но объединяться могут не все действия, поэтому реализация по умолчанию этого метода возвращает nil. Нередко в приложениях приходится перестраивать целый стек экранов, например, когда пользователь открывает диплинк. Предположим, что нам нужно выполнить три действия по такому диплинку: Если выполнять эти действия изолированно, то в ситуации, когда в стеке находится больше одного экрана, пользователь увидит двойную анимацию. Сначала возврат на список чатов, затем пуш нового экрана: Намного приятнее объединить эти действия в единый вызов метода setViewControllers, который применит для такой навигации только одну анимацию: Поэтому Nivelir предоставляет только одно действие для изменения стека навигации — ScreenSetStackAction, который инициализируется массивом так называемых модификаторов стека — простых операций, типа pop, push и т.д. Выполнение ScreenSetStackAction состоит из поочередного вызова всех его модификаторов для получения итогового стека, который затем отправляется в метод setViewControllers. А склеивание двух таких действий является простым суммированием модификаторов. Модификаторы стека соответствуют протоколу ScreenStackModifier: Метод perform(stack:navigator:) принимает текущий стек в параметре stack и возвращает его в изменённом виде. На примере модификатора ScreenStackPushModifier его реализация выглядит так: Из коробки доступны 4 реализации модификаторов, их достаточно для любой навигации со стеком: Модификатор ScreenStackPopModifier имеет собственный предикат, который позволяет удалять топовые экраны стека с определенным условием. Можно реализовать свои условия или использовать стандартные предикаты: previous, root и т.д. Все действия навигации условно разделены по типу контейнера, в котором они выполняются: Следует помнить, что модальные действия можно выполнять и с контейнерами UITabBarController и UINavigationController, так как они являются наследниками UIViewController. Кроме базовых действий есть ещё слой Addons с удобными расширениями: показ алертов, выбор фото, открытие URL и т.д. А если и этого мало, всегда можно добавить свои специфичные действия. Любая навигация состоит минимум из двух действий: Поэтому действия навигации не применяются самостоятельно, а входят в состав отдельной сущности для описания навигации — роутов. Они представлены в виде структуры ScreenRoute, которая реализует протокол ScreenThenable: В базовом виде роут можно модифицировать, добавляя действия и другие роуты через соответствующие методы then(:). Все они ограничены типом Current, который является типом текущего контейнера. Набор всех действий роута доступен в свойстве actions, и он завязан на тип корневого контейнера, с которого начинается вся навигация, описываемая этим роутом. Этот корневой контейнер соответствует ассоциированному типу Root. На самом деле действия навигации необязательно добавлять в роут в явном виде, лучше использовать специальные методы из расширений протокола ScreenThenable. Эти методы реализованы для каждого действия и на примере закрытия модального экрана выглядят так: Метод dismiss(animated:) создает экземпляр действия и добавляет его в роут вызовом протокольного метода then(:). Так реализованы простые расширения, а более сложные могут использовать методы других действий или даже комбинировать их. Эти расширения полезны для удобства описания роутов. Не нужно помнить тип действия, достаточно набрать точку в объявлении, и Xcode предложит список всех доступных методов, которые применимы с текущим типом контейнера. К тому же это здорово сокращает код самих объявлений и повышает их читабельность. Описание навигации всегда начинается с корневого роута, у которого равны типы контейнеров Root и Current. Nivelir предоставляет готовые алиасы для таких роутов под тип их контейнера: Чаще всего из этих алиасов используется ScreenWindowRoute, с его экземпляра начинается описание основного роута, например: В этом роуте два действия: поиск топового стека и пуш экрана чата. Первое действие получит экземпляр окна в качестве контейнера, а второе использует то, что найдётся. Остальные алиасы используются в основном для композиции, например этот же роут можно поделить на два: В этом примере описана та же навигация, но с переиспользованием роута pushRoute, который начинается с экземпляра ScreenStackRoute, так как пушить экран можно только в стек. Некоторые действия могут менять тип текущего контейнера Current у роута, неизменным остается только корневой контейнер Root. Например, вызов top(.stack) сделает текущим контейнером UINavigationController, и последующие действия будут выполняться на нём, пока какое-нибудь снова не поменяет контейнер. Это позволяет «чейнить» переиспользованный роут с текущим контейнером, но может доставить хлопот с определением его типа в методах, например: Такую запись нельзя назвать компактной, к тому же тип может поменяться после изменения самого роута. Проектам под iOS с версии 13 и выше повезло, для них доступны Opaque Types, можно использовать протокол ScreenThenable с ключевым словом some и забыть про конкретный тип: Менее удобное, но универсальное решение — сделать роут корневым, добавив вызов метода resolve() в конце: В этом случае роут будет иметь тот же тип, с которым он и создавался, но компилятор уже не позволит запушить второй экран в стек после вызова resolve(), так как текущим контейнером будет UIWindow. Чтобы выполнить навигацию, достаточно скормить роут навигатору: Метод navigate(to:) принимает только роуты, у которых контейнер Root равен UIWindow, то есть ScreenWindowRoute. Навигатор выполнит действия роута последовательно, передав им на вход свой экземпляр окна. Если в нашем примере топовый стек не найдётся в иерархии, то навигатор залогирует ошибку, а чтобы обработать результат самостоятельно, можно определить замыкание в параметре completion. Но если в случае ошибки необходимо выполнить другие действия навигации, то такую логику можно легко описать в самом роуте. Допустим, мы хотим откатиться на модальное отображение чата в случае, если топовый контейнер не является стеком. Тогда можно воспользоваться методом fallback(to:) и передать в него другой роут: Роут presentRoute ищет топовый контейнер в иерархии независимо от его типа и отображает на нём экран модально. Метод fallback(to:) запустит его, если роут pushRoute не найдёт топовый стек. Для локальной навигации не обязательно искать контейнер, он нам уже известен — это наш контроллер. В этом случае можно воспользоваться методом from(:). Допустим, у нас есть свойство container, в котором объявлен обычный контроллер, тогда пример его использования выглядит так: Так как наш контейнер не является стеком, нам сначала нужно получить его свойством stack. Компилятор не позволит запушить экран чата сразу в модальный контейнер. При желании можно объединить описание роута с его выполнением. Для кейсов локальной навигации это может быть удобнее, если нет необходимости переиспользовать роуты: В этом примере мы схитрили и использовали перегрузку метода navigate(from:), в который можно передать контейнер сразу, и он станет корневым для роута в замыкании. В случае диплинков часто требуется показать экран, который уже есть в иерархии. Например, пользователь открыл диплинк чата, и если он уже отображается на табе чатов, то нужно показать его, иначе запушить в топовый стек. Для таких сценариев полезен метод makeVisible(): Метод last(:) добавит в роут действие для поиска последнего контейнера в иерархии. Поиск будет производится с учётом идентификатора чата, так как мы указали его отличительным признаком для ChatScreen. Если контейнер есть в иерархии, метод makeVisible() сделает всё, чтобы он стал топовым: закроет все модальные экраны на нём, рекурсивно переключит табы, сбросит стек до нужного контейнера. В случае отсутствия экрана с искомым чатом в иерархии роут откатится на пуш в топовый стек, а если и его не удалось найти, то применится роут модального отображения. Тут есть одна проблема: в случае пуша в стек экран чата можно использовать как есть, но для модального отображения его лучше обернуть в собственный стек с кнопкой закрытия в навбаре. Возможность такой кастомизации предоставляет отдельный слой — декораторы. Декораторы — это объекты, которые оборачивают сборщик модуля и изменяют создаваемый контейнер. Им соответствует протокол ScreenDecorator: Все декораторы ассоциированы с контейнером, и метод build(screen:navigator:) принимает сборщик модуля с тем же типом контейнера. Но после сборки модуля метод может вернуть другой контейнер, например, сборщик создает UIViewController, а декоратор оборачивает его в UINavigationController. Также декораторы могут иметь данные, которые должны быть в памяти до тех пор, пока жив контейнер. Например, кастомный аниматор модального отображения. Такие данные должны быть определены в свойстве payload, который под капотом ассоциируется с контейнером. Реализация по умолчанию протокола Screen содержит метод decorated(by:), который принимает экземпляр декоратора и возвращает сборщик экрана, обернув его в этот декоратор. Такая реализация позволяет навешивать несколько декораторов подряд на один и тот же сборщик. Метод decorated(by:) полезен только для реализации кастомных декораторов, так как все стандартные добавляют удобные методы в расширение протокола Screen. С ними наш пример роута для модального отображения чата будет выглядеть так: В случае выполнения этого роута контейнер чата получит левую кнопку в навбаре, затем будет обернут в контейнер стека, который в свою очередь будет иметь полноэкранный стиль отображения. Порядок декораторов важен, так как они применяются для разных контейнеров: левая кнопка добавляется для ChatViewController, а модальный стиль отображения меняется для UINavigationController. Nivelir позволяет легко описывать любую навигацию с известными ему контейнерами. Что-то специфичное очень просто реализуется на любом уровне: от действий навигации до логирования.  Кроме базовых возможностей, рассмотренных в статье, есть ещё много «сахара» и удобных хелперов, которые мы продолжаем выносить из нашего проекта и работаем над документацией. Со стабильностью нет никаких проблем, Nivelir уже прошел все проверки: production наших приложений, тонну UI-тестов и еженедельное регрессионное тестирование зоркими QA. В репозитории есть простой демо-проект, который можно использовать как песочницу, для этого достаточно выполнить команды в терминале: В этом проекте реализована локальная навигация и использование некоторых дополнений. Скоро появится пример реализации диплинков с авторизацией. Nivelir можно использовать и для локальной навигации экранов, и в координаторах, и для реализации более совершенных решений для глобального роутинга. Про нашу архитектуру межмодульной навигации и работу с диплинками мы готовим отдельный материал. Будет интересно, stay tuned! Буду рад обратной связи в комментариях. Пока! iOS разработчик