Як зробити навігацію в iOS-застосунках. Розглядаємо плюси та мінуси різних підходів
Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!
Привіт, мене звати Андрій Боднар, і я iOS-розробник в OBRIO. Компанія входить в екосистему бізнесів Genesis і розвиває чотири продуктові напрями: Mobile, Web, GameDev, SaaS. Застосунок Nebula, над яким я працюю, посідає перше місце серед астрологічних застосунків у США.
У цій статті ми розглянемо кілька способів, як зробити навігацію, і поговоримо про їхні переваги та недоліки. Стаття буде корисною початківцям, які тільки починають свій шлях у розробці під iOS або вже мають деякий досвід і цікавляться популярними архітектурами в Mobile-світі.
Навряд чи в мобільній розробці існує задача, яка має більшу кількість реалізацій, ніж навігація. І це зрозуміло, адже чи багато ви можете згадати застосунків, що мають лише один екран?
Власне, навігація в iOS-застосунках — це опис того, яким чином ми переходимо з одного екрана (UIViewController, далі будемо просто називати його «контролером») на інший.
Перше і, мабуть, найпростіше, з чим ознайомлюються розробники-початківці, — це використання segue для навігації. Щоб відкрити новий екран у такий спосіб, достатньо додати новий контролер на сторіборді, створити segue, обравши тип переходу (show/present/etc.), задати ідентифікатор і викликати в коді метод performSegue(withIdentifier:sender:), передаючи як параметр той самий ідентифікатор.
Плюси цього способу:
- простота;
- налаштування на сторіборді: завдяки візуалізації початківцю легше зрозуміти процес переходу на новий екран.
Мінусів, на жаль, значно більше, але основні такі:
- забагато можливостей для виникнення технічних помилок: один одрук, і маємо застосунок, що вилітає замість того, щоб відкритись;
- незручна передача будь-яких даних з першого контролера на другий, за допомогою prepare(for:sender:);
- потреба створювати декілька segues, якщо ми хочемо в різних випадках відображати інший екран модально чи додавати до стека UINavigationController.
У налаштуванні навігації вагоме значення має обрана архітектура застосунку. Тож далі ми будемо розглядати реалізацію на прикладі конкретної архітектури.
Традиційно Apple пропонує розробникам використовувати MVC (Model-View-Controller), на що досить прямо натякає назва базового класу, що використовується для навігації в застосунку — UIViewController. Саме цей клас вміє відкривати інший екран (контролер). У коді це має такий вигляд:
let storyboard = UIStoryboard(name: "StoryboardName", bundle: nil) let controller = storyboard.instantiateViewController(withIdentifier: "MyNextViewController") navigationController?.pushViewController(controller, animated: true) // додаємо контролер до стеку UINavigationController - перший приклад нижче present(controller, animated: true) // відображаємо новий контролер модально - другий приклад
У цього способу, безумовно, більше плюсів:
- відносна простота реалізації;
- стиль представлення нового екрана не залежить від початкового налаштування переходу (segue), можна визначати його динамічно, спираючись на нашу логіку;
- для передачі даних з першого контролера на другий не потрібно реалізовувати ще один метод prepare(for segue: UIStoryboardSegue, sender: Any?), завдяки чому кількість коду не збільшується через кількість реалізованих переходів.
Мінуси:
- можуть виникати краші в рантаймі: для цього достатньо лише неправильно вказати назву сторіборду або забути проставити контролеру ідентифікатор;
- передачу даних від контролера до контролера навряд чи можна назвати безпечною;
- контролер знає про класи інших контролерів, що не надто відповідає принципу єдиної відповідальності (Single Responsibility Principle).
Фактично ми вирішили лише проблему привʼязки стилю відображення до створення переходу і трохи спростили передачу даних між контролерами.
Далі розглянемо винесення логіки навігації з контролера на прикладі архітектури MVP (Model-View-Presenter). Контролер стає більш пасивним обʼєктом, що передає події презентеру, який реалізовує потрібну логіку за допомогою сервісів і повідомляє контролер про необхідність зміни UI.
Складові частини архітектури MVP
Module (Assembly, Assembler) — створює всі необхідні складники модуля, його залежності, звʼязує все та вміє повертати контролер без конкретного типу (як створюється контролер — за допомогою сторібордів, xib чи коду, не має принципового значення для архітектури та навігації):
protocol BaseModule { func controller() -> UIViewController }
Model — пасивна модель, містить у собі всі дані, які потрібні для конкретного екрана.
View — займається відображенням і передачею подій від користувача (або системи) до презентера:
protocol BaseView: AnyObject { func present(module: BaseModule) func push(module: BaseModule) func close() ... }
За допомогою extension можемо додати дефолтну реалізацію для методів навігації:
extension BaseView where Self: UIViewController { func present(module: BaseModule) { present(module.controller(), animated: true) } func push(module: BaseModule) { navigationController?.pushViewController(module.controller(), animated: true) } func close() { if let navigationController = navigationController { navigationController.popViewController(animated: true) } else { dismiss(animated: true) } } }
Presenter — має сервіси, які ізолюють в собі певну частину бізнес-логіки, тримає посилання на View (закрите протоколом), повідомляє йому про потребу змінити стан, створює новий модуль і викликає методи View для навігації.
У такому разі для відкриття нового екрана контролер отримує подію, наприклад натискання кнопки, викликає метод презентеру, який може мати вигляд:
func nextScreenButtonTapped() { let nextModule = NextModule() view?.present(module: nextModule) }
Плюси реалізації:
- навігація стає абстрактною, має дефолтну реалізацію, яка може бути перевизначена та розширена в тих модулях, де це потрібно;
- контролер та інші складові модуля створюються та звʼязуються за допомогою окремого обʼєкта;
- контролер нічого не знає про існування інших контролерів;
- якщо використовуємо альтернативні сторібордам механізми створення контролерів, ризик отримати критичні помилки в роботі застосунку відчутно зменшується;
- передача даних з одного модуля до іншого стає більш очевидною, безпечною та зручною.
Мінуси:
- побудова навігації в такий спосіб є складнішою для початківців, доведеться реалізувати кілька модулів, аби розкласти в голові все по поличках.
- презентер має схильність накопичувати надто багато коду, якщо не декомпозувати складні екрани та не виносити бізнес-логіку на рівень сервісів.
Тепер настав час винести логіку навігації до окремого обʼєкта — Router. Ви могли вже чути про такі архітектури, як MVVM+Coordinator чи VIPER, в яких реалізується те, що нам потрібно. А ще могли бачити десятки статей на тему «Як ми переписали наш застосунок на VIPER і які проблеми вирішили завдяки цьому» або «Як ми позбулися VIPER в нашому застосунку і чому дихати стало легше».
Так, ця архітектура викликає в розробників цілу палітру емоцій, але саме на її прикладі будемо далі розбиратися, як навігація ізолюється в окремому обʼєкті.
VIPER, або View-Interactor-Presenter-Entity-Router, що ж воно таке? Складники:
View — як і у вищенаведеному прикладі, займається представленням і передачею подій від користувача (або системи) до презентера. Спілкується з ним через протоколи ViewInput і ViewOutput для передавання та отримання подій:
protocol SomeModuleViewInput: AnyObject { func setupInitialState() func configure() ... } final class SomeModuleViewController: UIViewController, SomeModuleViewInput { var output: SomeModuleViewOutput! func setupInitialState() { } func configure() { } ... }
Interactor — фасад для сервісів, отримує події від презентера, викликає відповідні методи в сервісах і повертає результат чи помилку до презентера, з яким спілкується через протоколи InteractorInput та InteractorOutput:
protocol SomeModuleInteractorInput { func doSomeMagic() ... } final class SomeModuleInteractor: SomeModuleInteractorInput { weak var output: SomeModuleInteractorOutput? private let someService: SomeServiceProtocol private let anotherService: AnotherServiceProtocol func doSomeMagic() { } }
Presenter — отримує події від контролера та вирішує, кому передати управління далі: інтерактору для виконання логіки чи роутеру для подальшої навігації. Є зв’язувальною ланкою між усіма частинами модуля:
protocol SomeModuleViewOutput { func viewIsReady() func viewDidAppear() ... } protocol SomeModuleInteractorOutput: AnyObject { func magicDone() func magicErrorDidOccur(_ error: Error) ... } final class SomeModulePresenter: SomeModuleViewOutput, SomeModuleInteractorOutput { weak var view: SomeModuleViewInput? var interactor: SomeModuleInteractorInput! var router: SomeModuleRouterInput! func viewIsReady() { } func viewDidAppear() { } ... func magicDone() { } func magicErrorDidOccur(_ error: Error) { } ... }
Entity — за аналогією до MVP — пасивна модель, містить у собі всі дані, які потрібні для конкретного екрана.
Router — відповідає за навігацію, вміє закривати поточний екран і відкривати нові, отримує події від презентера через протокол RouterInput:
protocol SomeModuleRouterInput { func close() func openNextModule(with model: SomeModel) ... } final class SomeModuleRouter: SomeModuleRouterInput { weak var view: SomeModuleViewInput? func close() { } func openNextModule(with model: SomeModel) { } ... }
Як і в прикладі з MVP, через extension можна налаштувати базову навігацію в протоколі Router, щоб не писати для кожного роутера свою реалізацію методів, які постійно використовуються: dismiss, push тощо.
Крім того, щоб звʼязати всі складові частини модуля, створюється Assembler:
enum SomeModuleModuleAssembler { static func makeModule() -> UIViewController { let view = SomeModuleViewController() let router = SomeModuleRouter() router.view = view let presenter = SomeModulePresenter() presenter.view = view presenter.router = router let interactor = SomeModuleInteractor() interactor.output = presenter presenter.interactor = interactor view.output = presenter return view } }
Якщо ми говоримо про VIPER, то навігація тут працює так: контролер отримує подію та передає її презентеру, той, своєю чергою, знає, що у відповідь на цю подію потрібно виконати дію, повʼязану з навігацією, та передає управління роутеру, який містить у собі методи на кшталт такого:
func openNextModule() { let nextModule = NextModuleAssembler.makeModule() present(nextModule, animated: true) }
Усі плюси, перелічені для архітектури MVP, актуальні для VIPER, до них можемо додати такий:
- навігація зібрана в одному місці та винесена в окремий обʼєкт, контролери не знають один про одного, тож ми стали на крок ближчими до принципу єдиної відповідальності
Мінуси:
- високий поріг входу: розробнику з мінімальним досвідом, тим паче без нього, потрібен час, щоб розібратися, хто за що відповідає, хто кому що передає тощо;
- значна кількість коду, який потрібен для створення мінімального VIPER-модуля, якщо порівняти з іншими архітектурами (кодогенерація допоможе розв’язати це питання);
- у великих проєктах, які тримають усю кодову базу в одному таргеті, використання VIPER’у може негативно впливати на час компіляції навіть через кількість файлів, потрібних для кожного модуля.
Висновки
У цій статті ми розглянули кілька варіантів того, як налаштувати навігацію в iOS-застосунках. Як відомо, «срібної кулі» не буває, кожен з підходів має плюси та мінуси, а обрати найкращий для конкретного застосунку — завдання розробника.
На нашому проєкті ми використовуємо VIPER, за навігацію відповідають роутери, у планах є дещо змінити, але як саме та чому — розповім у наступній статті.
Якщо ви початківець, напишіть у коментарях, які у вас були чи є проблеми з побудовою навігації. Будемо розбиратися разом.
Якщо ви досвідчений розробник, поділіться, як влаштована навігація у вас, з якими проблемами стикались і як їх розв’язували, чи використовуєте фреймворки для цього?
13 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів