Nivelir: Удобный DSL для навигации

vhqt3o_bokjtmtiixctoroghacs.jpeg

Год назад мы пересмотрели свою реализацию роутинга в iOS-приложениях hh.ru. Тогда она больше походила на простой слой сборки экранов, чем на роутинг как таковой. Смирившись с этим печальным фактом, мы принялись исследовать тему навигации: пересмотрели много подходов в iOS, примерили каждое в песочнице нашего проекта и даже дошли до Cicerone из мира Android.

Взяв лучшее из всех изученных решений, мы переработали всё это дело в собственную реализацию, которая теперь идеально подходит под наши требования к навигации. Недавно мы начали выносить свои наработки в отдельный open-source проект — Nivelir. Эта статья поможет в нём разобраться и покажет, как устроен роутинг в наших проектах.


А каким должен быть роутинг?

Нам хотелось отделить описание навигации от её выполнения, чтобы единожды объявленный переход можно было переиспользовать и для выполнения навигации, и для объявления другого перехода. Так мы выявили ряд основных требований к нашему роутингу:


  • Композируемость: сложная навигация должна строиться из простых, понятных и переиспользуемых сущностей.
  • Удобство: не имеет смысла использовать роутинг, синтаксис которого сложнее и объёмнее стандартных средств.
  • Масштабируемость: роутинг должен предоставлять достаточный уровень абстракции для добавления новой функциональности.
  • Универсальность: у нас много старых экранов на разных унаследованных архитектурах (MVC, VIPER), и от роутинга требуется поддержка навигации к любому такому экрану.
  • Строгая типизация: разработчики должны видеть свои ошибки на этапе компиляции, чтобы навигация была полностью безопасной в рантайме.

Увы, стандартными средствами невозможно описать навигацию отдельно от её выполнения. Объекты UIStoryboardSegue не в счёт, они используются только в сторибордах, а передачу данных между экранами в этом случае сложно назвать удобной и безопасной. Плюс ко всему из коробки отсутствует возможность композиции объектов навигации в любом виде.

Наши требования к навигации удалось реализовать созданием ёмкого DSL, который может быть интересен и другим проектам. В любом случае, чужой опыт всегда полезен, поэтому в статье много деталей реализации базовой структуры, с неё и начнём обзор.


Базовая структура

Nivelir базируется на 6 основных сущностях:


  • Контейнер экрана: любой объект, способный выполнять навигацию, простейший пример — UIViewController.
  • Сборщик модуля: абстракция для сборки модуля экрана, будь то MVVM, VIPER или MVC.
  • Навигатор: точка входа для выполнения действий навигации.
  • Действия навигации: простейшие операции, которые используются самой навигацией или другими действиями.
  • Роуты: описание навигации в виде набора действий.
  • Декораторы: обёртки над сборщиком модуля, изменяющие создаваемый им контейнер.

image-loader.svg

Вкратце общая схема выглядит так: навигатору на вход поступает набор действий навигации в виде роута, эти действия выполняются в определённых контейнерах и могут создавать новые с помощью сборщика модуля. При этом создаваемые контейнеры могут быть специализированы по месту использования с помощью декораторов.

Кроме этих высокоуровневых сущностей есть также внутренние слои. По мере рассмотрения базы их мы тоже затронем более детально.


Контейнер экрана

Любая навигация в Nivelir выполняется в так называемых «контейнерах экрана», для UIKit можно разделить их на несколько типов:


  • Оконный контейнер — это экземпляр UIWindow. То есть у любого экрана есть окно, в котором он отображается.
  • Контейнер табов — экземпляр UITabBarController.
  • Контейнер стека — экземпляр UINavigationController.
  • Модальный контейнер — любой экземпляр UIViewController, который может быть использован для модального отображения другого экрана.

Так как и UITabBarController, и UINavigationController — наследники UIViewController, они также выступают и модальными контейнерами.

В коде контейнеру соответствует пустой протокол ScreenContainer:

protocol ScreenContainer { }

И UIWindow, и UIViewController и, соответственно, все его наследники подписаны под этот протокол. Но чтобы использовать эти контейнеры, их нужно где-то получить — это уже ответственность сборщика модуля.


Сборщик модуля

Хорошим тоном считается выделение отдельной сущности для сборки модуля экрана. В ней внедряются внешние зависимости, создаются слои модуля и связываются между собой. Результатом выполнения сборки является контейнер для навигации, в случае UIKit — это чаще всего контроллер.

Nivelir определяет протокол Screen для таких сборщиков:

protocol Screen {
    associatedtype Container: ScreenContainer

    var name: String { get }
    var traits: Set { get }

    func build(navigator: ScreenNavigator) -> Container
}

Каждый сборщик ассоциирован с типом создаваемого им контейнера. На практике это обычно UIViewController, но возможны модули и c UINavigationController или UITabBarController в качестве контейнера.

В методе build(navigator:) необходимо реализовать сборку модуля и вернуть контейнер соответствующего типа. Для собственной навигации экрана следует использовать экземпляр навигатора, который передаётся в параметре navigator.

У каждого экрана есть имя, которое определено в свойстве name. Реализация по умолчанию протокола Screen возвращает название своего типа для этого свойства, и нет необходимости определять его самостоятельно.

Свойство traits определяет набор отличительных признаков экрана. Например, если в стеке находится два экрана чата с разными собеседниками, то их отличительным признаком будет идентификатор чата.


Зачем экрану имя и отличительные признаки?

Из свойств name и traits формируется ключ экрана в свойстве key, который определен в расширении протокола Screen:

extension Screen {

    var key: ScreenKey {
        ScreenKey(name: name, traits: traits)
    }
}

Этот ключ экрана может быть использован для поиска контейнера в иерархии, если он реализует протокол ScreenKeyedContainer:

protocol ScreenKeyedContainer: ScreenContainer {
    var screenKey: ScreenKey { get }
}

Достаточно подписать контейнер под этот протокол и объявить свойство screenKey, значение которого можно передать из сборщика модуля.


Как выглядит реализация?

Для примера возьмём простой экран чата, который принимает только один внешний параметр — идентификатор чата. Тогда его контроллер будет иметь такой вид:

class ChatViewController: UIViewController, ScreenKeyedContainer {

    let chatID: Int

    let screenKey: ScreenKey
    let screenNavigator: ScreenNavigator

    init(
        chatID: Int,
        screenKey: ScreenKey,
        screenNavigator: ScreenNavigator
    ) {
        self.chatID = chatID
        self.screenKey = screenKey
        self.screenNavigator = screenNavigator

        super.init(nibName: nil, bundle: nil)
    }

    // ...
}

Контроллер соответствует протоколу ScreenKeyedContainer, следовательно его можно будет найти в иерархии. Кроме ключа экрана, инициализатор принимает экземпляр навигатора, который контроллер будет использовать для собственной навигации.

Реализация сборщика модуля этого экрана будет выглядеть так:

struct ChatScreen: Screen {

    let chatID: Int

    var traits: Set {
        [chatID]
    }

    func build(navigator: ScreenNavigator) -> UIViewController {
        ChatViewController(
            chatID: chatID,
            screenKey: key,
            screenNavigator: navigator
        )
    }
}

В этом примере было бы удобнее, если бы сборщик соответствовал протоколу Equatable, вместо ручного определения идентификатора чата в свойстве traits. Тогда для поиска контейнера можно было бы использовать сам экземпляр сборщика модуля. Но в реальных проектах в его полях будет много других сущностей, не соответствующих протоколу Equatable. Например, сервисы.

В этом случае пришлось бы писать реализацию сравнения вручную, а вынуждать выделять отдельную структуру для каждого экрана с его данными нам тоже не хотелось, поэтому был придуман удобный компромисс в виде свойства traits.


А если сборщик не нужен?

Для совсем простых экранов, состоящих только из контейнера, отдельный сборщик может быть избыточным. На этот случай можно подписать сам контроллер под протокол Screen:

class SimpleViewController: UIViewController, Screen { ... }

Тогда реализация по умолчанию протокола Screen вернёт себя в методе build(navigator:), а экземпляр этого контроллера можно будет использовать в действиях навигации.


Как стереть тип сборщика?

Так как сборщик модуля ассоциирован с типом контейнера, его невозможно использовать по типу протокола Screen. Чтобы не передавать сборщик по конкретному типу, можно воспользоваться структурой AnyScreen.

Для удобства в реализации по умолчанию любого сборщика присутствует метод eraseToAnyScreen(), который оборачивает экземпляр в AnyScreen. А чтобы конструкции были максимально компактными, есть алиасы для AnyScreen под тип его контейнера:

typealias AnyModalScreen = AnyScreen
typealias AnyStackScreen = AnyScreen
typealias AnyTabsScreen = AnyScreen

Таким образом метод, возвращающий сборщик модуля чатов, может выглядеть так:

func chatScreen(chatID: Int) -> AnyModalScreen {
    ChatScreen(chatID: chatID).eraseToAnyScreen()
}

На этом собирать экраны научились, теперь пора разобраться со структурой навигатора.


Навигатор

Навигатор служит точкой входа для роутинга. Он выполняет действия навигации, логирует их ошибки и сообщения, ищет контейнеры в иерархии окна. Но всё это он делает не сам, а делегирует трём составляющим:

image-loader.svg


  • ScreenWindowProvider — предоставляет окно для навигации.
  • ScreenIterator — итерирует в иерархии для поиска контейнера.
  • ScreenLogger — логирует события навигации.

Каждую из этих сущностей можно заменить на собственную реализацию, но в этом чаще всего нет необходимости: стандартному инициализатору навигатора достаточно передать только экземпляр окна в параметре window.

Предполагается, что на каждый экземпляр UIWindow в приложении будет свой навигатор, поэтому лучше создавать его вместе с окном:

class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    var navigator: ScreenNavigator?

    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene else {
            return
        }

        let window = UIWindow(windowScene: windowScene)
        let navigator = ScreenNavigator(window: window)

        // ...
    }
}

Действия навигации вызывают метод build(navigator:) сборщика модуля с тем экземпляром навигатора, который их и выполняет. Модуль должен использовать полученный навигатор для собственного роутинга — это необходимо, поскольку навигация часто связана с поиском контейнера, и важно гарантировать, что этот поиск производится в иерархии правильного окна.

Большинство методов навигатора наиболее полезны для реализации действий навигации, поэтому сначала разберёмся с ними, а потом посмотрим на примеры использования.


Действия навигации

Самый масштабируемый слой в Nivelir — это как раз действия навигации, и для них мы выработали ряд свойств. Действия могут:


  • Выполнять переходы, например, present, push и т.д.
  • Вообще не менять иерархию экранов, например, искать контейнер.
  • Выполнять набор других действий.
  • Объединяться с другими действиями.

Все действия навигации соответствуют протоколу ScreenAction:

protocol ScreenAction {
    associatedtype Container: ScreenContainer
    associatedtype Output

    func perform(
        container: Container,
        navigator: ScreenNavigator,
        completion: @escaping (Result) -> Void
    )

    func combine(
        with other: Action
    ) -> AnyScreenAction?
}

Этот протокол ассоциирован с типом контейнера, к которому применяется действие, и с типом возвращаемого значения. Метод perform принимает контейнер и возвращает результат выполнения, вызывая замыкание completion.

Чтобы действия могли объединяться в требованиях протокола присутствует метод combine, который возвращает новое действие со стёртым типом в случае успешного объединения. Но объединяться могут не все действия, поэтому реализация по умолчанию этого метода возвращает nil.


Зачем объединять действия?

Нередко в приложениях приходится перестраивать целый стек экранов, например, когда пользователь открывает диплинк. Предположим, что нам нужно выполнить три действия по такому диплинку:


  • Найти стек в табе чатов
  • Сбросить стек до списка чатов
  • Запушить в стек экран чата

Если выполнять эти действия изолированно, то в ситуации, когда в стеке находится больше одного экрана, пользователь увидит двойную анимацию. Сначала возврат на список чатов, затем пуш нового экрана:


image-loader.svg

Намного приятнее объединить эти действия в единый вызов метода setViewControllers, который применит для такой навигации только одну анимацию:


image-loader.svg

Поэтому Nivelir предоставляет только одно действие для изменения стека навигации — ScreenSetStackAction, который инициализируется массивом так называемых модификаторов стека — простых операций, типа pop, push и т.д.

Выполнение ScreenSetStackAction состоит из поочередного вызова всех его модификаторов для получения итогового стека, который затем отправляется в метод setViewControllers. А склеивание двух таких действий является простым суммированием модификаторов.


Как выглядят модификаторы стека?

Модификаторы стека соответствуют протоколу ScreenStackModifier:

protocol ScreenStackModifier {
    func perform(
        stack: [UIViewController],
        navigator: ScreenNavigator
    ) throws -> [UIViewController]
}

Метод perform(stack:navigator:) принимает текущий стек в параметре stack и возвращает его в изменённом виде. На примере модификатора ScreenStackPushModifier его реализация выглядит так:

func perform(
    in stack: [UIViewController],
    navigator: ScreenNavigator
) -> [UIViewController] {
    stack + [screen.build(navigator: navigator)]
}

Из коробки доступны 4 реализации модификаторов, их достаточно для любой навигации со стеком:


  • ScreenStackPushModifier: добавляет новый топовый экран в стек.
  • ScreenStackClearModifier: удаляет все экраны из стека.
  • ScreenStackReplaceModifier: заменяет топовый экран стека.
  • ScreenStackPopModifier: удаляет топовые экраны из стека.

Модификатор ScreenStackPopModifier имеет собственный предикат, который позволяет удалять топовые экраны стека с определенным условием. Можно реализовать свои условия или использовать стандартные предикаты: previous, root и т.д.


Какие ещё есть действия?

Все действия навигации условно разделены по типу контейнера, в котором они выполняются:


  • Действия окна: применяются к контейнеру UIWindow.
  • Модальные действия: применяются к контейнеру UIViewController.
  • Действия табов: применяются к контейнеру UITabBarController.
  • Действия стека: применяются к контейнеру UINavigationController.
  • Общие действия: применимы к любому контейнеру.

Следует помнить, что модальные действия можно выполнять и с контейнерами UITabBarController и UINavigationController, так как они являются наследниками UIViewController.

Кроме базовых действий есть ещё слой Addons с удобными расширениями: показ алертов, выбор фото, открытие URL и т.д. А если и этого мало, всегда можно добавить свои специфичные действия.


Роуты

Любая навигация состоит минимум из двух действий:


  • Получение контейнера: заранее известного или найденного в иерархии.
  • Выполнение перехода в этом контейнере: present, push и т.д.

Поэтому действия навигации не применяются самостоятельно, а входят в состав отдельной сущности для описания навигации — роутов. Они представлены в виде структуры ScreenRoute, которая реализует протокол ScreenThenable:

protocol ScreenThenable {
    associatedtype Root: ScreenContainer
    associatedtype Current: ScreenContainer

    var actions: [AnyScreenAction] { get }

    func then(
        _ action: Action
    ) -> Self where Action.Container == Current

    func then(
        _ other: Route
    ) -> Self where Route.Root == Current
}

В базовом виде роут можно модифицировать, добавляя действия и другие роуты через соответствующие методы then(:). Все они ограничены типом Current, который является типом текущего контейнера.

Набор всех действий роута доступен в свойстве actions, и он завязан на тип корневого контейнера, с которого начинается вся навигация, описываемая этим роутом. Этот корневой контейнер соответствует ассоциированному типу Root.


Как лучше добавлять действия в роут?

На самом деле действия навигации необязательно добавлять в роут в явном виде, лучше использовать специальные методы из расширений протокола ScreenThenable. Эти методы реализованы для каждого действия и на примере закрытия модального экрана выглядят так:

extension ScreenThenable where Current: UIViewController {

    func dismiss(animated: Bool = true) -> Self {
        then(ScreenDismissAction(animated: animated))
    }
}

Метод dismiss(animated:) создает экземпляр действия и добавляет его в роут вызовом протокольного метода then(:). Так реализованы простые расширения, а более сложные могут использовать методы других действий или даже комбинировать их.

Эти расширения полезны для удобства описания роутов. Не нужно помнить тип действия, достаточно набрать точку в объявлении, и Xcode предложит список всех доступных методов, которые применимы с текущим типом контейнера. К тому же это здорово сокращает код самих объявлений и повышает их читабельность.


Как объявить роут?

Описание навигации всегда начинается с корневого роута, у которого равны типы контейнеров Root и Current. Nivelir предоставляет готовые алиасы для таких роутов под тип их контейнера:

typealias ScreenModalRoute = ScreenRootRoute
typealias ScreenStackRoute = ScreenRootRoute
typealias ScreenTabsRoute = ScreenRootRoute
typealias ScreenWindowRoute = ScreenRootRoute

Чаще всего из этих алиасов используется ScreenWindowRoute, с его экземпляра начинается описание основного роута, например:

let route = ScreenWindowRoute()
    .top(.stack)
    .push(ChatScreen(chatID: 123))

В этом роуте два действия: поиск топового стека и пуш экрана чата. Первое действие получит экземпляр окна в качестве контейнера, а второе использует то, что найдётся.

Остальные алиасы используются в основном для композиции, например этот же роут можно поделить на два:

let pushRoute = ScreenStackRoute()
    .push(ChatScreen(chatID: 123))

let route = ScreenWindowRoute()
    .top(.stack)
    .then(pushRoute)

В этом примере описана та же навигация, но с переиспользованием роута pushRoute, который начинается с экземпляра ScreenStackRoute, так как пушить экран можно только в стек.


Каким будет тип роута?

Некоторые действия могут менять тип текущего контейнера Current у роута, неизменным остается только корневой контейнер Root. Например, вызов top(.stack) сделает текущим контейнером UINavigationController, и последующие действия будут выполняться на нём, пока какое-нибудь снова не поменяет контейнер.

Это позволяет «чейнить» переиспользованный роут с текущим контейнером, но может доставить хлопот с определением его типа в методах, например:

func chatRoute(
    chatID: Int
) -> ScreenRoute {
    ScreenWindowRoute()
        .top(.stack)
        .push(ChatScreen(chatID: 123))
}

Такую запись нельзя назвать компактной, к тому же тип может поменяться после изменения самого роута.

Проектам под iOS с версии 13 и выше повезло, для них доступны Opaque Types, можно использовать протокол ScreenThenable с ключевым словом some и забыть про конкретный тип:

func chatRoute(chatID: Int) -> some ScreenThenable {
    ScreenWindowRoute()
        .top(.stack)
        .push(ChatScreen(chatID: 123))
}

Менее удобное, но универсальное решение — сделать роут корневым, добавив вызов метода resolve() в конце:

func chatRoute(chatID: Int) -> ScreenWindowRoute {
    ScreenWindowRoute()
        .top(.stack)
        .push(ChatScreen(chatID: 123))
        .resolve()
}

В этом случае роут будет иметь тот же тип, с которым он и создавался, но компилятор уже не позволит запушить второй экран в стек после вызова resolve(), так как текущим контейнером будет UIWindow.


Как выполнить роут?

Чтобы выполнить навигацию, достаточно скормить роут навигатору:

ScreenWindowRoute()
    .top(.stack)
    .push(ChatScreen(chatID: 123))

navigator.navigate(to: route)

Метод navigate(to:) принимает только роуты, у которых контейнер Root равен UIWindow, то есть ScreenWindowRoute. Навигатор выполнит действия роута последовательно, передав им на вход свой экземпляр окна.

Если в нашем примере топовый стек не найдётся в иерархии, то навигатор залогирует ошибку, а чтобы обработать результат самостоятельно, можно определить замыкание в параметре completion. Но если в случае ошибки необходимо выполнить другие действия навигации, то такую логику можно легко описать в самом роуте.


Как обработать ошибки в роуте?

Допустим, мы хотим откатиться на модальное отображение чата в случае, если топовый контейнер не является стеком. Тогда можно воспользоваться методом fallback(to:) и передать в него другой роут:

let screen = ChatScreen(chatID: 123)

let presentRoute = ScreenWindowRoute()
    .top(.container)
    .present(screen)

let pushRoute = ScreenWindowRoute()
    .top(.stack)
    .push(screen)
    .fallback(to: presentRoute)

Роут presentRoute ищет топовый контейнер в иерархии независимо от его типа и отображает на нём экран модально. Метод fallback(to:) запустит его, если роут pushRoute не найдёт топовый стек.


А если контейнер уже известен?

Для локальной навигации не обязательно искать контейнер, он нам уже известен — это наш контроллер. В этом случае можно воспользоваться методом from(:). Допустим, у нас есть свойство container, в котором объявлен обычный контроллер, тогда пример его использования выглядит так:

let route = ScreenWindowRoute()
    .from(container)
    .stack
    .push(ChatScreen(chatID: 123))

navigator.navigate(to: route)

Так как наш контейнер не является стеком, нам сначала нужно получить его свойством stack. Компилятор не позволит запушить экран чата сразу в модальный контейнер.

При желании можно объединить описание роута с его выполнением. Для кейсов локальной навигации это может быть удобнее, если нет необходимости переиспользовать роуты:

navigator.navigate(from: container) { route in
    route.stack.push(ChatScreen(chatID: 123))
}

В этом примере мы схитрили и использовали перегрузку метода navigate(from:), в который можно передать контейнер сразу, и он станет корневым для роута в замыкании.


А если экран уже есть в иерархии?

В случае диплинков часто требуется показать экран, который уже есть в иерархии. Например, пользователь открыл диплинк чата, и если он уже отображается на табе чатов, то нужно показать его, иначе запушить в топовый стек. Для таких сценариев полезен метод makeVisible():

let screen = ChatScreen(chatID: 123)

let pushRoute = ScreenWindowRoute()
    .top(.stack)
    .push(screen)

let presentRoute = ScreenWindowRoute()
    .top(.container)
    .present(screen)

let route = ScreenWindowRoute()
    .last(.container(of: screen))
    .makeVisible()
    .fallback(to: pushRoute)
    .fallback(to: presentRoute)

Метод last(:) добавит в роут действие для поиска последнего контейнера в иерархии. Поиск будет производится с учётом идентификатора чата, так как мы указали его отличительным признаком для ChatScreen.

Если контейнер есть в иерархии, метод makeVisible() сделает всё, чтобы он стал топовым: закроет все модальные экраны на нём, рекурсивно переключит табы, сбросит стек до нужного контейнера. В случае отсутствия экрана с искомым чатом в иерархии роут откатится на пуш в топовый стек, а если и его не удалось найти, то применится роут модального отображения.

Тут есть одна проблема: в случае пуша в стек экран чата можно использовать как есть, но для модального отображения его лучше обернуть в собственный стек с кнопкой закрытия в навбаре. Возможность такой кастомизации предоставляет отдельный слой — декораторы.


Декораторы

Декораторы — это объекты, которые оборачивают сборщик модуля и изменяют создаваемый контейнер. Им соответствует протокол ScreenDecorator:

protocol ScreenDecorator {
    associatedtype Container: ScreenContainer
    associatedtype Output: ScreenPayloadedContainer

    var payload: Any? { get }

    func build(
        screen: Wrapped,
        navigator: ScreenNavigator
    ) -> Output where Wrapped.Container == Container
}

Все декораторы ассоциированы с контейнером, и метод build(screen:navigator:) принимает сборщик модуля с тем же типом контейнера. Но после сборки модуля метод может вернуть другой контейнер, например, сборщик создает UIViewController, а декоратор оборачивает его в UINavigationController.

Также декораторы могут иметь данные, которые должны быть в памяти до тех пор, пока жив контейнер. Например, кастомный аниматор модального отображения. Такие данные должны быть определены в свойстве payload, который под капотом ассоциируется с контейнером.


Как декорировать?

Реализация по умолчанию протокола Screen содержит метод decorated(by:), который принимает экземпляр декоратора и возвращает сборщик экрана, обернув его в этот декоратор. Такая реализация позволяет навешивать несколько декораторов подряд на один и тот же сборщик.

Метод decorated(by:) полезен только для реализации кастомных декораторов, так как все стандартные добавляют удобные методы в расширение протокола Screen. С ними наш пример роута для модального отображения чата будет выглядеть так:

let presentRoute = ScreenWindowRoute()
    .top(.container)
    .present(
        screen
            .withLeftBarButton(closeBarButton)
            .withStackContainer()
            .withModalPresentationStyle(.fullScreen)
    )

В случае выполнения этого роута контейнер чата получит левую кнопку в навбаре, затем будет обернут в контейнер стека, который в свою очередь будет иметь полноэкранный стиль отображения. Порядок декораторов важен, так как они применяются для разных контейнеров: левая кнопка добавляется для ChatViewController, а модальный стиль отображения меняется для UINavigationController.


Подводя итог

Nivelir позволяет легко описывать любую навигацию с известными ему контейнерами. Что-то специфичное очень просто реализуется на любом уровне: от действий навигации до логирования.

Кроме базовых возможностей, рассмотренных в статье, есть ещё много «сахара» и удобных хелперов, которые мы продолжаем выносить из нашего проекта и работаем над документацией.


Как стабильность?

Со стабильностью нет никаких проблем, Nivelir уже прошел все проверки: production наших приложений, тонну UI-тестов и еженедельное регрессионное тестирование зоркими QA.


Как поиграть?

В репозитории есть простой демо-проект, который можно использовать как песочницу, для этого достаточно выполнить команды в терминале:

git clone https://github.com/hhru/Nivelir.git
cd Nivelir/Example
pod install
open NivelirExample.xcworkspace

В этом проекте реализована локальная навигация и использование некоторых дополнений. Скоро появится пример реализации диплинков с авторизацией.


Что ещё?

Nivelir можно использовать и для локальной навигации экранов, и в координаторах, и для реализации более совершенных решений для глобального роутинга. Про нашу архитектуру межмодульной навигации и работу с диплинками мы готовим отдельный материал. Будет интересно, stay tuned!


На этом всё

Буду рад обратной связи в комментариях. Пока!

© Habrahabr.ru