Хабрахабр

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

protocol TopViewControllerProvider
}

Однако непонятно, как определять корневой элемент иерархии и что делать с экранами-контейнерами вроде 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
}

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

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

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

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»