Роутинг для iOS: универсальная навигация без переписывания приложения

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

Однако, как только в приложении появляется переход на какой-то экран по push-уведомлению или ссылке, всё становится несколько сложнее. Сразу появляется масса вопросов:

  • что делать с view-контроллером, который сейчас находится на экране?
  • как переключить контекст (например, активную вкладку в UITabBarController)?
  • есть ли в текущем стеке навигации нужный экран?
  • когда следует игнорировать навигацию?




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

Наша проблема


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

В Badoo существовал подобный компонент. Он работал похожим образом с довольно старой библиотекой от Facebook, которую сейчас уже не найти в его публичном репозитории. Навигация была основана на URL, ассоциированных с экранами приложения. В основном вся логика содержалась в одном классе, который был завязан на наличие tab bar и на некоторые другие функции, специфичные для Badoo. Сложность и связность этого компонента были настолько высокими, что решение задач, которые требовали изменения логики навигации, могло занимать в разы больше времени, чем было запланировано. Тестируемость такого класса тоже вызывала большие вопросы.

Этот компонент создавался, когда у нас было только одно приложение. Мы не могли представить, что в дальнейшем будем развивать несколько продуктов, довольно сильно отличающихся друг от друга (Bumble, Lumen и другие). По этой причине, навигатор из нашего самого зрелого приложения — Badoo — было невозможно использовать в других продуктах и каждой команде приходилось придумывать что-то новое.

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

Peализуем универсальный роутер


Главных задач, решаемых глобальным навигатором, не так много:

  1. Найти текущий активный экран.
  2. Каким-то образом сравнить тип активного экрана и его содержимое с тем, что необходимо показать.
  3. Нужным образом выполнить переход (последовательность переходов).

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

1. Поиск активного экрана


Первая задача кажется довольно простой: нужно лишь пройтись по всей иерархии экранов и найти верхний UIViewController.



Интерфейс нашего объекта может выглядеть как-то так:

protocol TopViewControllerProvider {
    var topViewController: UIViewController? { get }
}

Однако непонятно, как определять корневой элемент иерархии и что делать с экранами-контейнерами вроде UIPageViewController и контейнерами, специфичными для конкретного приложения.

Самый простой вариант определять корневой элемент — брать корневой контроллер у активного экрана:

UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController

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

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

extension UITabBarController: TopViewControllerProvider {
    var topViewController: UIViewController? {
        return self.selectedViewController
    }
}

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

2. Текущий контекст


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

Но какие типы должны иметь свойства объекта? Наша конечная цель — сравнить контекст с тем, что нужно показать, поэтому они должны реализовывать протокол Equatable. Это можно реализовать через generic-типы:

struct ViewControllerContext<ScreenType: Equatable, InfoType: Equatable>: Equatable {
    let screenType: ScreenType
    let info: InfoType?
}

Однако из-за специфики Swift это накладывает определённые ограничения на использование данного типа. Во избежание проблем эта структура в наших приложениях имеет несколько другой вид:

protocol ViewControllerContextInfo {
    func isEqual(to info: ViewControllerContextInfo?) -> Bool
}

struct ViewControllerContext: Equatable {
    public let screenType: String
    public let info: ViewControllerContextInfo?
}

Ещё один вариант — воспользоваться новой возможностью Swift, Opaque Types, но она доступна только начиная с iOS 13, что для многих продуктов всё ещё неприемлемо.

Реализация сравнения контекстов довольно очевидна. Чтобы не писать функцию isEqual для типов, уже реализующих Equatable, можно сделать нехитрый трюк, на этот раз используя достоинства Swift:

extension ViewControllerContextInfo where Self: Equatable {
    func isEqual(to info: ViewControllerContextInfo?) -> Bool {
        guard let info = info as? Self else { return false }
        return self == info
    }
}

Отлично, у нас есть объект для сравнения. Но как можно его ассоциировать с UIViewController? Один из способов — использовать ассоциированные объекты, полезную в некоторых случаях функцию языка Objective C. Но во-первых, это не очень явно, а во-вторых, обычно мы хотим сравнивать контекст только некоторых экранов приложения. Поэтому хорошими идеями выглядят создание протокола:

protocol ViewControllerContextHolder {
    var currentContext: ViewControllerContext? { get }
}


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

3. Выполнение перехода


Посмотрим, что у нас уже есть. Возможность в любой момент достать информацию об активном экране в виде определённой структуры данных. Информация, полученная извне через открытый URL, push-уведомление или другой способ инициации навигации, которая может быть преобразована в структуру такого же типа и служить чем-то вроде намерения навигации. Если верхний экран уже показывает нужную информацию, то можно просто проигнорировать навигацию или обновить содержимое экрана.



Но что насчёт самого перехода?

Логично сделать компонент (назовём его роутером), который будет принимать на вход то, что нужно показать, сравнивать с тем, что уже показано, и выполнять переход или последовательность переходов. Также роутер может содержать общую логику для обработки и валидации информации и состояния приложения. Главное — не стоит включать в этот компонент логику, специфичную для какого-то домена или функции приложения. Если придерживаться этого правила, он останется переиспользуемым для разных приложений и лёгким в поддержке.

Базовая декларация интерфейса подобного протокола выглядит так:

protocol ViewControllerContextRouterProtocol {
    func navigateToContext(_ context: ViewControllerContext, animated: Bool)
}

Можно обобщить функцию выше, передавая последовательность контекстов. Это не окажет существенного влияния на реализацию.

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

protocol ViewControllersByContextFactory {
    func viewController(for context: ViewControllerContext) -> UIViewController?
}

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


enum NavigationType {
    case modal
    case navigationStack
    case rootScreen
}

От типа экрана зависит способ его отображения. Если это блокирующее уведомление, тогда его нужно показывать модально. Другой экран, возможно, нужно добавить в существующий стек навигации через UINavigationController.

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

protocol ViewControllerNavigationTypeProvider {
    func navigationType(for context: ViewControllerContext) -> NavigationType
}

А что, если мы захотим ввести новый тип навигации в одном из приложений? Нужно добавлять новый вариант в enum, и все остальные приложения узнают об этом? Вероятно, в некоторых случаях это именно то, чего мы добиваемся, но если придерживаться принципа open-closed, то для большей гибкости можно ввести протокол объекта, который может выполнять переходы:

protocol ViewControllerContextTransition {
    func navigate(from source: UIViewController?,
                  to destination: UIViewController,
                  animated: Bool)
}

Тогда ViewControllerNavigationTypeProvider превратится в это:

protocol ViewControllerContextTransitionProvider {
    func transition(for context: ViewControllerContext) -> ViewControllerContextTransition
}

Теперь мы не ограничены фиксированным набором типов показа экрана и можем расширять возможности навигации без изменений в самом роутере.

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

Как решить эту задачу? Больше абстракций!

protocol ViewControllerContextSwitcher {
    func canSwitch(to context: ViewControllerContext) -> Bool
    func switchContext(to context: ViewControllerContext, animated: Bool)
}

В случае со вкладками данный протокол может быть реализован компонентом, знающим, что содержится внутри UITabBarViewController, умеющим сопоставлять ViewControllerContext с конкретной вкладкой и переключать табы.



Набор подобных объектов можно передать роутеру как зависимость.

Если подытожить, алгоритм обработки контекста будет выглядеть так:

func navigateToContext(_ context: ViewControllerContext, animated: Bool) {
    let topViewController = self.topViewControllerProvider.topViewController
    if let contextHolder = topViewController as? ViewControllerContextHolder, contextHolder.currentContext == context {
        return
    }
    if let switcher = self.contextSwitchers.first(where: { $0.canSwitch(to: context) }) {
        switcher.switchContext(to: context, animated: animated)
        return
    }
    guard let viewController = self.viewControllersFactory.viewController(for: context) else { return }
    let navigation = self.transitionProvider.navigation(for: context)
    navigation.navigate(from: self.topViewControllerProvider.topViewController,
                        to: viewController,
                        animated: true)
}


Схему зависимостей роутера удобно представить в виде UML-диаграммы:



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

Преимущества и недостатки


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

Из преимуществ можно отметить:

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

Недостатки отчасти являются следствиями преимуществ.

  • Контроллеры должны знать, какую информацию они показывают. Eсли рассматривать архитектуру приложения, UIViewController стоит относить к слою отображения, а в этом слое не должна храниться бизнес-логика. Структура данных, содержащая контекст навигации, должна быть внедрена туда из слоя бизнес-логики, но тем не менее контроллеры будут хранить эту информацию, что не очень правильно.
  • Источником правды о состоянии приложения является иерархия показанных экранов, что в некоторых случаях может быть ограничением.


Альтернативы


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

Похожие идеи можно встретить в архитектуре RIBs, которая используется нашей Android-командой.

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

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