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

3sap0w70jyzre12wi-c8r9132l4.png
(Иллюстрация)

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

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


Для начала нам необходимо реализовать базу. Все начинается с протоколов 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)
}


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

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

iwogyacswjrawufbwtijmp9mbp0.jpeg

После запуска приложения должен быть вызван метод 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
    }
}

ddgtfjhqknwgahfd2dtbpffvice.jpeg
Как показано выше, у нас есть 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)
    }
}


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

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 — класс, в котором содержится массив дочерних координаторов и два метода: Удалить и Добавить зависимость координатора.
Теперь посмотрим, как выглядит 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«а, который определит дальнейшую логику приложения.
Для демонстрации того, что же происходит дальше, вернемся назад к 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)
} }

j-ogt9bti0cndmozt4znuly95sk.jpeg

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

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

© Habrahabr.ru