Использование Enum + Associated Values при навигации и передаче данных между экранами в IOS приложениях

от автора

В этом посте мне бы хотелось затронуть извечный вопрос об организации навигации и передачи данных между экранами в IOS приложениях. В первую очередь, я хотел бы донести концепт своего подхода, а не убедить вас использовать его как волшебную таблетку. Тут не будут рассматриваться различные архитектурные подходы или возможность использования UlStoryboard с segues, в целом я опишу еще один возможный способ достигнуть желаемого со своими плюсами и минусами. И так, начнем!

Предыстория:

Безусловно, на реализацию навигации и организацию транспорта данных в проекте влияет выбор архитектурного подхода, однако и сам подход складывается из ряда обстоятельств: состав команды, time to market, состояние ТЗ, масштабируемость проекта и многое др., определяющими факторами для меня стали:

  • обязательное использование MVVM;
  • возможность быстро добавлять новые экраны(контроллеры, и их вью модели) в процесс навигации;
  • изменения в бизнес- логике не должны затрагивать навигацию;
  • изменения в навигации не должны затрагивать бизнес-логику;
  • возможность быстро переиспользовать экраны без внесения исправлений в навигацию;
  • возможность быстро получить представление о существующих экранах;
  • возможность быстро получить представление о зависимостях в проекте;
  • не повысить порог вхождения разработчиков на проект.

Ближе к делу

Стоит отметить, что конечное решение было сформировано не за один день, не лишено своих минусов и подходит скорее для маленьких и средних проектов. Для наглядности, тестовый проект можно посмотреть тут: github.com/ArturRuZ/NavigationDemo

1. Чтобы была возможность быстро получить представление о существующих экранах было принято решение завести enum с однозначным названием ControllersList.

enum ControllersList {    case textInputScreen    case textConfirmationScreen }

2. По ряду причин в проекте не хотелось использовать сторонние решения для DI, a DI получить хотелось, в том числе с возможностью быстрого просмотра зависимостей в проекте, поэтому было решено использовать Assembly для каждого отдельного экрана (закрытого протоколом Assembly) и RootAssembly – в качестве общего scope.

 protocol Assembly {    func build() -> UIViewController }  final class TextInputAssembly: Assembly {    func build() -> UIViewController {       let viewModel = TextInputViewModel()       return TextInputViewController(viewModel: viewModel)    } }  final class TextConfirmationAssembly: Assembly {    private let text: String        init(text: String) {       self.text = text    }        func build() -> UIViewController {       let viewModel = TextConfirmationViewModel(text: text)       return TextConfirmationViewController(viewModel: viewModel)    } } 

3. Для передачи данных между экранами(там, где это действительно необходимо) ControllersList превратился в enum с Associated Values:

enum ControllersList {    case textInputScreen    case textConfirmationScreen(text: String) }

4. Для того чтобы ни бизнес-логика не влияла на навигацию, ни навигация на бизнес-логику, а также для быстрого переиспользования экранов, потребовалось навигацию вынести в отдельный слой. Так появился Coordinator и протокол Coordination:

 protocol Coordination {    func show(view: ControllersList, firstPosition: Bool)    func popFromCurrentController() }  final class Coordinator {        private var navigationController = UINavigationController()    private var factory: ControllerBuilder?        private func navigateWithFirstPositionInStack(to: UIViewController) {       navigationController.viewControllers = [to]    }    private func navigate(to: UIViewController) {       navigationController.pushViewController(to, animated: true)    } }  extension Coordinator: Coordination {    func popFromCurrentController() {       navigationController.popViewController(animated: true)    }    func show(view: ControllersList, firstPosition: Bool) {       guard let controller = factory?.buildController(for: view) else { return }                  firstPosition ?  navigateWithFirstPositionInStack(to: controller) : navigate(to: controller)    } }  

Тут важно отметить, что протокол может описывать больше методов, в т.ч. как и Coordinator может реализовывать различные протоколы, в зависимости от нужд.

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

6. Совсем не хотелось создавать дополнительные роутеры и координаторы. Более того, создание дополнительной логики для навигации могло значительно усложнить как восприятие навигации, так и переиспользование экранов. Все это привело к цепочке изменений, которые в конечном итоге выглядели следующим образом:

 //MARK - Dependences with controllers associations fileprivate extension ControllersList {    typealias scope = AssemblyServices       var assembly: Assembly {       switch self {       case .textInputScreen:          return TextInputAssembly(coordinator: scope.coordinator)       case .textConfirmationScreen(let text):          return TextConfirmationAssembly(coordinator: scope.coordinator, text: text)       }    } }  //MARK - Services all time in memory fileprivate enum AssemblyServices {    static let coordinator: СoordinationDependencesRegstration = Coordinator()    static let controllerFactory: ControllerBuilderDependencesRegistration = ControllerFacotry() }  //MARL: - RootAssembly Implementation final class  RootAssembly {    fileprivate typealias scope = AssemblyServices       private func registerPropertyDependences() { //     this place for propery dependences    } }   // MARK: - AssemblyDataSource implementation extension RootAssembly: AssemblyDataSource {    func getAssembly(key: ControllersList) -> Assembly? {       return key.assembly    } } 

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

Заключение

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

Из недостатков данного подхода можно выделить следующее:

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

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

Возможно и вам для решения задач подойдет такой подход, спасибо за уделённое время!

ссылка на оригинал статьи https://habr.com/ru/post/525434/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *