Управляем навигацией в iOS-приложениях. Паттерн координатор от СберМаркета

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Под катом рассказываем, как и зачем мы в команде написали свою реализацию паттерна Coordinator.

Эта статья — текстовая версия выступления Филиппа Красновида на iOS Meetup от СберМаркет Tech.

Что такое координаторы

Координатор — это особый класс, в котором находится логика навигации между экранами в приложении. Идею этого паттерна описал Соруш Ханлоу в 2015 году. 

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

  • прописать экраны и потоки (segues) между ними в файл Storyboard, а затем вызывать их в нужных местах кода;

  • использовать контейнеры, например UINavigationController;

  • вызывать экран напрямую через метод present(_:animated:completion:).

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

Проблема в самой реализации класса UIViewController. Все вызванные в объекте класса экраны жёстко связаны с родителем. Первый экран должен знать о существовании и типе каждого дочернего экрана, который он вызывает. Подробнее об этом можно почитать в разборе Павла Гурова.

Решить проблему можно, если убрать логику навигации из UIViewController.  Для этого нужно удалить из объекта класса все инициализации и вызовы других экранов, передачу данных, не использовать потоки и контейнеры. А логику перенести в новый класс Coordinator, объекты которого будут отвечать за вызовы экранов в приложении. Тогда экранам не нужно будет «знать», в каком порядке они идут и кому какие данные передают.

Примерная схема работы координаторов. Компонент AppCoordinator отвечает за главный координатор, экраны авторизации и онбординга
Примерная схема работы координаторов. Компонент AppCoordinator отвечает за главный координатор, экраны авторизации и онбординга

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

Почему мы создали свою реализацию координаторов

Нашей команде нужно было решить несколько проблем: 

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

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

Убрать человеческий фактор. Все разработчики иногда ошибаются. Мы хотели, чтобы у нас осталось как можно меньше возможностей сделать что-то не так. 

Чтобы решить все проблемы разом, мы написали свою версию паттерна.

Чем отличается реализация координаторов СберМаркета от стандартной

Мы написали несколько новых протоколов и немного доработали стандартные. 

Transition — новый протокол для работы с аниматорами в NavigationController и настройки анимации переходов между экранами и вкладками.

LyfeCycleListener — новый протокол, который отслеживает события навигации. Функции в нём работают по аналогии с функциями NavigationController:

  • increment и decrement — аналоги pop и push;

  • startNotify — установка корневого контроллера в стеке навигации;

  • dismissNotify — отклонение любого модального экрана;

  • toRootNotify — обработка события pop-to-root, когда нужно показать корневой контроллер в стеке.

SystemNavigation — новый протокол для работы со стандартными событиями навигации из UIkit.

/// Абстракция от UIKit'a
public protocol Transition: AnyObject {
    var transitioning: UIViewControllerAnimatedTransitioning { get }
}
/// Интерфейс слушателя жизненного цикла юнитов в координаторах
public protocol LifeCycleListener: AnyObject {
    func increment()
    func decrement()
    func startNotify()
    func dismissNotify(event: ApplicationRouter.RouterEvent) 
    func toRootNofity(in router: Routable)
}
/// Интерфейс для сущности UINavigationController в системе
public protocol SystemNavigation: UINavigationController {
    var popToRootHandler: (() -> Void)? { get set }
    var popHandler: (() -> Void)? { get set }
}

Реализации протоколов Transition, LifeCycleListener и SystemNavigation

Для своей реализации мы переписали два класса: BaseCoordinator и ApplicationRouter.

BaseCoordinator — основной класс, который отслеживает зависимости между экранами. В нем остались стандартные методы: добавление и удаление зависимостей, массив дочерних координаторов.

/// Базовый класс для координатора
open class BaseCoordinator {
    public let router: Routable
    private weak var parentCoordinator: BaseCoordinator?
    private let listener = DefaultLifeCycleListener()
    private var childCoordinators: [BaseCoordinator] = []
  
    public private(set) var countUnits: Int = 0 {
        didSet {
            assert(countUnits >= 0, "Что-то пошло не так!")
            if countUnits == 0 { parentCoordinator?.removeChild(self) }
        }
    }
  
    public init(router: Routable, parent: BaseCoordinator? = nil) {
        self.parentCoordinator = parent
        self.router = router
        self.router.subscribe(listener)
        self.listener.recieveEvent = { ... }
        }
    }
}  	

Добавление и удаление зависимостей инкапсулировано в BaseCoordinator. 

Чтобы управлять зависимостями, мы используем счетчик countUnits. Он показывает, сколько юнитов в данный момент зависят от родительского координатора.

Сюда же добавили свойство parentCoordinator — ссылку на родительский координатор. Она нужна для того, чтобы удалять и добавлять текущий координатор в зависимость. 

Поле listener — вызов протокола LyfeCycleListener, интерфейса сообщений от роутера. 

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

  • Routable,

  • UINavigationControllerDelegate,

  • UIAdaptivePresentationControllerDelegate.

Стандартный протокол Routable мы дополнили методом subscribe. Он передает в координатор сообщение о событиях навигации.

/// Интерфейс роутер для системы координаторов
public protocol Routable: AnyObject {
    func pushModule(_ module: Presentable, transition: Transition?, ....)
    func setRootModule(_ module: Presentable, transition: Transition?, ...)
    func popModule(transition: Transition?, animated: Bool, comletion: (() -> Void)?)
    func popToRootModule(animated: Bool, completion: (() -> Void)?)
  
    func presentModule(_ module: Presentable, ....)
    func dismissModule(animated: Bool, completion: (() -> Void)?)
    func closeModule(animated: Bool, transition transitionIfCan: Transition?, ...)
  
    func subscribe(_ listener: LifeCycleListener)
}

UINavigationControllerDelegate нужен для поддержки анимации переходов между экранами. Он же обрабатывает swipe-to-back, то есть закрывающий свайп с края экрана.

UIAdaptivePresentationControllerDelegate обрабатывает события от модальных представлений, которые открываются не на весь экран. 

Как мы используем координаторы

Схема навигации в приложении СберМаркет в целом довольно проста:

У нас есть корневой координатор ApplicationCoordinator, который стартует при запуске приложения. Он содержит три сервисных координатора, которые выполняют разные проверки: авторизацию, историю, онбординг.

Когда приложение готово к работе, один из сервисных координаторов вызывает TabBarCoordinator. Он управляет координаторами пяти вкладок приложения: 

  • MainTabCoordinator (Главное),

  • CatalogTabCoordinator (Каталог),

  • CartTabCoordinator (Корзина),

  • FavoritesTabCoordinator (Любимое),

  • ProfileTabCoordinator (Профиль). 

Вкладки в приложении СберМаркет
Вкладки в приложении СберМаркет

В каждой вкладке есть свои экраны, там навигация тоже построена на контроллерах, но рассказывать о них подробно мы не будем. Благодаря координаторам мы сократили объем кода вызова экранов в 2–4 раза.

func openLoginFlow() {
    let (coordinator, presentable) = coordinatorsFactory.makeLoginCoordinator()
  
    coordinator.output = LoginCoordinatorOutput(onFinish: { [weak self, weak coordinator] reason in
        guard
            let strongCoordinator = coordinator,
            let self = self
        else { return }
        
        switch reason {
        case .close: break
        case .success, .closeConfirmationPhoneFlow:
            self.didSuccessAuthorization()
        }
                                                              
        self.router.dismissModule(animated: true)
        self.removeDependency(strongCoordinator)
    })
  
    addDependency(coordinator)
    router.present(presentable, animated: true)
    coordinator.start(with: .login(source: .favouriteList), animated: false)
}

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

func openLoginFlow() {
  	let unit = coordinatorsFactory.makeLoginCoordinator(output: self)
    unit.coordinator.start(with: .login(source: .favouriteList))
    router.presentModule(unit.view)
}

Стало: тот же вызов, но в новой реализации

До того как мы стали использовать собственную реализацию координаторов, в коде был капчер-лист, зависимости нужно было добавлять и удалять вручную. Получалось много и запутанно.

С координаторами почти все вызовы стали занимать три строчки, получилось компактно и понятно. В редких случаях может быть 4–5 строк, если при инициализации координатора мы прописываем дополнительные свойства. Как работает реализация, можно понять на примере ниже. 

Жизненный цикл координатора на примере UINavigationController-стека
Жизненный цикл координатора на примере UINavigationController-стека
  1. Инициализируем координатор. 

  2. В инициализаторе вызывается метод subscribe() — подписка на сообщения от роутера.

  3. Запускаем координатор, вызвав метод start.

  4. Внутри метода start() создаётся модуль, который нужно показать на экране. Пушим его методом pushModule у роутера. 

  5. Роутер отправляет событие-increment координатору. 

  6. Координатор принимает событие от роутера и проверяет countUnits. countUnits == 0. В родительском координаторе вызывается метод addChild() и добавляется как зависимость новый координатор. 

  7. Счетчик countUnits, который изначально был равен 0, теперь равен 1. 

  8. Еще раз создаем и пушим модуль через метод pushModule. 

  9. Роутер снова отправляет increment координатору. 

  10. countUnits теперь равен 2. 

Мы отобразили всё, что было нужно, на экране и теперь закрываем модули через вызов метода pop.

  1. Первым закрываем тот модуль, который отобразили последним, — он верхний в стеке навигации. 

  2. Роутер отправляет decrement координатору. 

  3. Координатор уменьшает countUnits на единицу.

  4. Повторяем ещё раз: метод pop закрывает верхний модуль в стеке.

  5. Роутер отправляет decrement. 

  6. countUnits == 0, поэтому координатор удаляет себя из родительского координатора.

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

Главное, что мы получили от реализации координаторов:

  • убрали лишний код из проекта, например капчер-листы, бойлерплейт-код;

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

  • передали обработку системных событий и свайпов по экрану специальному протоколу. 

Всё вместе это дало меньшее число ошибок в коде и упростило жизнь разработчиков.

Мы завели соцсети с новостями и анонсами Tech-команды. Если хотите узнать, что под капотом высоконагруженного e-commerce, следите за нами там, где вам удобнее всего: Telegram, VK.

Источник: https://habr.com/ru/post/654339/


Интересные статьи

Интересные статьи

Менеджеры обладают всеми возможностями, чтобы заставить команду страдатьКак менеджеры, мы находимся в наилучшем положении, чтобы погрузить в уныние наши команды. Если вы менеджер, стремящийся действит...
Привет! Меня зовут Павел Агалецкий, я старший инженер в команде архитектуры. Одна из областей ответственности нашей команды — синхронное (RPC) и асинхронное взаимодействие между сервисами. В этой...
Ссылка на оригинал Паттерн заключается в отложенной загрузке ресурсов, то есть только тогда, когда пользователю нужна какая-либо часть интерфейса.Наша страница может соде...
Приветствую вас всех, мои дорогие читатели! Сегодня я хочу поделиться своими мыслями о давно наболевшей теме, и возможно, обсудить ее в комментариях. Достаточно часто мне попадаются статьи по...
Прочитав заголовок, многие наверняка подумали, что этот материал не для них. Но не торопитесь закрывать страницу! Вспомните знаменитое отключение электроэнергии в Москве, которое случилось бо...