Як зробити навігацію в iOS-застосунках. Розглядаємо плюси та мінуси різних підходів

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

Привіт, мене звати Андрій Боднар, і я 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, за навігацію відповідають роутери, у планах є дещо змінити, але як саме та чому — розповім у наступній статті.

Якщо ви початківець, напишіть у коментарях, які у вас були чи є проблеми з побудовою навігації. Будемо розбиратися разом.

Якщо ви досвідчений розробник, поділіться, як влаштована навігація у вас, з якими проблемами стикались і як їх розв’язували, чи використовуєте фреймворки для цього?

👍НравитсяПонравилось12
В избранноеВ избранном6
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

А чи можете порадити якісь інструкції з дизайну додатків для ios? Може є щось схоже на material.io?

Я переглядав, але там більше загальні рекомендації. У матеріал все більше роз’яснено. Ну добре.

У мене є більш просте питання: як придбати акаунт розробника? Вже два тижні не можу: при оплаті якась помилка, техпідтримка намагалсь допомогти, але вже й вона мовчить і не відповідає.

Спробуй провести оплату на напряму зі своєї картки, а через PayPal, в мене іноді бувають схожі проблеми с платежами на іноземних сайтах, в 99% випадків це допомагає.

use SwiftUI, Luke. Там значно простіше, як на мене. І сучасніше.

Да конечно, особенно когда «архитекторы» со своим Redux доберутся.

Так вже. google://redux+for+swiftui Навіть курси на udemy існують, я не кажу вже про medium, dev.to, habr та інфлюенсерів. Глобальний стейт як глобальні змінні треба використовувати з розумом. А не «як попало».

незручна передача будь-яких даних з першого контролера на другий, за допомогою prepare(for:sender:);

Можливо воно загубилося за всіми Combine та SwiftUI, але там нишком оновили на приємніший варіант через @IBSegueAction для iOS 13+
Changelog

Дякую, дійсно, інші апдейти були більш «гучними», тому це трохи повз мене пройшло.

Якщо якомусь підходу потрібна кодогенерація для того, щоб він працював, для мене це означає недостатній рівень
От той же роутер, нащо він SomeRouter? Не можна створити 1 generic роутер на всіх? Купа сутностей сильно зв‘язана одна з одною, а от SRP страждає, оскільки купа коду це форвардинг реквестів до інших сутностей.

Якщо зробити один роутер на всіх, то він буде знати про всю навігацію та стане своєрідним god object’ом. Якщо ж залишити йому лише логіку показу/закриття модулів, то хто буде відповідати за їх створення?

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