Создание архитектуры: работа с iOS Coordinator pattern

от автора


(Иллюстрация)

Каждая команда рано или поздно начинает думать о внедрении собственных архитектурных подходов, и немало было об это копий сломано. Вот и мы в компании Umbrella IT всегда хотели работать с гибкими инструментами, чтобы формирование архитектуры не было чем-то болезненным, и проблемы навигации, mock-файлов, изолированности и тестирования перестали быть чем-то страшным, чем-то таким, что рано или поздно нависает над разросшимся проектом. К счастью, речь не идет о новой «эксклюзивной» архитектуре с вычурным названием-аббревиатурой. Надо признать, что существующие на данный момент популярные архитектуры (MVP, MVVM, VIPER, Clean-swift) справляются со своими задачами, и сложности может вызвать лишь неправильный выбор и неправильное использование того или иного подхода. Однако и в рамках принятой архитектуры можно использовать различные паттерны, что позволит добиться тех самых, почти мифических показателей: гибкость, изолированность, тестируемость, переиспользование.

Безусловно, приложения бывают разные. Если проект содержит в себе лишь несколько экранов, которые связаны последовательно, то нет особой необходимости в сложных взаимодействиях между модулями. Вполне можно обойтись обычными segue-связями, приправив все это старым добрым MVC/MVP. И хотя архитектурный снобизм рано или поздно одолевает каждого разработчика, все-таки реализация должна быть соизмерима целям и сложности проекта. И вот, если в проекте предполагается сложная структура экранов и различные состояния (авторизация, режим Гостя, офлайн, роли для пользователей и т.д.), то упрощенный подход к архитектуре непременно сыграет злую шутку: множество зависимостей, неочевидный и дорогой переброс данных между экранами и состояниями, проблемы с навигацией и главное — никакой гибкости и переиспользуемости у всего этого не будет, решения будут намертво вплавляться в проект и экран А всегда будет открывать экран B. Попытки же изменения приведут к мучительным рефакторингам, во время которых так легко создавать ошибки и ломать то, что раньше работало. В примере ниже мы опишем гибкий способ организации работы приложения, которое имеет два состояния: пользователь не авторизован и его следует направить на экран авторизации, пользователь авторизован и следует открыть некий Main-экран.

1. Реализация основных протоколов


Для начала нам необходимо реализовать базу. Все начинается с протоколов Coordinatable, Presentable, Routable:

protocol Coordinatable: class {     func start()     }   protocol Presentable {     var toPresent: UIViewController? { get } }   extension UIViewController: Presentable {     var toPresent: UIViewController? {         return self     }          func showAlert(title: String, message: String? = nil) {         UIAlertController.showAlert(title: title,                                     message: message,                                     inViewController: self,                                     actionBlock: nil)     } }

В данном примере showAlert — это просто удобный метод для вызова уведомления, который находится в расширении UIViewController.

protocol Routable: Presentable {          func present(_ module: Presentable?)     func present(_ module: Presentable?, animated: Bool)          func push(_ module: Presentable?)     func push(_ module: Presentable?, animated: Bool)     func push(_ module: Presentable?, animated: Bool, completion: CompletionBlock?)          func popModule()     func popModule(animated: Bool)          func dismissModule()     func dismissModule(animated: Bool, completion: CompletionBlock?)          func setRootModule(_ module: Presentable?)     func setRootModule(_ module: Presentable?, hideBar: Bool)          func popToRootModule(animated: Bool) }

2. Создание координатора


Время от времени возникает необходимость изменения экранов приложения, а значит будет необходимо реализовать тестируемый слой без downcast’a, а также без нарушения SOLID-принципов.

Приступим к реализации координатного слоя:

После запуска приложения должен быть вызван метод AppCoordinator’а, который определяет, какой flow следует запустить. Например, если пользователь зарегистрирован, то следует запустить flow приложения, а если нет, то flow авторизации. В данном случае необходимы MainCoordinator и AuthorizationCoordinator. Мы опишем именно координатор для авторизации, все прочие экраны могут быть созданы аналогичным образом.

Для начала необходимо добавить output координатору, чтобы тот мог иметь связь с вышестоящим координатором (AppCoordinator):

protocol AuthorizationCoordinatorOutput: class {     var finishFlow: CompletionBlock? { get set } }   final class AuthorizationCoordinator: BaseCoordinator, AuthorizationCoordinatorOutput {        var finishFlow: CompletionBlock?          fileprivate let factory: AuthorizationFactoryProtocol     fileprivate let router : Routable          init(router: Routable, factory: AuthorizationFactoryProtocol) {         self.router  = router         self.factory = factory     } }   // MARK:- Coordinatable extension AuthorizationCoordinator: Coordinatable {     func start() {         performFlow()     } }   // MARK:- Private methods private extension AuthorizationCoordinator {     func performFlow() {        //:- Will implement later     } }


Как показано выше, у нас есть Authorization координатор с роутером и фабрикой модулей. Но кто и когда вызывает метод start()?
Здесь нам необходимо реализовать AppCoordinator.

final class AppCoordinator: BaseCoordinator {          fileprivate let factory: CoordinatorFactoryProtocol     fileprivate let router : Routable          fileprivate let gateway = Gateway()          init(router: Routable, factory: CoordinatorFactory) {         self.router  = router         self.factory = factory     } }   // MARK:- Coordinatable extension AppCoordinator: Coordinatable {     func start() {         self.gateway.getState { [unowned self] (state) in             switch state {             case .authorization:                 self.performAuthorizationFlow()             case .main:                 self.performMainFlow()            }         }     } }   // MARK:- Private methods     func performAuthorizationFlow() {         let coordinator = factory.makeAuthorizationCoordinator(with: router)         coordinator.finishFlow = { [weak self, weak coordinator] in             guard                 let `self` = self,                 let `coordinator` = coordinator             else { return }             self.removeDependency(coordinator)             self.start()         }         addDependency(coordinator)         coordinator.start()     }   func performMainFlow() { // MARK:- main flow logic } 

Из примера можно видеть, что у AppCoordinator’a есть роутер, фабрика координаторов и состояние точки входа для AppCoordinator’a, ролью которого является определение старта flow у приложения.

final class CoordinatorFactory {     fileprivate let modulesFactory = ModulesFactory() }   extension CoordinatorFactory: CoordinatorFactoryProtocol {     func makeAuthorizationCoordinator(with router: Routable) -> Coordinatable & AuthorizationCoordinatorOutput {         return AuthorizationCoordinator(router: router, factory: modulesFactory)     } }

3. Реализация фабрики координаторов


Каждый из координаторов инициализируется с роутером и фабрикой модулей. Более того, каждый из координаторов должен наследоваться от базового координатора:

class BaseCoordinator {          var childCoordinators: [Coordinatable] = []              // Add only unique object          func addDependency(_ coordinator: Coordinatable) {                 for element in childCoordinators {             if element === coordinator { return }         }         childCoordinators.append(coordinator)     }          func removeDependency(_ coordinator: Coordinatable?) {         guard             childCoordinators.isEmpty == false,             let coordinator = coordinator             else { return }                  for (index, element) in childCoordinators.enumerated() {             if element === coordinator {                 childCoordinators.remove(at: index)                 break             }         }     } }

BaseCoordinator — класс, в котором содержится массив дочерних координаторов и два метода: Удалить и Добавить зависимость координатора.

4. Настройка AppDelegate


Теперь посмотрим, как выглядит UIApplicationMain:

@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate {          var window: UIWindow?          var rootController: UINavigationController {         window?.rootViewController = UINavigationController()         window?.rootViewController?.view.backgroundColor = .white         return window?.rootViewController as! UINavigationController     }       fileprivate lazy var coordinator: Coordinatable = self.makeCoordinator()   func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {         coordinator.start()         return true     } }   // MARK:- Private methods private extension AppDelegate {     func makeCoordinator() -> Coordinatable {         return AppCoordinator(router: Router(rootController: rootController),                               factory: CoordinatorFactory())     } }

Как только вызовется метод делегата didFinishLaunchingWithOptions — вызывается метод start() у AppCoordinator’а, который определит дальнейшую логику приложения.

5. Создание модуля экрана


Для демонстрации того, что же происходит дальше, вернемся назад к AuthorizationCoordinator и реализуем метод performFlow().

Для начала нам следует реализовать интерфейс AuthorizationFactoryProtocol в классе ModulesFactory:

final class ModulesFactory {}   // MARK:- AuthorizationFactoryProtocol   extension ModulesFactory: AuthorizationFactoryProtocol {      func makeEnterView() -> EnterViewProtocol {           let view: EnterViewController =  EnterViewController.controllerFromStoryboard(.authorization)           EnterAssembly.assembly(with: view)          return view

Под вызовом любого метода у фабрики модулей, как правило, подразумевается инициализация ViewController’a из сториборда, а затем связывание всех необходимых компонентов этого модуля в рамках конкретной архитектуры (MVP, MVVM, CleanSwift).

После необходимых приготовлений мы можем реализовать метод performFlow() у AuthorizationCoordinator’а.
Стартовым экраном в рамках данного координатора является EnterView.
В методе performFlow() с помощью фабрики модулей вызывается создание готового модуля для данного координатора, далее реализуется логика обработки замыканий, которые вызывает в тот или иной момент времени наш view controller, затем данный модуль выставляется у роутера корнем в навигационном стеке экранов:

private extension AuthorizationCoordinator {       func performFlow() {         let enterView = factory.makeEnterView()           finishFlow =  enterView.onCompleteAuthorization           enterView.output?.onAlert = { [unowned self] (message: String) in             self.router.toPresent?.showAlert(message: message)           }          router.setRootModule(enterView) } }

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

Ссылка на github, где содержится демо на основе Clean архитектуры и удобный Xcode Template для создания необходимых архитектурных слоев.


ссылка на оригинал статьи https://habr.com/post/426507/


Комментарии

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

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