Опыт использования «координаторов» в реальном «iOS»-проекте

в 7:47, , рубрики: architectural patterns, iOS, ios development, iOS разработка, swift, swift development, swift разработка, разработка под iOS

Мир современного программирования богат на тренды, а для мира программирования «iOS»-приложений это справедливо вдвойне. Надеюсь, я не сильно ошибусь, утверждая, что одним из самых «модных» архитектурных шаблонов последних лет является «координатор». Вот и наша команда какое-то время назад осознала непреодолимое желание попробовать на себе этот прием. Тем более, что подвернулся очень удачный случай – значительное изменение логики и тотальная перепланировка навигации в приложении.

Проблема

Зачастую так получается, что контроллеры начинают брать на себя слишком многое: «отдавать команды» напрямую владеющим его UINavigationController, «общаться» с родными «братьями»-контроллерами (даже инициализировать их и передавать в навигационный стек) – в общем делать много того, о чем им не положено даже подозревать.

Одним из возможных способов этого избежать как раз и является «координатор». Причем, как оказалось, довольно удобным в работе и очень гибким: шаблон способен управлять навигационными событиями как небольших модулей (представляющих собой, возможно, лишь один-единственный экран), так и всего приложения (запуская свой «flow», условно говоря, прямо из UIApplicationDelegate).

История

Мартин Фаулер в своей книге «Patterns of Enterprise Application Architecture» назвал этот шаблон «Application Controller». А первым его популяризатором в среде «iOS» считается Соруш Ханлу: все началось с его доклада на «NSSpain» в 2015 году. Затем появилась обзорная статья на его сайте, которая имела несколько продолжений (например это).

А затем последовали множество обзоров (запрос «ios coordinators» выдает десятки результатов разного качества и степени подробности), в том числе даже руководство на Ray Wenderlich и статья от Пола Хадсона на его «Hacking with Swift» в рамках серии материалов о путях избавления от проблемы «массивного» контроллера.

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

Собственно, почему это проблема? Координаторы, как и любые объекты, чтобы существовать в памяти, нуждаются в том, чтобы какой-то другой объект ими «владел». Как правило, при выстраивании системы навигации с помощью координаторов, одни координаторы порождают другие и хранят на них сильную ссылку. При «выходе из зоны ответственности» порожденного координатора, контроль возвращается к порождающему координатору, и память, которую занимал порождаемый, должна быть освобождена.

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

Первое приближение

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

Когда мы в команде впервые взялись экспериментировать с координаторами, у нас не было для этого очень много времени и свободы действий: необходимо было считаться с существующими принципами и устройством навигации. Первый вариант реализации координаторов базировался на общем «роутере», который владеет и управляет UINavigationController. Он умеет делать с экземплярами UIViewController все, что нужно касаемо навигации – «push»/«pop», «present»/«dismiss» плюс манипуляции с «root»-контроллером. Пример интерфейса такого роутера:

import UIKit

protocol Router {
    func present(_ module: UIViewController, animated: Bool)
    func dismissModule(animated: Bool, completion: (() -> Void)?)
    func push(_ module: UIViewController, animated: Bool, completion: (() -> Void)?)
    func popModule(animated: Bool)    
    func setAsRoot(_ module: UIViewController)
    func popToRootModule(animated: Bool)
}

Конкретная реализация инициализируется с экземпляром UINavigationController и ничего особенно хитрого в себе не содержит. Единственное ограничение: в качестве значений аргументов методов интерфейса нельзя передавать другие экземпляры UINavigationController (по понятным причинам: UINavigationController не может содержать UINavigationController в своем стеке – это ограничение UIKit).

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

class Coordinator {
    
    private var childCoordinators = [Coordinator]()

    func add(dependency coordinator: Coordinator) {
        // ...
    }
    
    func remove(dependency coordinator: Coordinator) {
        // ...
    }

}

Одно из подразумеваемых достоинств координаторов – это инкапсуляция знаний о конкретных подклассах UIViewController. Чтобы обеспечить взаимодействие роутера и координаторов мы ввели следующий интерфейс:

protocol Presentable {
    func presented() -> UIViewController
}

Тогда каждый конкретный координатор должен наследоваться от Coordinator и реализовывать интерфейс Presentable, а интерфейс роутера – принять следующий вид:

protocol Router {
    func present(_ module: Presentable, animated: Bool)
    func dismissModule(animated: Bool, completion: (() -> Void)?)
    func push(_ module: Presentable, animated: Bool, completion: (() -> Void)?)
    func popModule(animated: Bool)    
    func setAsRoot(_ module: Presentable)
    func popToRootModule(animated: Bool)
}

(Подход с Presentable также позволяет использовать координаторы внутри модулей, которые написаны для взаимодействия напрямую с экземплярами UIViewController, не подвергая их (модули) кардинальной переработке.)

Краткий пример этого всего в деле:

final class FirstCoordinator: Coordinator, Presentable {
    
    func presented() -> UIViewController {
        return UIViewController()
    }
    
}

final class SecondCoordinator: Coordinator, Presentable {
    
    func presented() -> UIViewController {
        return UIViewController()
    }
    
}

let nc = UINavigationController()
let router = RouterImpl(navigationController: nc) // Router implementation.
router.setAsRoot(FirstCoordinator())

router.push(SecondCoordinator(), animated: true, completion: nil)
router.popToRootModule(animated: true)

Следующее приближение

И вот однажды наступил момент тотальной переделки навигации и абсолютной свободы самовыражения! Момент, когда ничто не мешало попробовать реализовать навигацию на координаторах с использованием заветного метода start() – версии, которая и пленила изначально своей простотой и лаконичностью.

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

protocol Coordinator {
    
    func add(dependency coordinator: Coordinator)
    func remove(dependency coordinator: Coordinator)
    func start()
    
}

class BaseCoordinator: Coordinator {
    
    private var childCoordinators = [Coordinator]()
    
    func add(dependency coordinator: Coordinator) {
        // ...
    }
    func remove(dependency coordinator: Coordinator) {
        // ...
    }
    func start() { }
    
}

«Swift» не предлагает возможность объявлять абстрактные классы (т.к. в большей степени он ориентирован на протокольно-ориентированный подход, нежели на более классический, объектно-ориентированный), поэтому метод start() можно как оставить с пустой реализацией, так и засунуть туда что-нибудь вроде fatalError(_:file:line:) (принуждая переопределять этот метод наследниками). Лично мне первый вариант больше по душе.

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

extension Coordinator {
    
    func add(dependency coordinator: Coordinator) {
        // ...
    }
    func remove(dependency coordinator: Coordinator) {
        // ...
    }
    
}

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

Основа любого конкретного координатора таким образом будет выглядеть так:

final class SomeCoordinator: BaseCoordinator {
    
    override func start() {
        // ...
    }
    
}

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

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

Внутри себя координатор при обработке событий (об этом – далее) может передавать этот UINavigationController дальше, другим координаторам, которые он порождает. А те могут также делать с текущим состоянием навигации то, что им необходимо: «push», «present», да хоть бы и весь навигационный стек подменять.

Возможные улучшения интерфейса

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

protocol CoordinatorDependencies {
    
    func add(dependency coordinator: Coordinator)
    func remove(dependency coordinator: Coordinator)
    
}

final class DefaultCoordinatorDependencies: CoordinatorDependencies {
    
    private let dependencies = [Coordinator]()
    
    func add(dependency coordinator: Coordinator) {
        // ...
    }
    func remove(dependency coordinator: Coordinator) {
        // ...
    }
    
}

final class SomeCoordinator: Coordinator {
    
    private let dependencies: CoordinatorDependencies
    
    init(dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) {
        dependencies = dependenciesManager
    }
    
    func start() {
        // ...
    }
    
}

Обработка событий, порождаемых пользователем

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

Предположим, имеется некий подкласс UIViewController:

final class SomeViewController: UIViewController { }

И координатор, который добавляет его в стек:

final class SomeCoordinator: Coordinator {
    
    private let dependencies: CoordinatorDependencies
    private weak var navigationController: UINavigationController?
    
    init(navigationController: UINavigationController,
         dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) {
        self.navigationController = navigationController
        dependencies = dependenciesManager
    }
    
    func start() {
        let vc = SomeViewController()
        navigationController?.pushViewController(vc, animated: true)
    }
    
}

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

protocol SomeViewControllerRoute: class {
    func onSomeEvent()
}

final class SomeViewController: UIViewController {
    
    private weak var route: SomeViewControllerRoute?
    
    init(route: SomeViewControllerRoute) {
        self.route = route
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    @IBAction
    private func buttonAction() {
        route?.onSomeEvent()
    }
    
}

final class SomeCoordinator: Coordinator {
    
    private let dependencies: CoordinatorDependencies
    private weak var navigationController: UINavigationController?
    
    init(navigationController: UINavigationController,
         dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) {
        self.navigationController = navigationController
        dependencies = dependenciesManager
    }
    
    func start() {
        let vc = SomeViewController(route: self)
        navigationController?.pushViewController(vc, animated: true)
    }
    
}

extension SomeCoordinator: SomeViewControllerRoute { 
    func onSomeEvent() {
        // ...
    }    
}

Обработка нажатия на кнопку возврата

Еще один неплохой обзор обсуждаемого архитектурного шаблона был опубликован Полом Хадсоном на его сайте «Hacking with Swift», можно даже сказать, руководство. В нем же содержится простое, без обиняков, объяснение одного их возможных решений упомянутой выше проблемы кнопки возврата: координатор (если это необходимо) объявляет себя делегатом передаваемого ему экземпляра UINavigationController и отслеживает интересующее нас событие.

У этого подхода есть небольшой недостаток: делегатом UINavigationController может быть только наследник NSObject.

Итак, имеется координатор, который порождает другой координатор. Этот, другой, по вызову start() добавляет в стек UINavigationController какой-то свой UIViewController. По нажатию на кнопку возврата назад на UINavigationBar все, что нужно сделать – это дать знать порождающему координатору, что порожденный координатор закончил свою работу («флоу»). Для этого мы ввели еще один инструмент делегирования: каждому порождаемому координатору выделяется делегат, интерфейс которого реализует порождающий координатор:

protocol CoordinatorFlowListener: class {
    func onFlowFinished(coordinator: Coordinator)
}

final class MainCoordinator: NSObject, Coordinator {
    
    private let dependencies: CoordinatorDependencies
    private let navigationController: UINavigationController
    
    init(navigationController: UINavigationController,
         dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) {
        self.navigationController = navigationController
        dependencies = dependenciesManager
        super.init()
    }
    
    func start() {
        let someCoordinator = SomeCoordinator(navigationController: navigationController, flowListener: self)
        dependencies.add(someCoordinator)
        someCoordinator.start()
    }
    
}

extension MainCoordinator: CoordinatorFlowListener {
    
    func onFlowFinished(coordinator: Coordinator) {
        dependencies.remove(coordinator)
        // ...
    }
    
}

final class SomeCoordinator: NSObject, Coordinator {
    
    private weak var flowListener: CoordinatorFlowListener?
    private weak var navigationController: UINavigationController?
    
    init(navigationController: UINavigationController,
         flowListener: CoordinatorFlowListener) {
        self.navigationController = navigationController
        self.flowListener = flowListener
    }
    
    func start() {
        // ...
    }
    
}

extension SomeCoordinator: UINavigationControllerDelegate {
    
    func navigationController(_ navigationController: UINavigationController,
                              didShow viewController: UIViewController,
                              animated: Bool) {
        guard let fromVC = navigationController.transitionCoordinator?.viewController(forKey: .from) else { return }
        if navigationController.viewControllers.contains(fromVC) { return }
        
        if fromVC is SomeViewController {
            flowListener?.onFlowFinished(coordinator: self)
        }
    }
    
}

В примере выше MainCoordinator, ничего не делает: просто запускает «флоу» другого координатора – в реальной жизни это, конечно, бесполезно. В нашем приложении MainCoordinator получает извне данные, по которым он определяет, в каком состоянии находится приложение – авторизованном, не авторизованном и т.д. – и какой именно экран необходимо показать. В зависимости от этого, он запускает «флоу» соответствующего координатора. Если порожденный координатор закончил свою работу, главный координатор получает об этом сигнал через CoordinatorFlowListener и, скажем, запускает «флоу» другого координатора.

Заключение

Прижившееся решение, конечно, обладает рядом недостатков (как и любое решение любой проблемы).

Да, приходится использовать много делегирования, но оно простое и имеет единое направление: от порождаемого к порождающему (от контроллера к координатору, от порождаемого координатора к порождающему).

Да, чтобы спастись от утечек памяти, приходится в каждый координатор добавлять метод делегата UINavigationController с практически идентичной реализацией. (Первый подход этого недостатка лишен, но вместо этого более «щедро» делится внутренними знаниями о назначении конкретного координатора.)

Но самый большой недочет такого подхода – это то, что в реальной жизни координаторы, к сожалению, будут знать об окружающем их мире чуть больше, чем хотелось бы. Точнее, в них придется добавлять элементы логики, зависящие от внешних условий, о которых координатор не осведомлен напрямую. В основном, это, собственно, то, что происходит по вызову метода start() или по обратному вызову onFlowFinished(coordinator:). А происходить в этих местах может что угодно, и это всегда будет «hardcoded»-поведение: добавление контроллера в стек, подмена стека, возврат к корневому контроллеру – что угодно. И это все зависит не от компетенций текущего контроллера, а от внешних условий.

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

Спасибо, что дочитали до этого места! Надеюсь, узнали что-нибудь для себя полезное. А если вдруг захочется «больше меня», то вот ссылка на мой Twitter.

Автор: hummingbirddj

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js