|
Professor Seleznov
|
 Навигация во Flutter — это постоянные компромиссы. Сначала кажется всё просто: push и pop. А потом проект растёт, появляются табы, вложенные модули, диплинки — и выясняется, что каждый следующий экран открывается по‑разному, а pop() в одном месте ведёт себя не так, как в другом. Navigator 1.0 прост и понятен, но при масштабировании рассыпается. Navigator 2.0 даёт полный контроль, но требует столько бойлерплейта, что проще изобрести свой фреймворк. Сообщество это поняло — и появились пакеты поверх Navigator 2.0. go_router упрощает жизнь, но недавно перешёл в режим поддержки: только баг‑фиксы, никаких новых фич. auto_route даёт type‑safety, но тянет за собой кодогенерацию. Мы прошли через все эти варианты в процессе разработки Яндекс Про — приложения для водителей и курьеров, где навигация включает сотни фич, несколько команд, вложенные модули, табы, диплинки и legacy‑код на Navigator 1.0. А ещё — сложную логику переходов, где точный контроль над состоянием навигации не просто желателен, а критичен: экран закрывается там, где не должен, стек оказывается в неожиданном состоянии, и разобраться в причинах через стандартный API почти невозможно. Первым кандидатом стал go_router — на то были веские причины: пакет разрабатывался Flutter‑командой, хорошо поддерживался, имел большое сообщество и де‑факто считался рекомендуемым выбором для Navigator 2.0. Казалось разумным обернуть его в удобный API и жить спокойно. Но в процессе обнаружили, что go_router принципиально не умеет обновлять соседний стек навигации в неактивной вкладке — для приложения с табами и фоновыми событиями это оказалось блокером. Типовой сценарий разберём ниже. И ни одно из существующих решений не закрывало наши потребности полностью. Так появился yx_navigation — новый пакет в нашей экосистеме архитектурных решений для Flutter, после yx_scope (DI) и yx_state (управление состоянием). Дальше расскажу, с какими трудностями мы столкнулись, какие требования сформулировали, как устроен yx_navigation и как именно он решает проблемы крупных приложений. - Предыстория: навигация в Яндекс Про Яндекс Про — это платформа выполнения заказов. Водители, курьеры и другие исполнители — каждое направление со своими фичами, экранами и сценариями. Разработкой занимается несколько команд, каждая отвечает за свой модуль. И каждая когда‑то выбирала свой способ навигации. Со временем это превратилось в коллекцию всевозможных решений — и породило целый букет проблем:
- Разнобой подходов. Одна команда работала с go_router, другая — напрямую с Navigator 1.0, третья экспериментировала с Navigator 2.0. Единого подхода не было, и при стыковке модулей возникали «интересные» баги.
- Непрозрачность состояния навигации. В произвольный момент было невозможно понять, что сейчас вообще открыто, какие экраны в стеке. Синхронизировать бизнес‑логику с состоянием навигации было крайне затруднительно — интерактор мог запросить переход, не зная, в каком контексте находится приложение.
- Несколько способов открыть один и тот же экран. Разные вызовы давали разное поведение: анимация, свайп, стиль перехода. Разработчик должен был помнить, какой способ «правильный» для конкретного контекста. Material в одном месте, Cupertino в другом — и да пребудет с вами знание, где, что и как.
- Проблемы сpop()и выходом из фичи. Закрыть экран и вернуться назад — казалось бы, всё тривиально. Но при вложенных навигаторах, модальных окнах и табах логика pop() становилась непредсказуемой: выход из фичи мог сломать стек родительского навигатора или повлиять на соседнюю вкладку. «Почему после закрытия диалога закрылся соседний экран?» — классический вопрос при разборе багов.
Что не так с существующими решениями Мы детально изучили существующие пакеты навигации — go_router, auto_route, beamer, routemaster, octopus и другие — и даже попытались использовать go_router как основу с обёрткой поверх. Вот что получилось:
- go_router перешёл в maintenance mode: только баг‑фиксы, никакого развития. Для нас это неприемлемо — мы вкладываемся в архитектуру на годы вперёд. Помимо этого, go_router не поддерживает изоляцию фич: все маршруты живут в едином дереве, и любая часть приложения может навигироваться куда угодно. Навигация привязана к BuildContext — из бизнес‑логики управлять ею нельзя.
- auto_route — зрелый пакет, но завязан на кодогенерацию. В крупном проекте build_runner — это боль: десятки секунд на каждый запуск, конфликты сгенерированных файлов при мёржах, дополнительный шаг в CI/CD. И те же проблемы: нет изоляции фич, нет управления навигацией из бизнес‑логики без BuildContext.
- beamer и routemaster — интересные альтернативы, но ни одна не решает ключевую для нас задачу: дать нескольким командам возможность работать над изолированными фичами с собственной навигацией, вкладывать их друг в друга, иметь единое реактивное состояние и не зависеть от UI‑контекста.
- Octopus заслуживает отдельного упоминания: декларативный роутер с деревом состояния, вложенной навигацией и guards — по духу нам близок, пакет сделан грамотно. Но на pub.dev он до сих пор версии 0.0.9, то есть без гарантий стабильности API для продукта нашего масштаба. И главное: он не закрывает весь набор наших требований — ту же изоляцию фич между командами приходится добивать снаружи.
Требования к нашему решению yx_navigation На основе обсуждений в рабочей группе мы собрали список требований — то, без чего навигация в нашем масштабе нежизнеспособна:
- Универсальность. Решение должно одинаково работать в отдельной фиче и в корневом приложении.
- Модульность. Навигационный модуль одной фичи можно вложить в другой — и получить общее дерево навигации.
- Изолированность. Модуль знает только о своих маршрутах и не может влиять на состояние навигации родителя или соседей.
- Реактивность. В любой момент можно получить текущее состояние навигации, подписаться на его изменения и управлять навигацией через мутации состояния.
- Business Logic First. Управлять навигацией можно из бизнес‑логики, без зависимости от BuildContext.
- Guards. Контроль мутаций состояния: проверка авторизации, прав доступа, валидация данных перед переходом.
- Поддержка диплинков. Связь URI и навигации: восстановление состояния из ссылки и произвольная обработка входящих URI, в том числе без смены экрана.
- Поддержка диалогов и оверлеев. Bottom sheet'ы, диалоги и оверлеи управляются аналогично стеку навигации.
- Поддержка табов. Управление табами и nested‑навигация в пределах табов или PageView.
- Совместимость с Navigator 1.0. Legacy‑код с Navigator.push() и showDialog() продолжает работать без переписывания.
Архитектура yx_navigation Как и yx_scope с yx_state, пакет yx_navigation разделён на две части:
- yx_navigation — чистый Dart без зависимостей на Flutter (то, что принято называть flutter agnostic). Маршруты, состояние, мутации, guards, сериализация — всё, что можно покрыть unit‑тестами без запуска приложения.
- yx_navigation_flutter — Flutter‑обвязка: виджеты, Router delegate, page factories, debug‑инструменты и прочее.
Такое разделение продиктовано практической необходимостью. Бизнес‑логика получает событие (например, «пришёл заказ») и должна обновить состояние навигации. Ей не нужен BuildContext, ей нужен доступ к дереву маршрутов. Чистый Dart даёт всё необходимое и избавляет от зависимости от Flutter. Состояние навигации — это дерево Центральная идея yx_navigation проста: состояние навигации — это дерево. Не стек (как у большинства пакетов), не плоский location, а именно дерево узлов RouteNode.

Текущее состояние навигации в определённый момент как дерево узлов Каждый узел содержит:
- route — идентификатор маршрута (YxRoute);
- arguments — сериализуемые параметры (например, {'orderId': '123'});
- extra — несериализуемые объекты (полезно на период миграции);
- children — список дочерних узлов.
Каждый раз, когда мы используем привычное нам API navigator.push(route, arguments), мы создаём новый узел RouteNode или выполняем мутацию текущего узла:

Операция push(route) создаёт новый узел RouteNode в дереве состояния Список дочерних узлов children интерпретируется по‑разному — в зависимости от типа родительского маршрута. Это может быть стек навигации: дети строятся один поверх другого как обычные страницы. Или табы: дети отображаются как вкладки в IndexedStack. Или вложенные навигаторы: каждый ребёнок представляет собой изолированную ветвь со своим собственным стеком. Например, состояние «авторизованный пользователь, открыта главная страница с вкладками сообщений, картами и активным профилем» может выглядеть так:
Root └── Main (IndexedStack: tabs) ├── Messages ├── Map └── Profile (активный таб)

Дочерние узлы: рассматриваем дочерние узлы как табы в indexed stack Другой пример. Имеем состояние «авторизованный пользователь, открыта главная страница, за ней страницы сообщений, карт и профиля»:
Root └── Main (Outlet: pages) ├── Messages ├── Map └── Profile (активный экран)

Дочерние узлы: рассматриваем дочерние узлы как последовательность экранов в стеке навигации В коде это дерево состояния RouteNode:
const YxRoute(id: 'main').toNode( children: [ const YxRoute(id: 'messages').toNode(), const YxRoute(id: 'map').toNode(), const YxRoute(id: 'profile').toNode(), ], )
Как мы видим, такое состояние одинаково и для отображения стека экранов, и для реализации табов. Отличается только интерпретация дочерних узлов (нод) для маршрута main. За управление состоянием (RouteNode) отвечает RouteNodeStateManager. Состояние реактивно: RouteNodeStateManager предоставляет stream и state для чтения и подписки на изменения. Сразу к практике Давайте посмотрим на примере. Минимальный путь от нуля до работающей навигации — четыре шага. Шаг 1. Определяем маршруты
abstract class ProfileRoutes { static const home = YxRoute(id: 'profile-home'); static const driverProfile = YxRoute(id: 'profile-driver'); static const tripsHistory = YxRoute(id: 'profile-trips-history'); static const statistics = YxRoute(id: 'profile-statistics'); static const settings = YxRoute(id: 'profile-settings'); static const documents = YxRoute(id: 'profile-documents'); }
Шаг 2. Связываем маршрут с UI через декларацию RouteDeclaration связывает маршрут с виджетом:
final driverProfileDeclaration = RouteDeclaration.routeBuilder( route: ProfileRoutes.driverProfile, routeBuilder: RouteBuilder.widget( builder: (context, state) => const DriverProfilePage(), ), );
Тут мы определили, что если в дереве состояния появится узел с маршрутом ProfileRoutes.driverProfile, то для него будет построен виджет DriverProfilePage. Подробности о декларациях будут ниже. Но смысл понятен: связываем состояние навигации с конкретным UI‑представлением. Шаг 3. Собираем схему навигации RouterSchema — это то, что группирует декларации нашего приложения или фичи и определяет начальное состояние:
class ProfileNavigationSchema extends RouterSchema { ProfileNavigationSchema() : super( // здесь получаем корень дерева и задаём начальное состояние // в данном случае будет единственный дочерний узел ProfileRoutes.home initialNodeBuilder: (node) => node ..setChildren([ ProfileRoutes.home.toNode(), ]), ); @override List<RouteDeclaration> get declarations => [ // тут определим декларацию для маршрута ProfileRoutes.home RouteDeclaration.routeBuilder( route: ProfileRoutes.home, routeBuilder: RouteBuilder.widget( builder: (context, state) => const ProfileHomePage(), ), ), driverProfileDeclaration, // декларация профиля и другие tripsHistoryDeclaration, statisticsDeclaration, settingsDeclaration, documentsDeclaration, ]; }
Шаг 4. Запускаем После запуска увидим первым экран ProfileHomePage.
class _ProfileAppState extends State<ProfileApp> { late YxRouterConfig config; @override void initState() { super.initState(); final profileSchema = ProfileNavigationSchema(); config = profileSchema.build(); // строим RouterConfig } @override Widget build(BuildContext context) => MaterialApp.router( routerConfig: config, ); @override void dispose() { config.dispose(); // не забываем очистить ресурсы super.dispose(); } }
profileSchema.build() создаёт RouterConfig со всеми необходимыми компонентами Navigator 2.0: RouterDelegate, RouteInformationParser, RouteInformationProvider, BackButtonDispatcher. Если хотим открыть другой экран, нам потребуется RouteNavigator. Получить его мы можем из контекста. Дальше используем знакомые по Navigator 1.0 операции push, pop и прочие (мы называем их «примитивы»):
final routeNavigator = YxNavigation.navigatorOf(context); // Перейти на экран routeNavigator.push(ProfileRoutes.driverProfile); // Вернуться назад routeNavigator.pop(); // Проверить, можно ли вернуться final canPop = routeNavigator.canPop();
Навигатор и RouteNodeStateManager YxNavigation.navigatorOf(context) отдаёт RouteNavigator — узкий контракт с операциями в духе Navigator 1.0 (push, pop,...). За ним стоит тот же объект, который отвечает за состояние, — RouteNodeStateManager. RouteNodeStateManager— он реализует важный контракт NavigationController, который совмещает:
- чтение текущего состояния RouteNode и его обновления (state/stream из RouteNodeReadable);
- мутации (mutate из RouteMutator);
- все операции‑примитивы, такие как push и pop (RouteNavigator);
- функции ActiveRouteController, которые отвечают за активный маршрут, — полезно для работы с табами.
Для работы внутри виджета обычно достаточно типа RouteNavigator; для бизнес‑логики чаще держат ссылку на RouteNodeStateManager (или NavigationController), потому что это полный доступ к состоянию навигации без BuildContext.

RouteNodeStateManager и NavigationController реализуют следующие контракты: RouteNavigator, RouteMutator, RouteNodeReadable, ActiveRouteController Ключевая идея: все операции навигации — это мутации дерева состояния. Операции push, pop и другие примитивы реализованы через mutate — метод из RouteMutator, который принимает текущее состояние RouteNode и возвращает новое:
// Так реализован push — добавляем узел к children текущей ноды void push(YxRoute route, {Map<String, String>? arguments}) => mutate((routeNode) { routeNode.add(RouteNode.fromRoute( route: route, arguments: arguments ?? const {}, )); return routeNode; });
Добавили узел в дерево — экран появился. Убрали — исчез. Нет возможности «открыть экран» в обход изменения дерева. Такой подход открывает возможности, недоступные в классическом Navigator: например, можно изменить состояние ветки навигации, которая сейчас не отображается на экране. Подробнее об этом — в разделе «Мутация состояния „неактивных“ ветвей навигации». Business Logic First: подход к работе с навигацией Одна из ключевых особенностей yx_navigation — навигация из бизнес‑логики без привязки к Flutter и BuildContext. Звучит очевидно, но на практике большинство пакетов навигации требуют context.go() или context.pushNamed(), то есть UI‑контекст. А интерактор, сервис или обработчик push‑уведомления — не виджеты. У них изначально нет BuildContext, и это нормально для слоя бизнес‑логики. Нужен способ обновить навигацию из этого слоя без поиска любого подходящего контекста в дереве или необходимости искать GlobalKey<NavigatorState>. Итак, что нужно сделать? Создаём RouteNodeStateManager в интеракторе:
class ProfileNavigationInteractor { late final RouteNodeStateManager _stateManager; RouteNavigator get navigator => _stateManager; ProfileNavigationInteractor() { // Здесь указываем корневую ноду (root) в дереве состояния. В нашем примере — ProfileRoutes.root _stateManager = RouteNodeStateManager( routeNode: ProfileRoutes.root.toNode(), ); } /// Открыть страницу профиля void openDriverProfile() => navigator.push(ProfileRoutes.driverProfile); /// Открыть страницу настроек void openSettings() => navigator.push(ProfileRoutes.settings); }
В чём тут основное отличие? Flutter‑first‑подход: при вызове schema.build() без stateManagerConfiguration пакет сам создаёт внутренний RouteNodeStateManager с начальным деревом из схемы; RouteNavigator появляется уже в UI — через YxNavigation.navigatorOf(context) после MaterialApp.router. Business‑logic‑first: меняется не набор API, а момент появления менеджера — тот же RouteNodeStateManager мы создаём заранее, до работы presentation‑слоя (в интеракторе, из DI), и передаём его в build. Навигатор для бизнес‑логики — это по‑прежнему контракт RouteNavigator, просто берётся из того же экземпляра RouteNodeStateManager, без BuildContext. Интерактор остаётся чистым Dart‑классом. Получение RouterConfig выглядит теперь так:
final stateManager = coreScope.stateManager; // coreScope — ссылка на Scope, но это может быть любой провайдер зависимости из DI в UI final profileSchema = ProfileNavigationSchema(); config = profileSchema.build( stateManagerConfiguration: StateManagerConfiguration( stateManager: stateManager, ), );
Guards — контроль мутаций состояния Любая мутация состояния проходит через цепочку guards. Идея знакома по auto_route и go_router: перед переходом проверяем авторизацию, права, валидацию, да всё что угодно. Guard получает текущее состояние (origin) и целевое (target) и решает: разрешить переход (next), отменить (cancel) или подменить target‑состояние (redirect).

Цепочка: мутация состояния проходит через guards до фиксации нового дерева
abstract interface class RouteNodeGuard { GuardResult call( RouteNode origin, RouteNode target, GuardContext context, ); }
Пример. Проверка авторизации:
class AuthGuard implements RouteNodeGuard { final AuthService authService; const AuthGuard(this.authService); @override GuardResult call( RouteNode origin, RouteNode target, GuardContext context, ) { if (isInLoginNode(target)) { return const GuardResult.next(); } if (!authService.isAuthorized()) { return GuardResult.redirect( target: AppRoutes.login.toNode(), ); } return const GuardResult.next(); } }
Другой пример. Автоматическая инициализация табов:
class TabInitGuard implements RouteNodeGuard { final YxRoute tabRoute; final YxRoute childRoute; const TabInitGuard({ required this.tabRoute, required this.childRoute, }); @override GuardResult call( RouteNode origin, RouteNode target, GuardContext context, ) { final mutableTarget = target.toMutable(); final tabNode = mutableTarget.findByRoute(tabRoute); if (tabNode != null && tabNode.children.isEmpty) { // Как только нода найдена, автоматически добавляем в неё детей tabNode.setChildren([childRoute.toNode()]); return GuardResult.redirect(target: mutableTarget); } return const GuardResult.next(); } }
Guards указываются в декларациях или на уровне схемы RouterSchema:
final someDeclaration = RouteDeclaration.routeBuilder( route: AppRoutes.protectedPage, guards: const [ AuthGuard(), PermissionGuard(), DataValidationGuard(), ], routeBuilder: /* ... */, )
Guards выполняются последовательно. Если любой вернёт cancel() — последующие не выполняются. При redirect() проверка перезапускается с новым target.next передаёт выполнение следующему Guard. Схема навигации (RouterSchema) RouterSchema — это собранный в одном месте каркас навигации как для приложения в целом, так и для отдельной фичи. Геттер declarations задаёт, какие декларации объявлены и как декларации вложены друг в друга (вложенность деклараций сейчас имеет смысл только для RouteStrictDeclaration).
class RouterSchema { @internal final Iterable<RouteDeclaration> declarations; @internal final Iterable<RouteNodeGuard> guards; @internal final InitialRouteNodeBuilder initialNodeBuilder; ...
Колбэк initialNodeBuilder описывает начальное дерево RouteNode при старте приложения или фичи: какие дочерние узлы будут построены в корне дерева и как устроены вложенные уровни. Чтобы получить RouterConfig и передать его в MaterialApp.router, вызывают метод build() — минимальный пример с ProfileNavigationSchema есть выше, в разделе «Сразу к практике». Можно также указать список Guards, который будет встроен в общую цепочку валидации и проверки на каждую мутацию состояния. Иерархия деклараций внутри схемы для приложения/примера могла бы выглядеть так:

Схема навигации описывает иерархию деклараций и вложенность фич Обратите тут внимание на OrderFeatureSchema. Важно то, что схемы можно вкладывать друг в друга. Но об этом позже, когда коснёмся вопроса модульности и изоляции фич. Типы деклараций yx_navigation предоставляет несколько типов деклараций для разных сценариев. Базовый контракт — abstract interface class RouteDeclaration, в приложении обычно вызывают именованные фабрики:
| Фабрика |
Класс реализации |
RouteDeclaration. routeBuilder |
RouteBuilderDeclaration |
RouteDeclaration. scheme |
RouteSchemaDeclaration |
RouteDeclaration. indexedStack |
RouteIndexedDeclaration |
RouteDeclaration. strict |
RouteStrictDeclaration (наследует RouteBuilderDeclaration; строгий режим / переход к дочернему маршруту возможен, только если он заявлен в declarations). На схеме ниже не показываю |
Схематично можно выразить так:

Три основных типа деклараций: RouteBuilderDeclaration, RouteIndexedStackDeclaration и RouteSchemaDeclaration RouteDeclaration.routeBuilder — универсальная декларация (RouteBuilderDeclaration) RouteDeclaration — это связующее звено между деревом состояния и UI. Напомню: дерево RouteNode — это чистые данные, в них нет ничего о Flutter. Декларация отвечает на вопрос: когда в дереве появляется узел с маршрутом X — что именно показать пользователю?

Декларация связывает YxRoute с построением UI через routeBuilder Вы сами решаете, как интерпретировать ноду для вашего маршрута — есть три варианта routeBuilder:

Декларация связывает YxRoute с построением UI через routeBuilder. Доступно три варианта routeBuilder widget — простая страница. Возвращаете любой виджет:
RouteDeclaration.routeBuilder( route: AppRoutes.profile, routeBuilder: RouteBuilder.widget( builder: (context, routeNode) => ProfilePage(), ), )
outlet — вложенный навигатор (стек страниц внутри экрана). Коллекция children рассматривается как стек навигации:
RouteDeclaration.routeBuilder( route: AppRoutes.home, routeBuilder: RouteBuilder.outlet( outletBuilder: (context, routeNode, outlet) { return Scaffold( appBar: AppBar(title: Text('Home')), body: outlet, ); }, ), declarations: [dashboardDeclaration, settingsDeclaration], )
indexed — IndexedStack для реализации табов:
RouteDeclaration.routeBuilder( route: AppRoutes.home, routeBuilder: RouteBuilder.indexed( indexedBuilder: (context, routeNode, indexedStack, controller) { return Scaffold( body: indexedStack, bottomNavigationBar: BottomNavigationBar( currentIndex: tabs.indexOf(controller.activeRoute), onTap: (index) => controller.setActiveRoute(tabs[index]), items: [/* ... */], ), ); }, ), declarations: [mapTabDeclaration, messagesTabDeclaration], )
RouteDeclaration.indexedStack — лёгкий способ построить табы (RouteIndexedDeclaration) Специализированная декларация для табов. Работать с табами можно и при помощи RouteBuilder.indexed, как в примере выше. Но, в отличие от RouteBuilder.indexed, RouteIndexedDeclaration будет рассматривать только вложенные декларации для создания табов. А также автоматически создаёт guards для управления children на основе указанных дочерних деклараций:
inal homeDeclaration = RouteDeclaration.indexedStack( route: AppRoutes.home, routeBuilder: RouteIndexedStackBuilder( indexedBuilder: (context, routeNode, indexedStack, controller) { return Scaffold( body: indexedStack, bottomNavigationBar: BottomNavigationBar( currentIndex: tabs.indexOf(controller.activeRoute ?? AppRoutes.map), onTap: (index) => controller.setActiveRoute(tabs[index]), items: [/* ... */], ), ); }, ), // Все дочерние декларации будут рассматриваться как табы declarations: [ mapTabDeclaration, messagesTabDeclaration, profileTabDeclaration, settingsTabDeclaration, ], );
При переходе на AppRoutes.home дочерние ноды для всех табов создаются автоматически через guards. RouteDeclaration.scheme — изоляция фич (RouteSchemaDeclaration) Подключает готовую RouterSchema как изолированный модуль. Автоматически создаёт вложенный навигатор и NavigationController для управления своим поддеревом состояния RouteNode. Подробности дальше. Модульность и изоляция фич В предыдущем разделе перечислены типы деклараций, в том числе RouteDeclaration.scheme для подключения изолированной RouterSchema. Рассмотрим типичную продуктовую задачу: фича как модуль в приложении‑хосте, без протаскивания внутренних маршрутов наружу. Классическая ситуация в крупном проекте: команда A пишет фичу «Профиль», команда B — основное приложение‑хост. Фича должна подключаться одной строкой, не сообщать хосту о своих внутренних маршрутах и ничего не знать о родителе. Полная изоляция. Ни один из рассмотренных нами пакетов этого не даёт: в go_router и auto_route все маршруты живут в одном дереве, и фича не может скрыть своё внутреннее устройство. В yx_navigation это возможно через RouteDeclaration.scheme:
final profileSchemaDeclaration = RouteDeclaration.scheme( route: DriverRoutes.profile, schema: ProfileNavigationSchema(), );
С точки зрения хоста фича — это просто маршрут DriverRoutes.profile. Чтобы перейти в неё, хосту достаточно вызвать:
routeNavigator.push(DriverRoutes.profile);
Что будет показано внутри, как устроены вложенные экраны, какие маршруты существуют в ProfileNavigationSchema — хост этого не знает и знать не должен. Дальше фича работает самостоятельно через свой NavigationController: навигация внутри модуля — её внутреннее дело. Важно, что RouteDeclaration.scheme можно использовать не только в хосте, но и внутри другой фичи. Любая RouterSchema может вложить в себя другую схему — механизм одинаков на любом уровне вложенности. Это позволяет строить произвольно глубокие иерархии изолированных модулей:
// Фича «Заказы» включает в себя фичу «Карточка заказа» как вложенный модуль final orderCardDeclaration = RouteDeclaration.scheme( route: OrderRoutes.orderCard, schema: OrderCardNavigationSchema(), ); base class OrdersNavigationSchema extends RouterSchema { OrdersNavigationSchema() : super(/* ... */); @override List<RouteDeclaration> get declarations => [ ordersListDeclaration, orderCardDeclaration, // вложенная фича ]; } // Дальше фича «Заказы» сама переиспользуется, например, на уровне хоста final ordersDeclaration = RouteDeclaration.scheme( route: ApplicationRoutes.orders, schema: OrdersNavigationSchema(), );

Важно: RouteDeclaration.scheme можно использовать не только в хосте, но и внутри другой фичи. Любая RouterSchema может вложить в себя другую схему — механизм одинаков на всех уровнях После подключения фича работает как изолированный модуль, с поддержкой следующего:
- Собственный навигатор. У фичи свой вложенный Navigator, управляемый через изолированный NavigationController.
- Изолированное состояние. Фича видит и мутирует только свою ветвь дерева состояния. Повлиять на состояние родителя или соседних фич невозможно.
- Standalone‑ и Embedded‑режим. Одна и та же фича может работать самостоятельно (например, в example‑приложении) или быть встроенной в родительское приложение. Переключение между режимами — через фабрику зависимостей.

У фичи свой вложенный Navigator, управляемый через изолированный NavigationController. Фича видит и мутирует только свою ветвь дерева состояния. Повлиять на состояние родителя или соседних фич невозможно Свой контроллер навигации NavigationController снаружи не обязателен: если в scheme не передавать NavigationController, пакет создаст его сам — вложенный навигатор будет привязан к ветке маршрута из декларации. Business‑logic‑first (BLF) — сценарий, когда нужен заранее созданный экземпляр (тот же, что у интерактора или в DI). Родитель собирает NavigationController.node и подключает его к фиче — либо полем navigationController: в scheme, либо через outletBuilder, если нужна обёртка зависимостей вокруг outlet. Как это работает изнутри (явный контроллер для BLF) Родительское приложение может заранее создать NavigationController.node — контроллер, следящий за конкретной веткой общего дерева:
final profileNavigationController = NavigationController.node( stateManager: stateManager, nodeResolver: RouteNodeResolver.id(route: DriverRoutes.profile), );
Передать его в фичу можно напрямую в декларации:
final profileSchemaDeclaration = RouteDeclaration.scheme( route: DriverRoutes.profile, schema: ProfileNavigationSchema(), navigationController: profileNavigationController, );

У фичи свой изолированный NavigationController. Фича видит и мутирует только свою ветвь дерева состояния. Повлиять на состояние родителя или соседних фич невозможно Или через outletBuilder, если кроме контроллера нужно обернуть вложенный навигатор в скоуп зависимостей:
final profileSchemaDeclaration = RouteDeclaration.scheme( route: DriverRoutes.profile, schema: ProfileNavigationSchema(), outletBuilder: (context, routeNode, outlet) { final appDependencies = DriverAppDependenciesScope.of(context); return ProfileFeatureDependenciesScope.embedded( navigationController: appDependencies.profileNavigationController, child: outlet, ); }, );
В результате получаем:
- Основное приложение не импортирует внутренние зависимости фичи.
- Фича не знает о существовании родительского приложения.
- Обе стороны работают через абстракцию NavigationController.
- Фича может быть подключена в любое другое приложение без изменений.
Это решает реальную проблему крупных проектов: команда A разрабатывает фичу «Профиль», команда B — основное приложение. Они интегрируются через одну точку — RouteDeclaration.scheme. Мутация состояния «неактивных» ветвей навигации Это та самая фича, ради которой мы в итоге и написали свой пакет. Представьте: водитель листает сообщения, а в это время прилетает заказ. Заказ должен появиться во вкладке «Заказы» в виде шторки — когда пользователь переключится на неё, он увидит накопившиеся заказы. В go_router и auto_route это невозможно: они работают только с активной ветвью навигации.

Пользователь находится во вкладке Сообщения. В табе заказов показываем шторку заказа, но остаёмся в Сообщениях. При переключении в таб заказов видим шторку заказа Простейший пример на go_router (та же тема — водительское приложение с табами, StatefulShellRoute):
// Приходит заказ — хотим добавить его во вкладку «Заказы» БЕЗ переключения. // С go_router единственный способ — go(), который ПЕРЕКЛЮЧАЕТ вкладку, делая её активной: _router.go('/orders/order/${order.id}?pickup=...&dropoff=...&price=...'); // Пользователя прервут и переключат на «Заказы». push() в неактивную ветвь невозможен.
У нас RouteNodeStateManager хранит полное дерево навигации, а NavigationController.node() позволяет изолировать любой узел, включая ветви, которые сейчас не видны. Типичный сценарий:
// Контроллер, привязанный к неактивной вкладке «Заказы» final ordersController = NavigationController.node( stateManager: stateManager, // Тут указываем, за какой частью дерева «следим» nodeResolver: RouteNodeResolver.id(route: AppRoutes.ordersTab), ); // Push в неактивную ветвь — штатная операция ordersController.push( AppRoutes.orderCard, arguments: {'orderId': 'ORD-001', 'price': '500'}, );
Этот код выполняется из чистого Dart‑интерактора без BuildContext. При переключении на вкладку «Заказы» пользователь видит актуальный стек со всеми накопившимися заказами. Отдельно о табах: у go_router, auto_route и большинства похожих пакетов нет доступа к навигационному состоянию на том же низком уровне, что в yx_navigation — единое состояние и явный mutate. Снаружи остаются исторически выросшие из Navigator 1.0 API (push, pop, понятие «текущего» стека), которые при переносе на Navigator 2.0, StatefulShellRoute и несколько вложенных Navigator часто сохраняют те же проблемы и corner cases. Из близких по логике альтернатив о мутации всего дерева состояния заявляет разве что Octopus. У нас смена навигации — это прежде всего изменение дерева RouteNode, а не обходной путь через navigatorKey чужого, неактивного стека. У go_router с StatefulShellRoute вызов go() на URI вложенного маршрута в другой ветви (как в примере выше) по модели пакета всегда делает эту вкладку активной. URL однозначно задаёт видимую ветвь, «докинуть» экраны во вкладку «Заказы», оставаясь в «Сообщениях», декларативно нельзя. Если пытаться менять стек неактивной ветви императивно через navigatorKey и, например, pop у соответствующего Navigator, легко поймать краш (assert в match.dart) — воспроизводимый кейс с перебором веток: flutter/flutter#132906. У auto_route сценарий «Находясь на одной вкладке, открыть маршрут из дерева другой» разобран в Milad‑Akarie/auto_route_library#1966: context.pushRoute(BookRoute(...)) с Home на экран из вкладки Books даёт Failed to navigate to BookRoute; в том же треде видно, что pushNamed/navigateNamed либо теряют историю, либо ведут «назад» не туда, то есть без переключения активной вкладки и с ожидаемым стеком задачу не закрыть. Поддержка кастомных анимаций и немного о Page Factory При определении декларации маршрута мы можем указать свою кастомную реализацию класса Page через PageFactory. PageFactory определяет, как маршрут превращается во Flutter Page:
// Material (по умолчанию) RouteBuilder.widget( builder: (context, routeNode) => MyPage(), pageFactory: const PagesFactory.material(), ) // Cupertino RouteBuilder.widget( builder: (context, routeNode) => SettingsPage(), pageFactory: const PagesFactory.cupertino(), )
// Кастомная анимация RouteBuilder.widget( builder: (context, routeNode) => MyPage(), pageFactory: PagesFactory.custom( builder: (context, routeNode, key, child) { return CustomTransitionPage( key: key, child: child, transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition(opacity: animation, child: child); }, ); }, ), )
// Модальный диалог RouteBuilder.widget( builder: (context, routeNode) => ConfirmDialog(), pageFactory: PagesFactory.custom( builder: (context, routeNode, key, child) { return DialogPage(key: key, child: child); }, ), )
Один и тот же билдер страницы можно использовать с разными PageFactory: на одном экране Material‑анимация, на другом — Cupertino, на третьем — кастомная. Сериализация и десериализация состояния Вся суть навигационного состояния — дерево RouteNode — хранится в URL. Это обеспечивает deep links, шеринг ссылок, работу браузерной кнопки «Назад» и восстановление состояния при перезагрузке. Встроенные сериализаторы формируют URI, совместимые с RFC 3986 (Uniform Resource Identifier: Generic Syntax): зарезервированные символы JSON заменяются на допустимые в URI эквиваленты, path‑сегменты и fragment соответствуют спецификации. За преобразование дерева RouteNode ↔ URI отвечает PlatformStateSerialization, который задаётся через RouterConfiguration:
config = schema.build( routerConfiguration: RouterConfiguration( serialization: const PrettyUriStateSerialization(), ), );
Поддерживаются два встроенных типа сериализации:
- PrettyUriStateSerialization (используется по умолчанию) — читаемый формат, удобен для отладки. Сегменты пути отражают иерархию: / — переход к ребёнку, . / .. / ... — уровень вложенности. У каждого узла свои параметры: после идентификатора маршрута пишут $?key=value (несколько пар — через &). Пример URL, где аргументы заданы на трёх уровнях:
#/driver-home$?tab=orders/.driver-documents$?folder=work/..document-detail$?documentId=dl-789
Дерево: корень driver-home (аргумент tab=orders) → children: [driver-documents (folder=work)] → children: [document-detail (documentId=dl-789)].
- UriStringStateSerialization — компактный формат для длинных путей. Дерево кодируется в base64-строку, что даёт короткий URL при большом количестве узлов. Пример:
#('cnQ' 'aWQ':'cm9vdA'),'YXJncw':{})
После обратного преобразования (ключи и строки в значениях — в base64url) это JSON узла для маршрута с id: "root", args → пустой объект, детей нет. Подходит такой формат для OAuth‑редиректов и сценариев, где третьи стороны добавляют свои query‑параметры (есть опция mergeQueryParams). Контракт сериализации минимальный:
abstract interface class PlatformStateSerialization { Uri convert(RouteNode node); // дерево → URI RouteNode parse(Uri data); // URI → дерево }
Реализовав этот интерфейс, можно задать свой кастомный формат URL. Например, поддержать сериализацию в стиле RESTful‑роутинга: ресурсы в path‑сегментах (/drivers/42/documents/dl-789) и состояние через query‑параметры (?status=active&tab=security).
 Совместимость с Navigator 1.0 В крупных проектах переход на новый подход не происходит за один день. «Мы всё переписываем на yx_navigation» — звучит красиво, но в реальности в Яндекс Про десятки фич на Navigator 1.0. Код с Navigator.of(context).push(MaterialPageRoute(...)), showDialog(), showModalBottomSheet() должен продолжать работать, пока мы постепенно мигрируем. Здесь упираемся в конфликт моделей. Декларативный RouterDelegate (и yx_navigation поверх него) живёт в мире page‑based‑навигации: итоговый стек описывается списком Page, состояние — деревом RouteNode. Императивный Navigator 1.0 оперирует Route: часть из них страничные (MaterialPageRoute и так далее), часть — page‑less (ModalRoute у диалогов, bottom sheet и прочих): они не участвуют в том же контракте, что список Page у делегата. Смешивание «снаружи» без compatibility layer и без адаптеров приводит к рассинхрону: операции вроде pushReplacement/pushAndRemoveUntil упираются в assert«ы (A page-based route cannot be completed using imperative api, provide a new list without the corresponding Page to Navigator.pages instead) и несогласованность стека — ровно потому, что старый Navigator 1.0 API рассчитан на другой способ обработки маршрутов.»
 Compatibility Layer (NavigatorCompatibilityOverrides) перехватывает вызовы Navigator 1.0 и оборачивает/адаптирует их до той же модели, что и остальное приложение: page‑less и прочие Route приводятся к page‑based‑представлению и встраиваются в общее дерево. Как side‑эффект (полезный на период миграции или отладки): всё, куда вы зашли через старый API, также отражается в едином дереве состояния навигации — те же узлы RouteNode, что и у декларативных маршрутов. Их видно в debug‑панели, на них распространяется общая картина стека, проще сопоставить legacy‑поведение с новым. Подключение — одна строка:
NavigationConfigProvider( navigatorOverrides: const NavigatorCompatibilityOverrides(), child: MaterialApp.router(routerConfig: config), )
После этого старый код работает без изменений:
// Всё это продолжает работать Navigator.of(context).push(MaterialPageRoute(builder: (_) => OldScreen())); Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => NewScreen())); showDialog(context: context, builder: (_) => MyDialog()); showModalBottomSheet(context: context, builder: (_) => MySheet());
Покрытие — ~95% стандартных Flutter route types:
- MaterialPageRoute, CupertinoPageRoute — полная поддержка.
- DialogRoute, CupertinoDialogRoute, RawDialogRoute — специализированные адаптеры.
- CupertinoModalPopupRoute — специализированный адаптер.
- ModalBottomSheetRoute — полная поддержка.
- Любой ModalRoute — fallback через modalRouteProxy.
Единственное исключение — PopupMenuRoute (showMenu) — private class во Flutter SDK, который пока не может быть обёрнут. Он работает в native‑режиме через обычный Navigator. CompatibilityObserver — мониторинг миграции Для отслеживания прогресса миграции предусмотрен CompatibilityObserver:
class MigrationTrackingObserver extends CompatibilityObserver { final Map<String, int> _routeTypeStats = {}; @override void didCreatePagelessRoute({ required RouteNodeReadable routeNodeReadable, required Route<dynamic> route, required String routeId, required String routeType, required RouteNode routeNode, }) { // Собрали статистику _routeTypeStats[routeType] = (_routeTypeStats[routeType] ?? 0) + 1; } // Распечатали список legacy-роутов void printReport() { for (final entry in _routeTypeStats.entries) { debugPrint('${entry.key}: ${entry.value}'); } } }
Подключаем:
NavigatorCompatibilityOverrides( observer: MigrationTrackingObserver(), )
Теперь мы видим, какие типы route ещё используют Navigator 1.0, и можем планировать миграцию. Debug‑инструменты «А что сейчас в стеке?» — вопрос, который каждый разработчик задавал себе при отладке навигации. yx_navigation предоставляет встроенную debug‑панель, которая визуализирует дерево состояния в реальном времени. Вместо логов и предположений — полный снапшот всего состояния: история мутаций, guards, результаты сериализации в URI. Включить панель просто — достаточно передать DebugPanelModeNotifier при построении схемы:
config = schema.build( debugConfiguration: NavigationDebugConfiguration( debugPanelModeNotifier: DebugPanelModeNotifier(enableDebugPanel: true), ), );
Панель показывает:
- полное дерево RouteNode с аргументами/extra;
- активный маршрут на каждом уровне вложенности (это просто последняя нода в children);
- сериализованное представление состояния в адресной строке;
- историю изменений при каждой мутации.
Для разработки это незаменимо: вместо отладки «а что сейчас в стеке?» вы видите полный снимок дерева навигации. После запуска приложения с включённой debug‑панелью (enableDebugPanel) будет доступна кнопка‑оверлей, по нажатии на которую откроется панель отладки. По умолчанию показываем дерево состояния.
 Кроме состояния, можно проверить сериализованное в строку состояние (то, что можно использовать в качестве URI для flutter web), историю мутаций, а также поменять режим отображения debug‑панели.
 Экосистема yx_architecture yx_navigation — третий пакет в экосистеме, которую мы называем yx_architecture:
| Задача |
Пакет |
Описание |
| DI |
yx_scope |
Скоупы зависимостей с жизненным циклом, compile‑safety, без кодогенерации |
| State Management |
yx_state |
Управление состоянием с очередью операций, без бойлерплейта |
| Навигация |
yx_navigation |
Декларативная навигация с реактивным состоянием и изоляцией фич |
Каждый пакет — независимый инструмент: можно взять только yx_navigation и оставить go_router для части приложения или подключить yx_state для состояния внутри фичи без остальной связки.
 Собранные вместе, три пакета задают единый архитектурный контур:
- yx_scope управляет жизненным циклом зависимостей. Скоуп фичи создаётся при авторизации и удаляется при выходе — вместе со всеми зависимостями.
- yx_state управляет состоянием внутри фичи. StateManager живёт в скоупе и предоставляет реактивный стейт с очередью операций.
- yx_navigation управляет навигацией между фичами и внутри них. RouteNodeStateManager навигации живёт в скоупе, интеракторы используют его для программного управления переходами.
У всех пакетов есть общие свойства:
- Чистый Dart в основе, Flutter‑обвязка отдельно. У каждого пакета есть Dart‑ядро без зависимости от Flutter. Скоупы зависимостей, операции состояния, мутации навигации — всё это тестируется unit‑тестами без запуска приложения.
- Без кодогенерации.Никакого build_runner ни в одном из пакетов: нет лишних шагов в CI/CD и конфликтов сгенерированных файлов при мёржах.
- Простота для базовых сценариев, гибкость для сложных. Каждый пакет минимален для старта, но масштабируется до enterprise‑задач: изоляция фич, вложенные скоупы, сложные цепочки guards и прочее.
- Обкатка в продакшене Яндекс Про. Все три пакета работают под реальной нагрузкой в приложении с десятками команд и сотнями фич.
Заключение yx_navigation — наше решение проблемы навигации в крупных Flutter‑приложениях. Древовидное реактивное состояние, изоляция фич, Business Logic First, guards, совместимость с Navigator 1.0, debug‑инструменты — всё это выросло из реальных болей: багов на проде, бесконечных разборов «а почему pop() тут ведёт себя иначе» и месяцев исследований того, можно ли адаптировать существующие пакеты под наши нужды. Пакет уже используется во множестве фич в Яндекс Про. Мы открыли его для сообщества — подключайте, пробуйте и делитесь обратной связью в issues на GitHub. Будем рады любому фидбэку. Ссылки
-Источник
|