Модульная архитектура. Как создать навигационный модуль

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привет! Я Александр Кругляк, Head of iOS Department с больше чем 6-летним стажем в программировании. Преподаю курс мобильной разработки с нуля для iOS в Triare Education. Спасибо всем, кто вдохновляет меня: моим читателям, моим коллегам и наставникам. В предыдущих статьях мы говорили о многом: что такое модульная архитектура и как я к этому пришел.

Парадокс в том, что, публикуя статью, я не уверен, что это эффективный способ чему-либо научиться. Сейчас объясню. Технологии очень быстро развиваются. Видео и статьи — это максимально удобный и быстрый способ на высоком уровне понять и принять эти изменения; они помогают идти в ногу со временем. В обыкновении эта информация — результат работы человека, который написал статью или снял видео с подготовленным, переработанным и адаптированным материалом/информацией. То есть, просматривая видео или читая 5-минутную статью, пользователь данного контента получает уже сжатую информацию с кратким умозаключением автора.

Это замечательно. Но что нам делать, если мы хотим прыгнуть с вершины в океан и коснуться его глубины? Все верно: нам необходимо читать книгу. Книга имеет несколько иной формат подачи материала. Книга учит мыслить как автор, ведь в ней он излагает все свои взгляды, приводит доводы, разглагольствует и заставляет читателя думать. Книга сеет зерно идеи в голову и час за часом позволяет этому семени превратиться в древо знаний. Я всегда выбираю книги, а вы? Возможно, это лирическое отступление даст оправдание моему столь художественному описанию статей о модульной архитектуре.

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

Неужели наконец-то будет код? Да!

Замысел

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

Задача: расширить логику навигации внутри приложения и описать удобный способ манипуляции и доступа к каждому отдельному уровню навигации.

Говоря о навигации в мобильных приложениях, мы можем выделить два основных типа. Первый — это линейная навигация, в iOS она реализуется средствами UINavigationController. Второй — это навигация с разветвлением, которая реализуется с помощью UITabBarController. Мы начнем описание логики линейного навигации и плавно перейдем к навигации с разветвлением.

Вопрос и быстрый ответ: на каком уровне происходит захват экрана? Правильно, на уровне UIWindow. Значит, мы должны захватить этот объект и передать в наш модуль для управления им. Навигация первой прослойки будет происходить именно на уровне UIWindow. Давайте обозначим имя объекта, который будет отвечать за данный уровень навигации. Предлагаю NavigationRouter! У читателей обычно нет выбора в именовании классов, а порой стоило бы его им давать.

Изначально это будет объект со свойством, которое будет в себе хранить тот самый UIWindow.

public class NavigationRouter {
private var window: UIWindow!
}

Как мы могли заметить, мы определили свойство window с уровнем доступа private. Никто, кроме нас, не должен пытаться изменить этот уровень навигации. Но возможность создавать объект NavigationRouter со значением window мы все же оставим.

public class NavigationRouter {
private var window: UIWindow!
 
public init(in window: UIWindow) {
     	  	self.window = window
  	}
}

Ну круто! Мы захватили окно нашей программы, мы Доктор Зло! Но что теперь с ним делать? Что в нем отображать? Нам стоит создать объект, который будет отвечать за каждый отдельный модуль навигации. Задача не из простых. Давайте начнем над ним работу шаг за шагом. Имя ему будет NavigationModule.

Этот объект будет отвечать за линейную навигацию, а как мы уже определили, за линейную навигацию в iOS отвечает UINavigationController. Давайте определим нашему объекту NavigationModule поле с типом UINavigationController да и сразу его инициализируем, чего нам отлаживать эту задачу?

open class NavigationModule {
	private(set) var navigationController: UINavigationController = UINavigationController()
}

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

public class NavigationRouter {
private var window: UIWindow!
 
private var currentModule: NavigationModule?
 
public init(in window: UIWindow) {
     	  	self.window = window
  	}
}

Объект уровня навигации UIWindow теперь хранит в себе состояние текущего навигационного модуля, который отображается пользователю в данный момент времени. Но как же нам изменить флоу? Как нашему NavigationModule сообщить на уровень выше, что пора завершать работу и запускать новый модуль? На помощь к нам спешит делегат. Делегат мы назовем очень просто: NavigationRouterDelegate, который поможет NavigationModule сообщить нашему NavigationRouter, что необходимо сменить флоу.

Давайте опишем наш протокол NavigationRouterDelegate.

public protocol NavigationRouterDelegate {
   	 func startNextNavigationModule()
}

Мы имеем доступ к главному уровню навигации, к текущему уровню линейной навигации, но снова перед нами дилемма... На основе чего, нам строить нашу навигацию? Нам нужна модель и нам по-любому нужен UIViewController.

Предлагаю создать свой объект для работы с текущим отображением и унаследовать его от UIViewController. Имя ему будет NavigationModuleViewController.

open class NavigationModuleViewController: UIViewController {
	public var navigationModule: NavigationModule?
	
	required public init(navigationModule: NavigationModule? = nill) {
    	    self.navigationModule = navigationModule
      	    super.init(nibName: String(describing: type(of: self)), bundle: nil)
 	 }
    
 required public init(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

Если внимательно читать код, мы увидим, что в нашем унаследованном объекте есть поле var navigationModule: NavigationModule?. Это свойство нам необходимо для того, чтобы знать, какому навигационному модулю принадлежит UIViewController, а точнее, теперь уже NavigationModuleViewController.

Ура, товарищи! Мы создали уровень текущего отображения на экране пользователя. Теперь необходимо его совместить с нашим навигационным модулем. Хорошо бы.... Хм... Если подумать, то было бы очень хорошо, чтобы наш NavigationModuleViewController создавался только в тот момент, когда в нем есть необходимость. Для этого мы создадим NavigationModel, которая будет отвечать за хранение типа нашего NavigationModuleViewController и создавать его в тот момент, когда к нему будет обращаться наш NavigationModule. Идея хорошая, давайте попробуем реализовать.

public class NavigationModel {
    private(set) var initialViewControllerType: NavigationModuleViewController.Type
    
    public init(initialViewControllerType: NavigationModuleViewController.Type) {
        self.initialViewControllerType = initialViewControllerType
    }
 
    func buildComponent(in navigationModule: NavigationModule) -> UINavigationController {
 return UINavigationController.init(rootViewController: initialViewControllerType.init(navigationModule: navigationModule))
 }
 
}

Много буков, поэтому давайте распишем. Тип нашего NavigationModuleViewController хранит в себе описание объекта, который будет создан в дальнейшем, а за само создание отвечает метод buildComponent(in:).

Пришла очередь собрать пазл в одну картинку.

Обновим наш NavigationRouterDelegate. Укажем в методе startNextNavigationModule новый параметр, который будет описывать, в какой же флоу нам стоит дальше двигаться.

public protocol NavigationRouterDelegate {
   	func startNextNavigationModule(with navigationModel: [NavigationModel])
}

Не удивляйтесь, что параметр, который принимает данный метод, имеет тип массива. В дальнейшем это сыграет нам на руку. К тому же мы всегда можем передать один элемент в этом массиве и всегда можем проверить и извлечь значение из опционала Array.first?, если таковое имеется.

Следующим шагом будет обновление объекта NavigationModule. Он должен использовать делегат, чтобы передать значение на уровень выше, то есть на уровень NavigationRouter. Так же он обязан хранить наши NavigationModel — на основе этих данных он будет строить текущее отображение экрана пользователя.

open class NavigationModule {
    private(set) var navigationController: UINavigationController = UINavigationController()
 
    private(set) var navigationRouterModuleDelegate: NavigationRouterDelegate!
 
    private(set) var navigationModels: [NavigationModel]?
 
 
public required init(navigationRouterModuleDelegate: NavigationRouterDelegate, navigationModels: [NavigationModel]?) {
       		 self.navigationRouterModuleDelegate = navigationRouterModuleDelegate
      	 	 self.navigationModels = navigationModels
    	}
 
public func endFlow(with nextNavigationModel: [NavigationModel]) {
       		 navigationRouterModuleDelegate.startNextNavigationModule(with: nextNavigationModel)
   	 }
} 

Функция endFlow(with:) сообщает о том, что наше флоу завершило свой цикл и необходимо его заменить на следующее. Именно в этой функции мы используем вызов метода делегата.

Теперь необходимо подготовить на NavigationRouter, который будет принимать сигнал о том, что необходимо изменить флоу.

public class NavigationRouter {
    
    private var window: UIWindow!
    
    private var currentModule: NavigationModule?
    
    public init(in window: UIWindow) {
        self.window = window
    }
    
    func changeNavigationModule(with navigationModels: [NavigationModel]) {
        let navigationModuleAbstract = NavigationModule.init(navigationRouterModuleDelegate: self, navigationModels: navigationModels)
        
        currentModule = navigationModuleAbstract
        self.window.rootViewController = navigationModuleAbstract.startFlow()
    }
 
    
}
 
extension NavigationRouter: NavigationRouterDelegate {
    
    public func startNextNavigationModule(with navigationModel: [NavigationModel]) {
        changeNavigationModule(with: navigationModel)
    }
}

Метод changeNavigatoinModule(with: ) генерирует новый навигационный флоу и записывает его на уровень rootViewController у объекта window. Также мы задаем новое состояние параметру currentModule.

Внимание вопрос: что делает метод startFlow()? Откуда он? Инсайты сообщают, что ответ есть. startFlow() запускает цепочку последующих экранов.

Задача проста: нашему окну следует понимать, что ему отображать. Именно метод startFlow() объекта NavigationModule дает нам это понимание. Предлагаю рассмотреть его реализацию:

open class NavigationModule {
………...
func startFlow(_ object: Any? = nil) -> UINavigationController? {
        guard let navigationModels = navigationModels else { return nil }
        return builder(for: navigationModels, with: object)
    }
    
    func builder(for navigationModels: [NavigationModel], with object: Any?) -> UINavigationController {
        if let navigationModel = navigationModels.first {
            navigationController = navigationModel.buildComponent(in: self, with: object)
        }
        return navigationController
    }
 
…………….
}

Метод startFlow() проверяет, есть ли необходимая информация для отображения. Другими словами, на основании какой информации ему отображать данные. Следующим шагом вызывает метод builder(for: with:). Как ранее и упоминалось, в данном контексте мы говорим о линейной навигации, поэтому нам достаточно проверить первый объект из списка наших навигационных моделей на наличие в нем данных. Последним пунктом закидываем NavigationModuleViewController, который нам вернул метод buildComponent(in: with:), в navigationController и возвращаем уже готовую структуру на уровень окна в rootViewController.

Цепочка замкнулась. Мы управляем навигацией на уровне UIWindow и жонглируем нашими линейными флоу навигации. Но наша цепочка не простая, а магическая, нам следует все же оставить точку входа на уровне UIWindow. Мы должны сгенерировать и записать исходную точку отправления наших флоу навигации. Для этого напишем всего один метод в нашем NavigationRouter.

 public func startNavigationModuleFrom(_ navigationModel: NavigationModel) {
        let navigationModuleAbstract = NavigationModule.init(navigationRouterModuleDelegate: self, navigationModels: [navigationModel])
        window.rootViewController = navigationModuleAbstract.startFlow()
        
        currentModule = navigationModuleAbstract
    }

Все просто, не так ли? Далее поговорим о внутренней навигации... Да, не оставлю я недосказанности. Метод startNavigationModuleFrom задает первоначальный NavigationModule, который является входной точкой в приложение. Это может быть модуль авторизационного флоу или модуль начального экрана.

На данном этапе мы имеем полный жизненный цикл нашей навигации на уровне UIWindow, на уровне смены флоу. А что с уровнем линейной навигации? В моей перспективе — это такой себе враппер над объектом UINavigationController. Давайте посмотрим на реализацию методов NavigationModule, которые отвечают за линейную навигацию.

public func pushViewController<T : NavigationModuleViewController>(_ viewController: T.Type) {
        
        let viewController = viewController.init(navigationModule: self)
        viewController.object = object
        navigationController.pushViewController(viewController, animated: true)
    }
    
    public func popViewController() {
        navigationController.popViewController(animated: true)
    }

Прочитав код, мы обратим внимание на то, что метод pushViewController принимает не объект, а его тип. Мне нравится, когда мы можем инициализировать объекты позже. У нас есть возможность задать себе вопрос: а надо ли нам этот объект?

Как и полагается в хороших сериалах, на самом интересном моменте появляется надпись «to be continued». Но прежде чем завершить первую часть повествования о модуле навигации, давайте посмотрим, как нам использовать линейный навигационный модуль.

Открываем наш любимый SceneDelegate и пишем следующий код:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
 
    var window: UIWindow?
 
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let _ = (scene as? UIWindowScene) else { return }
        
        guard let mainWindow = window else { return }
    
        let navigationRouter = NavigationRouter.init(in: mainWindow)
        let navigationModel = NavigationModel.init(initialViewControllerType: MainViewController.self)
        navigationRouter.startNavigationModuleFrom(navigationModel)
    }
}

Я не обманул, все просто: мы захватили навигацию на уровне UIWindow, задали нашей навигации первый отображаемый объект, а точнее его тип MainViewController. Запустили.

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

Спасибо всем, кто просмотрел, прочитал, прошел мимо!

Приложение: структура объектов:

👍НравитсяПонравилось1
В избранноеВ избранном1
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Подписаться на комментарии