Application Coordinator в iOS приложениях

    Каждый год в платформе iOS происходит множество изменений, к тому же регулярно выходят сторонние библиотеки по работе с сетью, кэшированию данных, отрисовке UI через JavaScript и прочему. В противовес всем этим тенденциям Павел Гуров рассказал об архитектурном решении, которое будет актуально независимо от того, какими технологиями вы пользуетесь сейчас или будете пользоваться через пару лет.

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



    О спикере: Павел Гуров занимается разработкой iOS приложений в Avito.



    Навигация





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



    Наиболее распространенные методы построения архитектуры iOS приложений: MVc, MVVm и MVp, описывают то, как построить один экран-модуль. Еще там говорится о том, что модули могут знать друг о друге, общаться друг с другом и т.д. Но совсем мало внимания уделяется вопросам, как совершаются переходы между этими модулями, кто принимает решение об этих переходах, и как передаются данные.

    UlStoryboard + segues


    iOS из коробки предоставляет несколько способов показать следующий по сценарию экран:

    1. Всем известный UlStoryboard + segues, когда мы обозначаем все переходы между экранами в одном мета-файле, и потом их вызываем. Все очень удобно и здорово.
    2. Контейнеры (Containers) — такие, как UINavigationController. UITabBarController, UIPageController или, возможно, самописные контейнеры, которые можно использовать как программно, так и вместе со StoryBoards.
    3. Метод present(_:animated:completion:). Это просто метод класса UIController.

    В самих этих инструментах проблем нет. Проблема в том, как именно они обычно используются. UINavigationController, performSegue, prepareForSegue, метод presentViewController — это все property-методы класса UIViewController. Apple предлагает пользоваться этими инструментами внутри самого UIViewController.



    Доказательством этому служит следующее.



    Это комментарии, которые появляются в вашем проекте, если вы создаете новый подкласс UIViewController по стандартному шаблону. Написано прямо — если вы используете segues и вам нужно передать данные в следующий по сценарию экран, вы должны: достать этот ViewController из segue; знать, какого он будет типа; привести его к этому типу и передать туда свои данные.

    Такой подход к проблемам в построении навигации.

       1. Жесткая связанность экранов

    Это значит, что экран 1 знает о существовании экрана 2. Мало того, что он знает о его существовании, он его еще и потенциально создает, или берет из segue, зная, какого он типа, и передает ему какие-то данные.

    Если нам понадобится при каких-то обстоятельствах показать вместо экрана 2 экран 3, то придется знание о новом экране 3 точно так же зашивать в контроллер экрана 1. Все становится еще сложнее, если контроллеры 2 и 3 могут вызываться еще из нескольких мест, не только из экрана 1. Получается, что знание об экране 2 и 3 придется зашивать в каждое из этих мест.

    Сделать это — еще половина беды, основные проблемы начнутся, когда потребуется внести в эти переходы изменения, или все это поддерживать.



       2. Изменение порядка контроллеров сценария

    Это тоже не так просто по причине связанности. Чтобы поменять местами два ViewController, недостаточно будет зайти в UlStoryboard и поменять местами 2 картинки. Придется открывать код каждого из этих экранов, переносить его в настройки следующего, менять его местами, что не очень удобно.



       3. Передача данных по сценарию

    Например, при выборе чего-то на экране 3, нам нужно обновить View на экране 1. Так как у нас изначально нет ничего, кроме ViewController, придется каким-то образом связать эти два ViewController — не важно как — через делегирование или как-то еще. Еще сложнее будет, если по действию на экране 3 нужно будет обновить не один экран, а сразу несколько, например, и первый, и второй.



    В таком случае делегированием обойтись не удастся, потому что делегирование — это связь один к одному. Кто-то скажет, давайте использовать нотификацию, кто-то — через shared state. Все это затруднит отладку и отслеживание потоков данных в нашем приложении.

    Как говорится, лучше 1 раз увидеть, чем 100 раз услышать. Давайте посмотрим на конкретный пример из настоящего приложения «Avito Услуги Pro». Это приложение для профессионалов-в сфере услуг, в котором удобно отслеживать свои заказы, общаться с заказчиками, искать новые заказы.

    Сценарий — выбор города в редактировании профиля пользователя.



    Перед вами экран редактирования профиля, такой есть во многих приложениях. Нас интересует выбор города.

    Что здесь происходит?

    • Пользователь нажимает на ячейку с городом, и первый экран принимает решение, что пора в стек навигации добавить следующий экран. Это экран со списком федеральных городов (Москва и Санкт-Петербург) и список регионов.
    • Если пользователь на втором экране выбирает федеральный город, то второй экран понимает, что сценарий завершен, пересылает первому выбранный город и Navigation-стек откатывается до первого экрана. Сценарий считается завершенным.
    • Если же пользователь на втором экране выбирает область, то второй экран принимает решение о том, что нужно подготовить третий экран, в котором мы видим список городов этой области. Если пользователь выбирает какой-то город, то этот город отправляется на первый экран, откатывает Navigation-стек и сценарий считается завершенным.

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

    Как мы это делаем?

    1. Мы запрещаем себе внутри UIViewController обращаться к контейнерам, то есть к self.navigationController, self.tabBarController или еще каким-то кастомным контейнерам, которые вы сделали как property extension. Мы теперь не можем из кода экрана взять свой контейнер и попросить его что-то сделать.


    2. Мы запрещаем себе внутри UIViewController вызывать метод performSegue и писать код в методе prepareForSegue, который бы брал следующий по сценарию экран и занимался его настройкой. То есть мы больше не работаем c segue (с переходами между экранами) внутри UIViewController.


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




    Координатор


    Так как все эти обязанности мы убираем из UIViewController, нам нужна новая сущность, которая будет их выполнять. Создаем новый класс объектов, и называем его координатор.



    Координатор — это просто обычный объект, которому мы передаем на старте NavigationController и вызываем метод Start. Сейчас не думайте о том, как он реализован, просто посмотрите, как в этом случае изменится сценарий выбора города.

    Теперь он начинается не с того, что мы готовим переход на какой-то конкретный экран NavigationСontroller, а мы у координатора вызываем метод Start, передав ему перед этим в инициализаторе NavigationСontroller. Координатор понимает, что в NavigationController пора запушить первый экран, что он и делает.

    Дальше, когда пользователь выбирает ячейку с городом, это событие передается наверх координатору. То есть экран сам ничего не знает — после него, как говорится, хоть потоп. Он это сообщение передает координатору, и дальше координатор реагирует на это тем (так как у него есть NavigationController), что отправляет в него следующий шаг — это выбор регионов.

    Дальше пользователь нажимает «Регион» — такая же точно картина — экран сам ничего не решает, только сообщает координатору, которые открывает следующий экран.

    Когда на третьем экране пользователь выбирает уже конкретный город, этот город передается на первый экран тоже через координатор. То есть координатору передается сообщение, что был выбран город. Координатор отправляет это сообщение в первый экран и откатывает Navigation-стек до первого экрана.

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



    Если рассмотреть приложение в рамках трехслойной архитектуры, то ViewController в идеале должны полностью поместиться в слой Presentation и нести в себе как можно меньше логики приложения.

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

    Демо


    Презентация и демопроект доступен на Github, ниже демонстрация во время доклада.


    Это тот же самый сценарий: редактирование профиля и выбор в нем города.

    Первый экран — это экран редактирования юзера. Он показывает информацию о текущем пользователе: имя и выбранный город. Есть кнопка «Выбрать город». Когда мы нажимаем на нее, попадаем на экран со списком городов. Если мы выбираем там какой-то город, то первый экран получает этот город.

    Давайте посмотрим теперь, как это устроено в коде. Начнем с модели.

    struct City {
        let name: String
    }
    
    struct User {
        let name: String
        var city: City?
    }
    

    Модели простые:

    1. Структура город, у которой есть поле имя, строка;
    2. Пользователь, у которой тоже есть имя и property город.

    Дальше — StoryBoard. Он начинается с NavigationController. В принципе, здесь те же самые экраны, которые были в симуляторе: экран редактирования пользователя с лейблом и кнопкой и экран со списком городов, на котором показана табличка с городами.

    Экран редактирования пользователя


    import UIKit
    
    final class UserEditViewController: UIViewController, UpdateableWithUser {
        
        // MARK: - Input -
        var user: User? { didSet { updateView() } }
        
        // MARK: - Output -
        var onSelectCity: (() -> Void)?
        
        @IBOutlet private weak var userLabel: UILabel?
        @IBAction private func selectCityTap(_ sender: UIButton) {
            onSelectCity?()
        }
        
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
            
            updateView()
        }
        
        private func updateView() {
            userLabel?.text = "User: \(user?.name ?? ""), \n"
                            + "City: \(user?.city?.name ?? "")"
        }
    }
    

    Здесь есть property User — это тот user, который передается снаружи — пользователь, которого будем редактировать. Set user сюда приводит к тому, что вызывается блок didSet, что приводит к вызову локального метода updateView(). Все, что делает этот метод — просто помещает информацию о пользователе в лейбл, то есть показывает его имя и название города, в котором этот пользователь живет.

    То же самое происходит в методе viewWillAppear().

    Самое интересное место — это обработчик нажатия на кнопку выбора города selectCityTap(). Здесь контроллер сам ничего не решает: не создает никакие контроллеры, не вызывает никакие segue. Все, что он делает, это вызывает коллбэк — это второй свойство нашего ViewController. Коллбэк onSelectCity не имеет параметров. Когда пользователь нажимает кнопку, это приводит к тому, что вызывается этот коллбэк.

    Экран выбора города


    import UIKit
    
    final class CitiesViewController: UITableViewController {
    
        // MARK: - Output -
        var onCitySelected: ((City) -> Void)?
        
        // MARK: - Private variables -
        private let cities: [City] = [City(name: "Moscow"),
                                      City(name: "Ulyanovsk"),
                                      City(name: "New York"),
                                      City(name: "Tokyo")]
        
        // MARK: - Table -
        override func tableView(_ tableView: UITableView,
                                numberOfRowsInSection section: Int) -> Int {
            return cities.count
        }
        
        override func tableView(_ tableView: UITableView,
                                cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            
            let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
            cell.textLabel?.text = cities[indexPath.row].name
            return cell
        }
    
        override func tableView(_ tableView: UITableView,
                                didSelectRowAt indexPath: IndexPath) {
            onCitySelected?(cities[indexPath.row])
        }
    }
    

    Этот экран — это UITableViewController. Список городов здесь фиксированный, но он может приходить откуда-то из другого места. Далее (// MARK: — Table -) достаточно тривиальный табличный код, который показывает список городов в ячейках.

    Самое интересное место здесь — это обработчик didSelectRowAt IndexPath, всем хорошо известный метод. Здесь экран опять сам ничего не решает. Что происходит дальше, после того как выбран город? Он просто вызывает коллбэк с единственным параметром «город».

    На этом код самих экранов заканчивается. Как мы видим, они ничего о своем окружении не знают.

    Координатор


    Перейдем к связующему звену между этими экранами.

    import UIKit
    
    protocol UpdateableWithUser: class {
        var user: User? { get set }
    }
    
    final class UserEditCoordinator {
        
        // MARK: - Properties
        private var user: User { didSet { updateInterfaces() } }
        private weak var navigationController: UINavigationController?
        
        // MARK: - Init
        init(user: User, navigationController: UINavigationController) {
            self.user = user
            self.navigationController = navigationController
        }
        
        func start() {
            showUserEditScreen()
        }
    
        // MARK: - Private implementation
        private func showUserEditScreen() {
            let controller = UIStoryboard.makeUserEditController()
            controller.user = user
            controller.onSelectCity = { [weak self] in
                self?.showCitiesScreen()
            }
            navigationController?.pushViewController(controller, animated: false)
        }
        
        private func showCitiesScreen() {
            let controller = UIStoryboard.makeCitiesController()
            controller.onCitySelected = { [weak self] city in
                self?.user.city = city
                _ = self?.navigationController?.popViewController(animated: true)
            }
            navigationController?.pushViewController(controller, animated: true)
        }
        
        private func updateInterfaces() {
            navigationController?.viewControllers.forEach {
                ($0 as? UpdateableWithUser)?.user = user
            }
        }
    }
    

    Координатор имеет два property:

    1. User — пользователь, которого мы будем редактировать;
    2. NavigationController, которому нужно передать при старте.

    Eсть простой init(), который заполняет эти property.

    Дальше есть метод start(), который приводит к тому, что вызывается метод ShowUserEditScreen(). Остановимся на нем поподробнее. Этот метод достает контроллер из UIStoryboard, передает ему нашего локального юзера. Дальше проставляет коллбэк onSelectCity и пушит этот контроллер в Navigation-стек.

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

    На самом деле, он делает практически то же самое — поднимает немножко другой контроллер из UIStoryboard, проставляет ему коллбэк onCitySelected и пушит его в Navigation-стек — вот и все, что происходит. Когда пользователь выбирает конкретный город, срабатывает этот коллбэк, координатор обновляет у нашего локального юзера поле «город» и откатывает Navigation-стек до первого экрана.

    Так как User — это структура, то обновление поля «город» у неё приводит к тому, что вызывается блок didSet, соответственно вызывается приватный метод updateInterfaces(). Этот метод проходится по всему Navigation-стеку и пытается развернуть каждый ViewController как протокол UpdateableWithUser. Это простейший протокол, у которого есть только одно свойство — user. Если это удается, то он прокидывает его обновленному юзеру. Таким образом получается, что наш выбранный юзер на втором экране автоматически прокидывается на первый экран.

    С координатором все понятно, и единственное, что осталось здесь показать, это точку входа в наше приложение. Это то, где все это начинается. В данном случае это метод didFinishLaunchingWithOptions нашего AppDelegate.

    import UIKit
    
    @UIApplicationMain
    class AppDelegate: UIResponder, UIApplicationDelegate {
    
        var window: UIWindow?
        var coordinator: UserEditCoordinator!
        
        func application(_ application: UIApplication,
                         didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
            guard let navigationController = window?.rootViewController as? UINavigationController else { return true }
            let user = User(name: "Pavel Gurov", city: City(name: "Moscow"))
            
            coordinator = UserEditCoordinator(user: user,
                                              navigationController: navigationController)
            coordinator.start()
            return true
        }
    }
    

    Здесь navigationController достается из UIStoryboard, создается User, которого мы будем редактировать, с именем и конкретным городом. Дальше создается наш координатор с User и navigationController. У него вызывается метод start(). Координатор передается в локальные property — вот, в принципе, и все. Схема достаточно простая.

    Inputs and outputs


    Есть несколько моментов, на которых я бы хотел остановиться подробнее. Вы наверняка обратили внимание, что property в userEditViewController помечен комментарием, как Input, а коллбэки этих контроллеров помечены, как Output.



    Вход — это любые данные, которые могут измениться со временем, а также какие-то методы ViewController, которые можно вызвать снаружи. Например, в UserEditViewController это property User — может измениться сам User или его параметр City.

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

    В Objective-C я не очень любил писать сохранение коллбэков из-за их ужасного синтаксиса. Но в Swift с этим все гораздо проще. Использование коллбэков в данном случае — это альтернатива известного паттерна делегирования в iOS. Только тут, вместо того чтобы обозначать методы в протоколе и говорить, что координатор соответствует этому протоколу, и потом где-то отдельно писать эти методы, мы сразу можем очень удобно в одном месте создать сущность, проставить ей коллбэк и все это сделать.

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

    От этого можно избавиться таким же образом, как и в делегировании, с помощью протоколов.



    Чтобы избежать связанности, мы можем закрыть Input и Output нашего контроллера протоколом.

    Выше протокол CitiesOutput, у которого есть ровно одно требование — наличие коллбэка onCitySelected. Слева — аналог этой схемы на Swift. Наш контроллер соответствует этому протоколу, определяя у себя необходимый коллбэк. Делаем мы это для того, чтобы координатор не знал о существовании класса CitiesViewController. Но в какой-то момент ему понадобится сконфигурировать output у этого контроллера. Для того, чтобы все это провернуть, добавляем в координатор фабрику.



    У фабрики есть метод cityOutput(). Получается, что наш координатор не создает контроллер и не получает его откуда-то. Ему прокидывается фабрика, которая возвращает в методе закрытый протоколом объект, и он ничего не знает о том, какого класса этот объект.

    Теперь самое главное — зачем вообще все это делать? Зачем нам встраивать еще один дополнительный уровень, когда и так не было никаких проблем?

    Можно представить такую ситуацию: к нам придет менеджер и попросит сделать A/B-тестирование того, что вместо списка городов у нас появился бы выбор города на карте. Если бы в нашем приложении выбор города был не в одном месте, а в разных координаторах, в разных сценариях, нам пришлось в каждое место зашивать флажок, прокидывать его снаружи, по этому флажку поднимать либо один, либо другой ViewController. Это не очень удобно.

    Мы хотим из координатора это знание убрать. Поэтому можно было бы сделать это в одном месте. В самой фабрике мы бы сделали параметр, по которому фабрика возвращает закрытый протоколом либо тот, либо другой контроллер. У них у обоих был бы коллбэк onCitySelected, и координатору было бы, в принципе, не важно, с каким из этих экранов работать — с картой или списком.

    Composition VS Inheritance


    Следующий момент, на котором хотелось остановиться, это композиция против наследования.



    1. Первый метод, как можно сделать наш координатор — это сделать композицию, когда NavigationController передается ему снаружи и хранится локально как property. Это как бы композиция — мы в нее добавили NavigationController как property.
    2. С другой стороны, существует мнение, что в UI Kit и так все есть, и нам не нужно изобретать велосипед. Можно просто взять и наследовать UINavigationController.

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

    С моей точки зрения, самый главный аргумент заключается в том, что вы скрываете от конечного пользователя в композиции все ненужные ему методы. Получается, что у него меньше шансов оступиться. Вы оставляете только тот API, который необходим, например, метод Start — и все. У него нет возможности вызвать метод PushViewController, PopViewController, то есть как-то вмешаться в деятельность самого координатора. Все методы родительского класса скрыты.

    Storyboards


    Я считаю, что они заслуживают отдельного внимания вместе с segues. Лично я поддерживаю segues, так как они позволяют визуально быстро ознакомиться со сценарием. Когда приходит новый разработчик, ему не нужно лазить по коду, Storyboards в этом помогают. Даже если вы делаете интерфейс с кодом, вы можете оставить пустые ViewController, и верстать интерфейс с кодом, но оставить хотя бы переходы и всю суть. Вся суть Storyboards именно в самих переходах, а не в верстке UI.

    К счастью, подход с координаторами не ограничивает в выборе инструментов. Мы можем спокойно использовать координаторы вместе с segues. Но нужно помнить, что теперь мы не можем работать с segues внутри UIViewController.



    Поэтому мы должны в нашем классе переопределить метод onPrepareForSegue. Вместо того, чтобы делать что-то внутри контроллера, мы будем делегировать эти задачи опять же координатору, через коллбэк. Вызывается метод onPrepareForSegue, вы сами ничего не делаете — вы не знаете, что это за segue, какой там destination контроллер — вам это все не важно. Вы просто прокидываете это все в коллбэк, а координатор там разберется. У него есть это знание, вам это знание ни к чему.

    Для того, чтобы все было проще, можно сделать это в некоем Base классе, чтобы не переопределять его в отдельном каждом взятом контроллере. В таком случае координатору будет удобнее работать с вашими segues.

    Еще одна вещь, которую я нахожу удобной со Storyboard — это придерживаться правила, что один Storyboard равен одному координатору. Тогда можно сильно все упростить, сделать вообще один класс — StoryboardCoordinator, и у него генерировать параметр RootType, в Storyboard делать начальным контроллером Navigation и заворачивать в него весь сценарий.



    Как вы видите, здесь у координатора есть 2 property: navigationController; rootViewController нашего RootType generic-типа. При инициализации мы передаем ему не конкретный navigationController, а Storyboard, из которого достается наш корневой Navigation и его первый контроллер. Таким образом нам даже не нужно будет вызывать никаких методов Start. То есть вы создали координатор, у него сразу есть Navigation, и сразу есть Root. Вы можете либо Navigation показать модально, либо взять Root и запушить в существующую навигацию и дальше работать.

    Наш UserEditCoordinator в таком случае стал бы просто typealias, подставляющим в generic-параметр тип своего RootViewController.

    Передача данных обратно по сценарию


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



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

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

    Координатор упрощает эту задачу тем, что передает данные назад по сценарию — это теперь такая же простая задача, как и передача данных вперед по сценарию.

    Что здесь происходит? Пользователь выбирает какой-то город. Это сообщение отправляется координатору. Координатор, как я уже показывал в демо, проходится по всему navigation-стеку и всем заинтересованным лицам передает обновленные данные. Соответственно, ViewController могут обновить свой View с этими данными.

    Рефакторинг существующего кода


    Как рефакторить существующий код, если вы хотите внедрить этот подход в уже существующее приложение, где есть MVc, MVVm или MVp?



    У вас есть пачка ViewController. Первое, что нужно сделать — разделить их на сценарии, в которых они участвуют. В нашем примере есть 3 сценария: авторизация, редактирование профиля, лента.



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

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

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



    В нашем случае дерево простое: LoginCoordinator может стартовать координатор редактирования профиля. Здесь почти все встает на свои места, но остается очень важная деталь — у нашей схемы не хватает точки входа.



    Этой точкой входа будет особый координатор — ApplicationCoordinator. Его создает и стартует AppDelegate, и дальше он уже управляет логикой на уровне приложения, то есть тем, какой координатор стартует прямо сейчас.

    Только что мы рассматривали очень похожую схему, только на ней вместо координаторов были ViewController, и мы делали так, чтобы ViewController ничего друг о друге не знали и не передавали друг другу данных. С координаторами в принципе можно сделать то же самое. Мы можем обозначить у них некий Input (метод Start) и Output (коллбэк onFinish). Координаторы становится независимыми, переиспользуемыми и легко тестируемыми. Координаторы перестают знать друг о друге и общаются, например, только с ApplicationCoordinator.

    Нужно быть осторожным, потому что если в вашем приложении будет достаточно много этих сценариев, то ApplicationCoordinator может превратиться в огромный god-объект, будет знать о всех существующих сценариях — это тоже не очень здорово. Тут надо уже смотреть —возможно, разбить координаторы на подкоординаторы, то есть продумать такую архитектуру, чтобы эти объекты не разрастались до невероятных размеров. Хотя размер — это не всегда повод для рефакторинга.

    Откуда начать


    Я советую начинать снизу вверх — сначала реализовать отдельные сценарии.



    Как временное решение их можно стартовать внутри UIViewController. То есть пока у вас нет ни Root, ни других координаторов, вы можете делать один координатор и, как временное решение, стартануть его из UIViewController, сохранив его локально в property (как выше есть nextCoordinator). Когда происходит какое-то событие, вы, как я показывал в демо, создаете локальное property, кладете туда координатор и вызываете у него метод Start. Все очень просто.

    Потом, когда уже сделали все эти координаторы, старт одного внутри другого выглядит абсолютно точно также. У вас есть локальное property или какой-то массив зависимостей типа coordinator, вы туда все это складываете, чтобы никуда не убежало, и вызываете метод Start.

    Итог


    • Независимые экраны и сценарии, которые ничего друг о друге не знают, друг с другом не общаются. Мы этого и пытались добиться.
    • Легко менять порядок экранов в приложении без изменения кодов экранов. Если все сделано, как нужно, единственное, что должно измениться в приложении при изменении сценария, это не код экранов, а код координатора.
    • Упрощается передача данных между экранами и другие задачи, которые подразумевают под собой связь между экранами.
    • Лично мной самый любимый момент — чтобы начать его применять, вам не нужно добавлять в проект никаких сторонних зависимостей и разбираться в чужом коде.

    AppsConf 2018 уже 8 и 9 октября — не пропусти! Скорее изучай расписание (или обзор по нему) и бронируй билеты. Естественно большое внимание обеим платформам — iOS и Android, плюс к этому доклады по архитектуре, которые не привязаны только к одной технологии, и обсуждение других важных вопросов, связанных с миром вокруг мобильной разработки.
    • +36
    • 6,5k
    • 7

    Конференции Олега Бунина (Онтико)

    350,00

    Конференции Олега Бунина

    Поделиться публикацией
    Комментарии 7
      0

      Господи, как же все сложно.
      Я у себя сделал некое подобие intent'а андроидовского: все в одном месте и сразу. Делю максимально на различные story board'ы и все прекрасно работает

        +1
        Предложите ваш вариант реализации в отдельной статье на хабре. Возможно, ваш подход действительно окажется удобнее, так почему бы не показать ваши навыки и не поделиться знаниями через собственную полезную статью?
        0
        Спасибо, история со сценариями обернутыми в локаторы понравилась.
          0
          Спасибо за публикацию! Я считаю это довольно интересный паттерн, и использую его в текущем проекте. Но во время реализации столкнулся со следующей проблемой: если пушить root контроллер координатора, то пользователь может нажать на кнопку back и контроллер будет убран из стека, но application coordinator продолжает держать ссылку на созданный координатор. Получается ситуация буквально «хвост виляет собакой». Как Вы решили эту проблему?
            0
            В оригинальном видео об этом сказали вскользь. Я постараюсь пояснить.
            Если я вас правильно понял, то у вас имеется App Coordinator и, скажем, Feature Coordinator. App Coordinator создает и держит ссылку на Feature Coordinator. Как только пользователь закончит работать с Feature Coordinator и нажмет системную кнопку назад (back button), нам необходимо убрать feature coordinator из App Coordinator.
            Системное нажатие назад можно определить следующим образом:
            override func willMove(toParentViewController parent: UIViewController?) {
                super.willMove(toParentViewController:parent)
                if parent == nil {
                    // пользователь нажал назад
                }
            }

            Теперь надо сообщить об этом Feature Coordinator обычным способом, вызвав переданный closure/callback. На этом этапе Feature Coordinator знает, что пользователь уже ушел с активного экрана и дальше уже нечего делать. Теперь надо сообщить об этом App Coordinator. Сделать это можно через delegate, callback или Notification Center. К примеру, на этапе инициализации Feature Coordinator передать callback:
            init(navigationController: UINavigationController, onFinish: (FeatureCoordinator)-> ()) {} 
            0
            Вынести логику из жирных вьюконтроллеров идея не плохая, но дополнительные сущности увеличивают энтропию системы в целом, со всеми вытекающими отсюда последствиями… И в начале статьи упоминалось, что для смены кадров не придется переписывать код, но далее по презентации видно что это не так. Т.е. код править в любом случае придётся, а значит дополнительных преимуществ такой подход не даёт. Быть может я что-то упустил?
              0
              Код менять всегда придется, само собой ничего не произойдет. Однако одно дело — править код в единственном месте (в координаторе, максимум — в нем и плюс еще в рутовом, который может удерживать на него ссылку), а совсем другое — во всех местах, связанных с данным контроллером (когда у вас 5 экранов реализуют один и тот же код вызова контроллера — править придется 5 мест).

              Это всегда две стороны одной медали. Чем больше разделение ответственности — тем сложнее структура системы, но тем проще ее модификация, сопровождение и тестирование (при условии, что человек разобрался в архитектуре, конечно же, и реализует ее корректно). И наоборот, чем меньше разделение — тем проще ориентироваться в структуре проекта, тем меньше требуемая квалификация (пока багов нет и контроллеры не разрослись выше 1000 строк), однако тем тяжелее и трудозатратнее становится расширять систему. Совместить «лучшее из лучших» не получится, во всяком случае, на данный момент ничего не придумано. Предложенный подход с координаторами — нечто среднее по сложности между MVC/MVP и VIPER, некий компромисс, кроме того, ее сложность можно подстроить под проект, это уже зависит от навыка архитектора. Лично мне подход нравится.

              На мой взгляд, «серебряной пули» в виде идеальной архитектуры нет и быть не может, под каждую задачу есть свое подходящее решение. Реализовывать тяжеловесный VIPER в двухэкранном приложении — дико избыточная и нерентабельная затея, но и писать огромное банковское приложение на базе традиционного MVC — тоже так себе идея. Если утрировать, шуруп можно и молотком забить, но лучше все же воспользоваться отверткой.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое