
Навигация во 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. Будем рады любому фидбэку.


























