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

Опыт использования «координаторов» в реальном «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 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 с пустым стеком.

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

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

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

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 с практически идентичной реализацией.

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

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

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


Оставить комментарий

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

*

x

Ещё Hi-Tech Интересное!

Анализ кода CUBA Platform с помощью PVS-Studio

Для Java программистов существуют полезные инструменты, помогающие писать качественный код, например, мощная среда разработки IntelliJ IDEA, бесплатные анализаторы SpotBugs, PMD и другие. Всё это уже используется в разработке проекта CUBA Platform, и в этом обзоре найденных дефектов кода я расскажу, ...

[Из песочницы] Подготовка к промышленному производству ДО-РА

1. Транспортировка образцов после ядерной катастрофы на АЭС Фукусима в Японии и задумывался в виде гаджета – персонального дозиметра-радиометра работающего с одноименным ПО – DO-RA. Проект DO-RA DO-RA.com был рождён в марте 2011 г. Soft на любом смартфоне под мобильные ...