Эта статья - логическое продолжение нашего погружения в архитектуру. Если в первой части мы навели порядок внутри «черного ящика» под названием ViewModel, то здесь мы выйдем за его пределы. Вы узнаете, как выпилить логику переходов из ViewControllers и ViewModels, почему вызов navigationController?.pushViewController() прямо в экшене кнопки - это архитектурный тупик, и как построить систему навигации, которая не превратит ваш проект в спагетти при добавлении десятого экрана. Мы разберем концепцию Child Coordinators, решим проблему утечек памяти (спойлер: системная кнопка "Назад" - ваш враг) и обсудим, выжил ли этот паттерн в эпоху SwiftUI.
Если MVVM отвечает за то, что происходит внутри экрана, то Coordinator - это ответ на вопрос «куда мы идем дальше?».
В стандартном подходе Apple навигация зашита прямо в UIViewController. Это удобно для маленьких демо-проектов из туториалов, но в реальном продакшене это приводит к тому, что контроллеры знают друг о друге слишком много. Когда LoginViewController создает HomeViewController, вы получаете жесткую связность. Попробуйте потом изменить флоу или переиспользовать LoginViewController в другом месте - и вы поймете, почему это плохая идея. Это как если бы каждая комната в вашей квартире знала, какая комната идет следующей. Переставить мебель? Удачи.
Я считаю, что контроллер должен быть эгоистом. Он не должен знать, откуда он пришел и куда пойдет. Его дело - отобразить данные и сообщить, что пользователь нажал кнопку. Всё. Остальное - не его забота.
Проблема: захардкоженная навигация
Давайте честно: кто из нас не писал что-то подобное прямо в обработчике нажатия кнопки?
@objc private func loginButtonTapped() { // Валидация... let homeVC = HomeViewController() homeVC.user = self.user homeVC.apiClient = self.apiClient homeVC.database = self.database navigationController?.pushViewController(homeVC, animated: true) }
На первый взгляд всё выглядит нормально. Но проблемы начинаются, когда:
Нужно изменить логику: Если пользователь не авторизован, ведем на один экран, если авторизован, но не заполнил профиль - на другой. Писать это внутри контроллера - значит раздувать его логику до размеров дирижабля.
Dependency Injection: Чтобы создать следующий контроллер, текущему нужно передать в него зависимости (базу данных, API-клиент, аналитику, логгер, нотификации...). Откуда текущему контроллеру их взять? Хранить у себя «про запас»? Поздравляю, ваш контроллер теперь склад зависимостей для всех последующих экранов.
Deep Linking: Попробуйте реализовать переход на глубоко вложенный экран из Push-уведомления, если вся навигация зашита в контроллерах. Это будет цепочка из
if-elseи костылей, после которой захочется сменить профессию.Тестирование: Как вы протестируете навигацию, если она размазана по десятку контроллеров? Правильно, никак. Или напишете UI-тесты на несколько дней и будете молиться, чтобы они прошли.
Базовая реализация
Координатор - это простой объект, который инкапсулирует логику создания контроллеров и управления их жизненным циклом. Начнем с базового протокола.
protocol Coordinator: AnyObject { var childCoordinators: [Coordinator] { get set } var navigationController: UINavigationController { get set } func start() }
Зачем нам childCoordinators? Это критически важный момент для управления памятью. Если вы просто создадите координатор и не сохраните на него ссылку, он умрет сразу после выхода из метода. Массив childCoordinators держит их «в живых», пока идет работа с конкретным флоу. Это как держать детей за руку на прогулке - отпустите, и они разбегутся (а в нашем случае - будут деаллоцированы).
Метод запуска
Метод start() - это точка входа. Никакой магии, просто создание нужного экрана и его отображение. Вот пример координатора для флоу авторизации:
final class AuthCoordinator: Coordinator { var childCoordinators: [Coordinator] = [] var navigationController: UINavigationController // Делегат для связи с родителем (например, AppCoordinator) weak var parentCoordinator: AppCoordinator? // Зависимости, которые мы будем пробрасывать во ViewModels private let authService: AuthService private let analyticsService: AnalyticsService init( navigationController: UINavigationController, authService: AuthService, analyticsService: AnalyticsService ) { self.navigationController = navigationController self.authService = authService self.analyticsService = analyticsService } func start() { let viewModel = LoginViewModel( authService: authService, analyticsService: analyticsService ) // Вот здесь мы связываем VM и Coordinator viewModel.coordinator = self // Создаем контроллер с версткой кодом let viewController = LoginViewController(viewModel: viewModel) navigationController.pushViewController(viewController, animated: true) } // Методы навигации, которые вызывает ViewModel func showRegistration() { let viewModel = RegistrationViewModel( authService: authService, analyticsService: analyticsService ) viewModel.coordinator = self let viewController = RegistrationViewController(viewModel: viewModel) navigationController.pushViewController(viewController, animated: true) } func showForgotPassword() { let viewModel = ForgotPasswordViewModel(authService: authService) viewModel.coordinator = self let viewController = ForgotPasswordViewController(viewModel: viewModel) navigationController.pushViewController(viewController, animated: true) } // Когда авторизация завершена успешно func didFinishAuth(user: User) { parentCoordinator?.authDidFinish(with: user) } }
Обратите внимание: координатор знает о структуре флоу, но контроллеры ничего не знают друг о друге. LoginViewController даже не подозревает о существовании RegistrationViewController. Красота!
Создание контроллера
Давайте посмотрим, как выглядит типичный UIViewController:
final class LoginViewController: UIViewController { // MARK: - Properties private let viewModel: LoginViewModel // MARK: - UI Components private lazy var emailTextField: UITextField = { let textField = UITextField() // здесь и далее только обозначаю создание элемента return textField }() private lazy var passwordTextField: UITextField = { let textField = UITextField() //... return textField }() private lazy var loginButton: UIButton = { let button = UIButton(type: .system) //... button.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside) return button }() private lazy var registerButton: UIButton = { let button = UIButton(type: .system) //... button.addTarget(self, action: #selector(registerButtonTapped), for: .touchUpInside) return button }() // MARK: - Initialization init(viewModel: LoginViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() setupUI() setupBindings() } // MARK: - Setup private func setupUI() { view.backgroundColor = .systemBackground title = "Welcome" view.addSubview(emailTextField) view.addSubview(passwordTextField) view.addSubview(loginButton) view.addSubview(registerButton) NSLayoutConstraint.activate([ //размещаем элементы на экране ]) } private func setupBindings() { // Биндинг к ViewModel (в реальном проекте можно использовать Combine или RxSwift) viewModel.onError = { [weak self] error in self?.showError(error) } viewModel.onLoading = { [weak self] isLoading in self?.loginButton.isEnabled = !isLoading // Можно показать индикатор загрузки } } // MARK: - Actions @objc private func loginButtonTapped() { viewModel.login( email: emailTextField.text ?? "", password: passwordTextField.text ?? "" ) } @objc private func registerButtonTapped() { viewModel.showRegistration() } private func showError(_ message: String) { let alert = UIAlertController( title: "Error", message: message, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "OK", style: .default)) present(alert, animated: true) } }
Ключевой момент: контроллер не знает ничего о навигации. Он просто вызывает методы ViewModel, а та уже решает, что делать дальше через координатор.
Коммуникация: ViewModel → Coordinator
Как ViewModel говорит координатору «пора переходить»? Я предпочитаю два способа:
Closures: Просто и эффективно для маленьких проектов.
Delegates: Более структурировано и привычно для iOS-разработки.
Лично я в последнее время склоняюсь к замыканиям, потому что это уменьшает количество шаблонного кода (boilerplate). Но если флоу сложный и событий много, делегат выглядит чище.
Способ 1: Замыкания
final class LoginViewModel { // Координатор (weak, чтобы избежать retain cycle) weak var coordinator: AuthCoordinator? // Замыкания для обновления UI var onLoginSuccess: (() -> Void)? var onError: ((String) -> Void)? var onLoading: ((Bool) -> Void)? private let authService: AuthService private let analyticsService: AnalyticsService init(authService: AuthService, analyticsService: AnalyticsService) { self.authService = authService self.analyticsService = analyticsService } func login(email: String, password: String) { onLoading?(true) authService.login(email: email, password: password) { [weak self] result in guard let self = self else { return } self.onLoading?(false) switch result { case .success(let user): self.analyticsService.track(event: "login_success") // Говорим координатору, что пора переходить дальше self.coordinator?.didFinishAuth(user: user) case .failure(let error): self.analyticsService.track(event: "login_failed") self.onError?(error.localizedDescription) } } } func showRegistration() { coordinator?.showRegistration() } }
Способ 2: Делегат
protocol AuthCoordinatorDelegate: AnyObject { func didFinishAuth(user: User) func showRegistration() func showForgotPassword() } final class LoginViewModel { weak var coordinator: AuthCoordinatorDelegate? // ... остальной код func login(email: String, password: String) { // ... логика авторизации coordinator?.didFinishAuth(user: user) } }
Выбирайте то, что вам нравится. Главное - не смешивать оба подхода в одном проекте, а то получится архитектурный зоопарк.
Кошмар кнопки "Назад"
А теперь самая большая боль в UIKit при использовании координаторов - это системная кнопка «Назад». Представьте ситуацию:
Пользователь открывает экран регистрации
AuthCoordinatorдобавляетRegistrationCoordinatorвchildCoordinatorsПользователь нажимает системную кнопку "Назад"
RegistrationViewControllerудаляется из памятиНо
RegistrationCoordinatorвсё еще висит в массивеchildCoordinators
Поздравляю, у вас утечка памяти! Координатор живёт вечно, хотя экран давно закрыт. Повторите это раз 20, и вот уже ваше приложение жрет памяти как браузер Chrome.
Есть три способа это решить:
Решение 1: UINavigationControllerDelegate (рекомендую)
Самый надежный способ - следить за стеком через делегат навигации:
final class AuthCoordinator: NSObject, Coordinator { var childCoordinators: [Coordinator] = [] var navigationController: UINavigationController override init(navigationController: UINavigationController) { self.navigationController = navigationController super.init() // Подписываемся на делегат навигации navigationController.delegate = self } // ... остальной код} // MARK: - UINavigationControllerDelegate extension AuthCoordinator: UINavigationControllerDelegate { func navigationController( _ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool ) { // Получаем предыдущий контроллер guard let fromViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else { return } // Если этот контроллер НЕ в стеке - значит, пользователь ушел назад if !navigationController.viewControllers.contains(fromViewController) { // Проверяем, какой координатор нужно убрать if fromViewController is RegistrationViewController { childCoordinators.removeAll { $0 is RegistrationCoordinator } } } } }
Это требует чуть больше кода, но зато работает надежно и сохраняет нативное поведение системы (включая swipe to back).
Решение 2: Кастомная кнопка назад (не рекомендую)
func start() { let viewController = LoginViewController(viewModel: viewModel) // Убираем системную кнопку viewController.navigationItem.hidesBackButton = true // Добавляем свою let backButton = UIBarButtonItem( image: UIImage(systemName: "chevron.left"), style: .plain, target: self, action: #selector(backButtonTapped) ) viewController.navigationItem.leftBarButtonItem = backButton navigationController.pushViewController(viewController, animated: true) } @objc private func backButtonTapped() { // Тут мы точно знаем, что пользователь уходит childCoordinators.removeAll { $0 is RegistrationCoordinator } navigationController.popViewController(animated: true) }
Проблема: вы ломаете нативный UX. Swipe to back перестает работать (если не добавите еще и UIGestureRecognizerDelegate), кнопка выглядит не совсем как нативная. Пользователи это чувствуют, даже если не могут объяснить, что не так.
Решение 3: Обертка над UIViewController
Можно создать базовый класс, который уведомляет координатор о своей "смерти":
class CoordinatedViewController: UIViewController { var onDismiss: (() -> Void)? override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) // Проверяем, что мы действительно убраны из стека if isBeingDismissed || isMovingFromParent { onDismiss?() } } }
Но это тоже не идеально: срабатывает не только при "назад", но и при модальных переходах, да и наследование контроллеров - это скользкая дорожка.
Мой вердикт: используйте первый способ с UINavigationControllerDelegate. Да, код чуть длиннее, но зато он работает предсказуемо и не ломает UX.
Родители и дети: иерархия координаторов
Представьте приложение как дерево. В корне - AppCoordinator. Он решает, запустить AuthCoordinator или MainCoordinator. Это как главный дирижер оркестра - сам не играет, но управляет всеми.
final class AppCoordinator: Coordinator { var childCoordinators: [Coordinator] = [] var navigationController: UINavigationController private let window: UIWindow private let authService: AuthService private let analyticsService: AnalyticsService init(window: UIWindow) { self.window = window self.navigationController = UINavigationController() // Инициализируем сервисы self.authService = AuthService() self.analyticsService = AnalyticsService() // Настраиваем навигацию navigationController.navigationBar.prefersLargeTitles = true window.rootViewController = navigationController window.makeKeyAndVisible() } func start() { if authService.isUserLoggedIn { showMainFlow() } else { showAuthFlow() } } private func showAuthFlow() { let authCoordinator = AuthCoordinator( navigationController: navigationController, authService: authService, analyticsService: analyticsService ) authCoordinator.parentCoordinator = self childCoordinators.append(authCoordinator) authCoordinator.start() } private func showMainFlow() { // Очищаем стек навигации navigationController.viewControllers = [] let mainCoordinator = MainCoordinator( navigationController: navigationController, authService: authService, analyticsService: analyticsService ) mainCoordinator.parentCoordinator = self childCoordinators.append(mainCoordinator) mainCoordinator.start() } // Вызывается AuthCoordinator'ом после успешного логина func authDidFinish(with user: User) { // Удаляем AuthCoordinator из списка детей childCoordinators.removeAll { $0 is AuthCoordinator } // Запускаем основной флоу showMainFlow() } // Вызывается MainCoordinator'ом при логауте func didLogout() { // Удаляем MainCoordinator childCoordinators.removeAll { $0 is MainCoordinator } // Показываем авторизацию showAuthFlow() } }
Когда AuthCoordinator заканчивает работу (пользователь залогинился), он должен сообщить об этом родителю. Родитель удаляет его из childCoordinators, очищает стек и запускает следующий флоу. Это как смена актов в театре - один заканчивается, другой начинается.
DI: инъекция зависимостей на стероидах
Координатор - идеальное место для инъекции зависимостей. Вместо того чтобы пробрасывать NetworkService через пять контроллеров (и хранить его в каждом "на всякий случай"), вы держите его в координаторе и передаете только в ту ViewModel, которой он реально нужен.
final class MainCoordinator: Coordinator { // Зависимости живут здесь private let networkService: NetworkService private let databaseService: DatabaseService private let imageCache: ImageCacheService private let authService: AuthService init( navigationController: UINavigationController, networkService: NetworkService, databaseService: DatabaseService, imageCache: ImageCacheService, authService: AuthService ) { self.navigationController = navigationController self.networkService = networkService self.databaseService = databaseService self.imageCache = imageCache self.authService = authService } func showProductList() { // Передаем только то, что нужно этой конкретной ViewModel let viewModel = ProductListViewModel( networkService: networkService, imageCache: imageCache ) viewModel.coordinator = self let viewController = ProductListViewController(viewModel: viewModel) navigationController.pushViewController(viewController, animated: true) } func showProductDetail(productId: String) { // А здесь нужны другие зависимости let viewModel = ProductDetailViewModel( productId: productId, networkService: networkService, databaseService: databaseService, imageCache: imageCache ) viewModel.coordinator = self let viewController = ProductDetailViewController(viewModel: viewModel) navigationController.pushViewController(viewController, animated: true) } }
Это делает код безумно легким для тестирования. Вы можете создать координатор с MockNetworkService и проверить, правильно ли он отрабатывает ошибки:
func testAuthFlowOnNetworkError() { let mockNetwork = MockNetworkService() mockNetwork.shouldFail = true let coordinator = AuthCoordinator( navigationController: UINavigationController(), authService: AuthService(networkService: mockNetwork), analyticsService: MockAnalyticsService() ) coordinator.start() // Проверяем, что показывается нужный экран ошибки XCTAssertTrue(coordinator.navigationController.topViewController is ErrorViewController) }
Табы и модальные окна
Навигация - это не только push и pop. А как быть с UITabBarController и модальными окнами? Легко!
TabBar Coordinator
final class TabBarCoordinator: Coordinator { var childCoordinators: [Coordinator] = [] var navigationController: UINavigationController private let tabBarController: UITabBarController init() { self.navigationController = UINavigationController() self.tabBarController = UITabBarController() } func start() { // Создаем координаторы для каждого таба let homeNav = UINavigationController() let homeCoordinator = HomeCoordinator(navigationController: homeNav) homeCoordinator.start() let profileNav = UINavigationController() let profileCoordinator = ProfileCoordinator(navigationController: profileNav) profileCoordinator.start() let settingsNav = UINavigationController() let settingsCoordinator = SettingsCoordinator(navigationController: settingsNav) settingsCoordinator.start() // Сохраняем координаторы childCoordinators = [homeCoordinator, profileCoordinator, settingsCoordinator] // Настраиваем табы homeNav.tabBarItem = UITabBarItem( title: "Home", image: UIImage(systemName: "house"), selectedImage: UIImage(systemName: "house.fill") ) profileNav.tabBarItem = UITabBarItem( title: "Profile", image: UIImage(systemName: "person"), selectedImage: UIImage(systemName: "person.fill") ) settingsNav.tabBarItem = UITabBarItem( title: "Settings", image: UIImage(systemName: "gearshape"), selectedImage: UIImage(systemName: "gearshape.fill") ) tabBarController.viewControllers = [homeNav, profileNav, settingsNav] // TabBar становится root view controller navigationController.viewControllers = [tabBarController] } }
Модальные окна
Для модальных окон можно создать отдельный координатор:
final class ProductListCoordinator { func showFilterModal() { let filterNav = UINavigationController() let filterCoordinator = FilterCoordinator(navigationController: filterNav) filterCoordinator.delegate = self childCoordinators.append(filterCoordinator) filterCoordinator.start() // Показываем модально navigationController.present(filterNav, animated: true) } } extension ProductListCoordinator: FilterCoordinatorDelegate { func didApplyFilters(_ filters: [Filter]) { // Убираем координатор childCoordinators.removeAll { $0 is FilterCoordinator } // Закрываем модалку navigationController.dismiss(animated: true) // Обновляем список refreshProductList(with: filters) } func didCancelFilters() { childCoordinators.removeAll { $0 is FilterCoordinator } navigationController.dismiss(animated: true) } }
SwiftUI: Паттерн координатора мёртв?
С выходом NavigationStack в iOS 16 и NavigationPath Apple дала нам инструменты для управления навигацией на уровне состояния. Означает ли это смерть паттерна?
И да, и нет. В SwiftUI классический координатор, который держит UINavigationController, больше не нужен (и даже не имеет смысла). Но концепция отделения логики переходов от View никуда не делась. Теперь мы часто называем это Router или Navigator.
Вместо манипуляций с контроллерами, Router управляет NavigationPath (массивом данных, представляющих стек):
// Определяем возможные экраны enum Route: Hashable { case login case registration case home case productDetail(id: String) case profile(userId: String) } class AppRouter: ObservableObject { @Published var path = NavigationPath() @Published var presentedSheet: Route? // Навигация через push func navigateTo(_ route: Route) { path.append(route) } // Навигация через sheet func presentSheet(_ route: Route) { presentedSheet = route } // Возврат назад func pop() { if !path.isEmpty { path.removeLast() } } // Возврат в корень func popToRoot() { path = NavigationPath() } } // Использование в SwiftUI struct ContentView: View { @StateObject private var router = AppRouter() var body: some View { NavigationStack(path: $router.path) { LoginView() .navigationDestination(for: Route.self) { route in viewForRoute(route) } } .environmentObject(router) .sheet(item: $router.presentedSheet) { route in viewForRoute(route) } } @ViewBuilder private func viewForRoute(_ route: Route) -> some View { switch route { case .login: LoginView() case .registration: RegistrationView() case .home: HomeView() case .productDetail(let id): ProductDetailView(productId: id) case .profile(let userId): ProfileView(userId: userId) } } } // Во View просто вызываем методы роутера struct LoginView: View { @EnvironmentObject var router: AppRouter var body: some View { VStack { // ... UI Button("Login") { // После успешного логина router.navigateTo(.home) } Button("Register") { router.navigateTo(.registration) } } } }
Это тот же координатор, просто переодетый в современные одежды SwiftUI. Суть не изменилась: отделить логику навигации от View.
Deep Linking: когда всё тикает как часики
Помните, я говорил про Push-уведомления? Вот где координаторы действительно блистают:
extension AppCoordinator { func handleDeepLink(_ url: URL) { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true), let host = components.host else { return } switch host { case "product": // URL: myapp://product?id=123 if let productId = components.queryItems?.first(where: { $0.name == "id" })?.value { showProductDetail(productId: productId) } case "profile": // URL: myapp://profile?userId=456 if let userId = components.queryItems?.first(where: { $0.name == "userId" })?.value { showProfile(userId: userId) } case "settings": // URL: myapp://settings showSettings() default: break } } private func showProductDetail(productId: String) { // Убеждаемся, что пользователь авторизован guard authService.isUserLoggedIn else { // Сохраняем намерение и показываем авторизацию pendingDeepLink = .product(id: productId) showAuthFlow() return } // Находим или создаем нужный координатор if let mainCoordinator = childCoordinators.first(where: { $0 is MainCoordinator }) as? MainCoordinator { mainCoordinator.showProductDetail(productId: productId) } else { showMainFlow() // После создания MainCoordinator показываем продукт (childCoordinators.last as? MainCoordinator)?.showProductDetail(productId: productId) } } }
Попробуйте реализовать такое без координаторов. Спойлер: это будет боль и страдания.
Антипаттерны, которых следует избегать
За годы работы я видел разные извращения с координаторами. Вот топ-3 ошибок, которые точно не стоит повторять:
1. God Coordinator
// ❌ ПЛОХО: один координатор для всего приложения final class AppCoordinator { func showLogin() { /* ... */ } func showRegistration() { /* ... */ } func showHome() { /* ... */ } func showProductList() { /* ... */ } func showProductDetail() { /* ... */ } func showCart() { /* ... */ } func showCheckout() { /* ... */ } func showProfile() { /* ... */ } func showSettings() { /* ... */ } func showEditProfile() { /* ... */ } func showChangePassword() { /* ... */ } func showNotifications() { /* ... */ } func showAbout() { /* ... */ } // ... еще 50 методов }
Не делайте так. Это антипаттерн "God Object" во всей красе. Разделяйте на логические блоки (Auth, Main, Profile, Settings, Shop и т.д.). Каждый координатор должен управлять своим флоу.
2. Passing ViewControllers
// ❌ ПЛОХО: координатор возвращает контроллер func createLoginViewController() -> UIViewController { let viewModel = LoginViewModel() return LoginViewController(viewModel: viewModel) } // В другом месте let vc = coordinator.createLoginViewController()navigationController?.pushViewController(vc, animated: true)
Координатор никогда не должен возвращать UIViewController наружу. Он должен сам его показывать или пушить. Иначе теряется весь смысл: кто-то снаружи начинает управлять навигацией.
3. Strong References to Parents
// ❌ ПЛОХО: strong reference создает retain cycle final class AuthCoordinator { var parentCoordinator: AppCoordinator? // Должно быть weak! }
Всегда используйте weak для ссылок на родительские координаторы. Иначе вы создадите цикл сильных ссылок (retain cycle):
Parent держит child в массиве
childCoordinators(strong)Child держит parent в свойстве
parentCoordinator(strong)Никто никогда не освободится из памяти
Ваше приложение превращается в дырявое ведро
4. Coordinator знает о View
// ❌ ПЛОХО: координатор дергает методы UI final class AuthCoordinator { func loginSuccess() { let vc = navigationController.topViewController as? LoginViewController vc?.showSuccessAnimation() // НЕТ! vc?.clearFields() // Да ну НЕТ же, прекратите! } }
Координатор должен знать только о других координаторах и о том, как создавать ViewControllers. Он не должен дергать методы UI напрямую. Для этого есть ViewModel.
Тестирование: наконец-то это возможно
Одно из главных преимуществ координаторов - возможность тестировать навигацию. Да-да, ту самую навигацию, которую раньше можно было проверить только руками (или UI-тестами, которые падают от малейшего чиха).
final class MockNavigationController: UINavigationController { var pushedViewControllers: [UIViewController] = [] var presentedViewControllers: [UIViewController] = [] override func pushViewController(_ viewController: UIViewController, animated: Bool) { pushedViewControllers.append(viewController) super.pushViewController(viewController, animated: animated) } override func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)? = nil) { presentedViewControllers.append(viewControllerToPresent) super.present(viewControllerToPresent, animated: animated, completion: completion) } } // Тест func testAuthFlowNavigation() { let mockNavigation = MockNavigationController() let mockAuthService = MockAuthService() let coordinator = AuthCoordinator( navigationController: mockNavigation, authService: mockAuthService, analyticsService: MockAnalyticsService() ) coordinator.start() // Проверяем, что показали LoginViewController XCTAssertTrue(mockNavigation.pushedViewControllers.first is LoginViewController) // Эмулируем переход на регистрацию coordinator.showRegistration() // Проверяем, что показали RegistrationViewController XCTAssertTrue(mockNavigation.pushedViewControllers.last is RegistrationViewController) XCTAssertEqual(mockNavigation.pushedViewControllers.count, 2) } func testSuccessfulAuthLeadsToMainFlow() { let mockParent = MockAppCoordinator() let coordinator = AuthCoordinator(/* ... */) coordinator.parentCoordinator = mockParent let user = User(id: "123", name: "Test") coordinator.didFinishAuth(user: user) // Проверяем, что родитель получил уведомление XCTAssertTrue(mockParent.authDidFinishCalled) XCTAssertEqual(mockParent.receivedUser?.id, "123") }
Красота! Чистые unit-тесты без запуска UI.
Подводя итог
Координатор - это не просто лишний слой кода ради галочки "у нас чистая архитектура". Это свобода:
Свобода менять экраны местами за пять минут, не раскапывая десяток контроллеров
Свобода тестировать навигацию отдельно от UI
Свобода не видеть
navigationController?.pushViewController()в обработчиках кнопокСвобода от захардкоженных зависимостей в каждом контроллере
Свобода легко реализовать Deep Linking и универсальные ссылки
Свобода спать спокойно, зная, что добавление нового экрана не превратит проект в спагетти
Да, это требует дисциплины. Да, нужно написать чуть больше протоколов. Да, придется разобраться с управлением памятью и той самой кнопкой "Назад". Но в проекте, который живет дольше пары месяцев, это окупается сторицей.
Помните: контроллер должен быть эгоистом. Пусть координатор решает, куда идти дальше. А контроллер пусть занимается своим делом - показывает красивый UI и реагирует на тапы пользователя. Это разделение ответственности, о котором говорят все умные книжки по архитектуре, но которое так редко применяется на практике.
И да, если кто-то из ваших коллег спросит: "А зачем нам все эти координаторы? Мы же 5 лет жили без них!" - покажите им этот код:
// Было @objc func buttonTapped() { let nextVC = NextViewController() nextVC.apiClient = self.apiClient nextVC.database = self.database nextVC.analytics = self.analytics nextVC.imageCache = self.imageCache nextVC.userSession = self.userSession navigationController?.pushViewController(nextVC, animated: true) } // Стало @objc func buttonTapped() { viewModel.handleButtonTap() }
Вопросы отпадут сами собой.
