Опенсорсим yx_navigation — декларативную навигацию для Flutter

Страницы:  1

Ответить
 

Professor Seleznov


pic
Навигация во 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.
pic
Текущее состояние навигации в определённый момент как дерево узлов
Каждый узел содержит:
  • route — идентификатор маршрута (YxRoute);
  • arguments — сериализуемые параметры (например, {'orderId': '123'});
  • extra — несериализуемые объекты (полезно на период миграции);
  • children — список дочерних узлов.
Каждый раз, когда мы используем привычное нам API navigator.push(route, arguments), мы создаём новый узел RouteNode или выполняем мутацию текущего узла:
pic
Операция push(route) создаёт новый узел RouteNode в дереве состояния
Список дочерних узлов children интерпретируется по‑разному — в зависимости от типа родительского маршрута. Это может быть стек навигации: дети строятся один поверх другого как обычные страницы. Или табы: дети отображаются как вкладки в IndexedStack. Или вложенные навигаторы: каждый ребёнок представляет собой изолированную ветвь со своим собственным стеком.
Например, состояние «авторизованный пользователь, открыта главная страница с вкладками сообщений, картами и активным профилем» может выглядеть так:
Root
└── Main (IndexedStack: tabs)
├── Messages
├── Map
└── Profile (активный таб)
pic
Дочерние узлы: рассматриваем дочерние узлы как табы в indexed stack
Другой пример. Имеем состояние «авторизованный пользователь, открыта главная страница, за ней страницы сообщений, карт и профиля»:
Root
└── Main (Outlet: pages)
├── Messages
├── Map
└── Profile (активный экран)
pic
Дочерние узлы: рассматриваем дочерние узлы как последовательность экранов в стеке навигации
В коде это дерево состояния 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.
pic
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).
pic
Цепочка: мутация состояния проходит через 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, который будет встроен в общую цепочку валидации и проверки на каждую мутацию состояния. 
Иерархия деклараций внутри схемы для приложения/примера могла бы выглядеть так:
pic
Схема навигации описывает иерархию деклараций и вложенность фич
Обратите тут внимание на OrderFeatureSchema. Важно то, что схемы можно вкладывать друг в друга. Но об этом позже, когда коснёмся вопроса модульности и изоляции фич.
Типы деклараций
yx_navigation предоставляет несколько типов деклараций для разных сценариев. Базовый контракт — abstract interface class RouteDeclaration, в приложении обычно вызывают именованные фабрики:
Фабрика Класс реализации
RouteDeclaration.
routeBuilder
RouteBuilderDeclaration
RouteDeclaration.
scheme
RouteSchemaDeclaration
RouteDeclaration.
indexedStack
RouteIndexedDeclaration
RouteDeclaration.
strict
RouteStrictDeclaration (наследует RouteBuilderDeclaration; строгий режим / переход к дочернему маршруту возможен, только если он заявлен в declarations). 
На схеме ниже не показываю

Схематично можно выразить так:
pic
Три основных типа деклараций: RouteBuilderDeclaration, RouteIndexedStackDeclaration и RouteSchemaDeclaration
RouteDeclaration.routeBuilder — универсальная декларация (RouteBuilderDeclaration)
RouteDeclaration — это связующее звено между деревом состояния и UI. 
Напомню: дерево RouteNode — это чистые данные, в них нет ничего о Flutter. Декларация отвечает на вопрос: когда в дереве появляется узел с маршрутом X — что именно показать пользователю?
pic
Декларация связывает YxRoute с построением UI через routeBuilder
Вы сами решаете, как интерпретировать ноду для вашего маршрута — есть три варианта routeBuilder:
pic
Декларация связывает 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(),
);
pic
Важно: RouteDeclaration.scheme можно использовать не только в хосте, но и внутри другой фичи. Любая RouterSchema может вложить в себя другую схему — механизм одинаков на всех уровнях 
После подключения фича работает как изолированный модуль, с поддержкой следующего:
  • Собственный навигатор. У фичи свой вложенный Navigator, управляемый через изолированный NavigationController.
  • Изолированное состояние. Фича видит и мутирует только свою ветвь дерева состояния. Повлиять на состояние родителя или соседних фич невозможно.
  • Standalone‑ и Embedded‑режим. Одна и та же фича может работать самостоятельно (например, в example‑приложении) или быть встроенной в родительское приложение. Переключение между режимами — через фабрику зависимостей.
pic
У фичи свой вложенный 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,
);
pic
У фичи свой изолированный 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 это невозможно: они работают только с активной ветвью навигации.
pic
Пользователь находится во вкладке Сообщения. В табе заказов показываем шторку заказа, но остаёмся в Сообщениях. При переключении в таб заказов видим шторку заказа
Простейший пример на 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).
pic
Совместимость с Navigator 1.0
В крупных проектах переход на новый подход не происходит за один день. «Мы всё переписываем на yx_navigation» — звучит красиво, но в реальности в Яндекс Про десятки фич на Navigator 1.0. Код с Navigator.of(context).push(MaterialPageRoute(...)), showDialog(), showModalBottomSheet() должен продолжать работать, пока мы постепенно мигрируем.
Здесь упираемся в конфликт моделей. Декларативный RouterDelegateyx_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 рассчитан на другой способ обработки маршрутов.»
pic
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) будет доступна кнопка‑оверлей, по нажатии на которую откроется панель отладки. По умолчанию показываем дерево состояния.
pic
Кроме состояния, можно проверить сериализованное в строку состояние (то, что можно использовать в качестве URI для flutter web), историю мутаций, а также поменять режим отображения debug‑панели.
pic
Экосистема yx_architecture 
yx_navigation — третий пакет в экосистеме, которую мы называем yx_architecture:
Задача Пакет Описание
DI yx_scope Скоупы зависимостей с жизненным циклом, compile‑safety, без кодогенерации
State Management yx_state Управление состоянием с очередью операций, без бойлерплейта
Навигация yx_navigation Декларативная навигация с реактивным состоянием и изоляцией фич

Каждый пакет — независимый инструмент: можно взять только yx_navigation и оставить go_router для части приложения или подключить yx_state для состояния внутри фичи без остальной связки.
pic
Собранные вместе, три пакета задают единый архитектурный контур:
  • 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. Будем рады любому фидбэку.
Ссылки -Источник
 
Loading...
Error