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

Автор: admin от 3-10-2018, 14:50, посмотрело: 90

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



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



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


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





демопроект доступен на 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.



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


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



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



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



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



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



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


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



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



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


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



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



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



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



Composition VS Inheritance



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



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



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

  • С другой стороны, существует мнение, что в UI Kit и так все есть, и нам не нужно изобретать велосипед. Можно просто взять и наследовать UINavigationController.



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



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



    Storyboards



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



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



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


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



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



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



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


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



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



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



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



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


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



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



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



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



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



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



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


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



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


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



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



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



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


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



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


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



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



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



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



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



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


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



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



    Итог




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


    • Легко менять порядок экранов в приложении без изменения кодов экранов. Если все сделано, как нужно, единственное, что должно измениться в приложении при изменении сценария, это не код экранов, а код координатора.


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


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




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



    Источник: Хабр / Интересные публикации

    Категория: Операционные системы » iOS

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

    Добавление комментария

    Имя:*
    E-Mail:
    Комментарий:
    Полужирный Наклонный текст Подчеркнутый текст Зачеркнутый текст | Выравнивание по левому краю По центру Выравнивание по правому краю | Вставка смайликов Выбор цвета | Скрытый текст Вставка цитаты Преобразовать выбранный текст из транслитерации в кириллицу Вставка спойлера
    Введите два слова, показанных на изображении: *