
Навигация во 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 (активный таб)

Другой пример. Имеем состояние «авторизованный пользователь, открыта главная страница, за ней страницы сообщений, карт и профиля»:
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, в приложении обычно вызывают именованные фабрики:
Фабрика |
Класс реализации |
|
|
|
|
|
|
|
|
|
|
|
На схеме ниже не показываю |
Схематично можно выразить так:

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

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

YxRoute с построением UI через routeBuilder. Доступно три варианта routeBuilderwidget — простая страница. Возвращаете любой виджет:
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 |
Скоупы зависимостей с жизненным циклом, compile‑safety, без кодогенерации |
|
State Management |
Управление состоянием с очередью операций, без бойлерплейта |
|
Навигация |
Декларативная навигация с реактивным состоянием и изоляцией фич |
Каждый пакет — независимый инструмент: можно взять только 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. Будем рады любому фидбэку.
Mitai
к вашим пакетам документацию бы как у феликса с кучей приложений от простого к сложному цены бы не было
dushes_at_habr Автор
Привет!
Спасибо за комментарий :+1
Замечение валидно – документация и хорошие примеры очень нужны для понимания. Для yx_navigation мы этому уделили (наверно субъективно) достаточно внимания.
В описании пакета yx_navigation в разделе Documentation есть ссылка на "quick start", где как раз постарались дать примеры от простого к сложному:
https://github.com/yandex/city-services-pub/blob/main/yx_navigation/docs/quick_start.md
Вся пачка примеров доступна тут по разным аспектам использования пакета:
https://github.com/yandex/city-services-pub/tree/main/yx_navigation/packages/yx_navigation_flutter/example/lib/src