Как стать автором
Обновить
СберМаркет
Кодим будущее доставки товаров

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

Время на прочтение 7 мин
Количество просмотров 7.8K

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

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

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

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

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

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

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

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

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

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

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

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

Примерная схема работы координаторов. Компонент LoginCoordinator отвечает за последовательность, в которой показываются модули
Примерная схема работы координаторов. Компонент LoginCoordinator отвечает за последовательность, в которой показываются модули

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

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

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

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

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

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

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

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

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

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.

Теги:
Хабы:
+4
Комментарии 7
Комментарии Комментарии 7

Публикации

Информация

Сайт
sbermarket.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
SberMarket Tech