Навигация во 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 в дереве состояния
Операция push(route) создаёт новый узел RouteNode в дереве состояния

Список дочерних узлов children интерпретируется по‑разному — в зависимости от типа родительского маршрута. Это может быть стек навигации: дети строятся один поверх другого как обычные страницы. Или табы: дети отображаются как вкладки в IndexedStack. Или вложенные навигаторы: каждый ребёнок представляет собой изолированную ветвь со своим собственным стеком.

Например, состояние «авторизованный пользователь, открыта главная страница с вкладками сообщений, картами и активным профилем» может выглядеть так:

Root
└── Main (IndexedStack: tabs)
    ├── Messages
    ├── Map
    └── Profile (активный таб)
Дочерние узлы: рассматриваем дочерние узлы как табы в indexed stack
Дочерние узлы: рассматриваем дочерние узлы как табы в indexed stack

Другой пример. Имеем состояние «авторизованный пользователь, открыта главная страница, за ней страницы сообщений, карт и профиля»:

Root
└── Main (Outlet: pages)
    ├── Messages
    ├── Map
    └── Profile (активный экран)
Дочерние узлы: рассматриваем дочерние узлы как последовательность экранов в стеке навигации
Дочерние узлы: рассматриваем дочерние узлы как последовательность экранов в стеке навигации

В коде это дерево состояния RouteNode:

const YxRoute(id: 'main').toNode(
  children: [
    const YxRoute(id: 'messages').toNode(),
    const YxRoute(id: 'map').toNode(),
    const YxRoute(id: 'profile').toNode(),
  ],
)

Как мы видим, такое состояние одинаково и для отображения стека экранов, и для реализации табов. Отличается только интерпретация дочерних узлов (нод) для маршрута main

За управление состоянием (RouteNode) отвечает RouteNodeStateManager. Состояние реактивно: RouteNodeStateManager предоставляет stream и state для чтения и подписки на изменения.

Сразу к практике

Давайте посмотрим на примере. Минимальный путь от нуля до работающей навигации — четыре шага.

Шаг 1. Определяем маршруты

abstract class ProfileRoutes {
  static const home = YxRoute(id: 'profile-home');
  static const driverProfile = YxRoute(id: 'profile-driver');
  static const tripsHistory = YxRoute(id: 'profile-trips-history');
  static const statistics = YxRoute(id: 'profile-statistics');
  static const settings = YxRoute(id: 'profile-settings');
  static const documents = YxRoute(id: 'profile-documents');
}

Шаг 2. Связываем маршрут с UI через декларацию

RouteDeclaration связывает маршрут с виджетом:

final driverProfileDeclaration = RouteDeclaration.routeBuilder(
  route: ProfileRoutes.driverProfile,
  routeBuilder: RouteBuilder.widget(
    builder: (context, state) => const DriverProfilePage(),
  ),
);

Тут мы определили, что если в дереве состояния появится узел с маршрутом ProfileRoutes.driverProfile, то для него будет построен виджет DriverProfilePage.

Подробности о декларациях будут ниже. Но смысл понятен: связываем состояние навигации с конкретным UI‑представлением.

Шаг 3. Собираем схему навигации

RouterSchema — это то, что группирует декларации нашего приложения или фичи и определяет начальное состояние:

class ProfileNavigationSchema extends RouterSchema {
  ProfileNavigationSchema()
      : super(
          //  здесь получаем корень дерева и задаём начальное состояние
          //  в данном случае будет единственный дочерний узел ProfileRoutes.home
          initialNodeBuilder: (node) => node
            ..setChildren([
                ProfileRoutes.home.toNode(),
            ]),
        );

  @override
  List<RouteDeclaration> get declarations => [
        //  тут определим декларацию для маршрута ProfileRoutes.home
        RouteDeclaration.routeBuilder(
          route: ProfileRoutes.home,
          routeBuilder: RouteBuilder.widget(
            builder: (context, state) => const ProfileHomePage(),
          ),
        ),
        driverProfileDeclaration, //  декларация профиля и другие
        tripsHistoryDeclaration,
        statisticsDeclaration,
        settingsDeclaration,
        documentsDeclaration,
      ];
}

Шаг 4. Запускаем

После запуска увидим первым экран ProfileHomePage.

class _ProfileAppState extends State<ProfileApp> {
  late YxRouterConfig config;

  @override
  void initState() {
    super.initState();
    final profileSchema = ProfileNavigationSchema();
    config = profileSchema.build(); //  строим RouterConfig
  }

  @override
  Widget build(BuildContext context) => MaterialApp.router(
        routerConfig: config,
      );

  @override
  void dispose() {
    config.dispose(); //  не забываем очистить ресурсы
    super.dispose();
  }
}

profileSchema.build() создаёт RouterConfig со всеми необходимыми компонентами Navigator 2.0: RouterDelegate, RouteInformationParser, RouteInformationProvider, BackButtonDispatcher.

Если хотим открыть другой экран, нам потребуется RouteNavigator. Получить его мы можем из контекста. Дальше используем знакомые по Navigator 1.0 операции push, pop и прочие (мы называем их «примитивы»):

final routeNavigator = YxNavigation.navigatorOf(context);

// Перейти на экран
routeNavigator.push(ProfileRoutes.driverProfile);

// Вернуться назад
routeNavigator.pop();

// Проверить, можно ли вернуться
final canPop = routeNavigator.canPop();

Навигатор и RouteNodeStateManager

YxNavigation.navigatorOf(context) отдаёт RouteNavigator — узкий контракт с операциями в духе Navigator 1.0 (push, pop,...). За ним стоит тот же объект, который отвечает за состояние, — RouteNodeStateManager.

RouteNodeStateManager— он реализует важный контракт NavigationController, который совмещает:

  • чтение текущего состояния RouteNode и его обновления (state/stream из RouteNodeReadable); 

  • мутации (mutate из RouteMutator); 

  • все операции‑примитивы, такие как push и pop (RouteNavigator)

  • функции ActiveRouteController, которые отвечают за активный маршрут, — полезно для работы с табами.

Для работы внутри виджета обычно достаточно типа RouteNavigator; для бизнес‑логики чаще держат ссылку на RouteNodeStateManager (или NavigationController), потому что это полный доступ к состоянию навигации без BuildContext.

RouteNodeStateManager и NavigationController реализуют следующие контракты: RouteNavigator, RouteMutator, RouteNodeReadable, ActiveRouteController
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 до фиксации нового дерева
Цепочка: мутация состояния проходит через guards до фиксации нового дерева
abstract interface class RouteNodeGuard {
  GuardResult call(
    RouteNode origin,
    RouteNode target,
    GuardContext context,
  );
}

Пример. Проверка авторизации:

class AuthGuard implements RouteNodeGuard {
  final AuthService authService;

  const AuthGuard(this.authService);

  @override
  GuardResult call(
    RouteNode origin,
    RouteNode target,
    GuardContext context,
  ) {
    if (isInLoginNode(target)) {
      return const GuardResult.next();
    }

    if (!authService.isAuthorized()) {
      return GuardResult.redirect(
        target: AppRoutes.login.toNode(),
      );
    }

    return const GuardResult.next();
  }
}

Другой пример. Автоматическая инициализация табов:

class TabInitGuard implements RouteNodeGuard {
  final YxRoute tabRoute;
  final YxRoute childRoute;

  const TabInitGuard({
    required this.tabRoute,
    required this.childRoute,
  });

  @override
  GuardResult call(
    RouteNode origin,
    RouteNode target,
    GuardContext context,
  ) {
    final mutableTarget = target.toMutable();
    final tabNode = mutableTarget.findByRoute(tabRoute);

    if (tabNode != null && tabNode.children.isEmpty) {
      //  Как только нода найдена, автоматически добавляем в неё детей
      tabNode.setChildren([childRoute.toNode()]);
      return GuardResult.redirect(target: mutableTarget);
    }

    return const GuardResult.next();
  }
}

Guards указываются в декларациях или на уровне схемы RouterSchema:

final someDeclaration = RouteDeclaration.routeBuilder(
  route: AppRoutes.protectedPage,
  guards: const [
    AuthGuard(),
    PermissionGuard(),
    DataValidationGuard(),
  ],
  routeBuilder: /* ... */,
)

Guards выполняются последовательно. Если любой вернёт cancel() — последующие не выполняются. При redirect() проверка перезапускается с новым target. next передаёт выполнение следующему Guard.

Схема навигации (RouterSchema)

RouterSchema — это собранный в одном месте каркас навигации как для приложения в целом, так и для отдельной фичи. Геттер declarations задаёт, какие декларации объявлены и как декларации вложены друг в друга (вложенность деклараций сейчас имеет смысл только для RouteStrictDeclaration). 

class RouterSchema {
 @internal
 final Iterable<RouteDeclaration> declarations;


 @internal
 final Iterable<RouteNodeGuard> guards;


 @internal
 final InitialRouteNodeBuilder initialNodeBuilder;
...

Колбэк initialNodeBuilder описывает начальное дерево RouteNode при старте приложения или фичи: какие дочерние узлы будут построены в корне дерева и как устроены вложенные уровни. Чтобы получить RouterConfig и передать его в MaterialApp.router, вызывают метод build() — минимальный пример с ProfileNavigationSchema есть выше, в разделе «Сразу к практике».

Можно также указать список Guards, который будет встроен в общую цепочку валидации и проверки на каждую мутацию состояния. 

Иерархия деклараций внутри схемы для приложения/примера могла бы выглядеть так:

Схема навигации описывает иерархию деклараций и вложенность фич
Схема навигации описывает иерархию деклараций и вложенность фич

Обратите тут внимание на OrderFeatureSchema. Важно то, что схемы можно вкладывать друг в друга. Но об этом позже, когда коснёмся вопроса модульности и изоляции фич.

Типы деклараций

yx_navigation предоставляет несколько типов деклараций для разных сценариев. Базовый контракт — abstract interface class RouteDeclaration, в приложении обычно вызывают именованные фабрики:

Фабрика

Класс реализации

RouteDeclaration.

routeBuilder

RouteBuilderDeclaration

RouteDeclaration.

scheme

RouteSchemaDeclaration

RouteDeclaration.

indexedStack

RouteIndexedDeclaration

RouteDeclaration.

strict

RouteStrictDeclaration (наследует RouteBuilderDeclaration; строгий режим / переход к дочернему маршруту возможен, только если он заявлен в declarations). 

На схеме ниже не показываю

Схематично можно выразить так:

Три основных типа деклараций: RouteBuilderDeclaration, RouteIndexedStackDeclaration и RouteSchemaDeclaration
Три основных типа деклараций: RouteBuilderDeclaration, RouteIndexedStackDeclaration и RouteSchemaDeclaration

RouteDeclaration.routeBuilder — универсальная декларация (RouteBuilderDeclaration)

RouteDeclaration — это связующее звено между деревом состояния и UI. 

Напомню: дерево RouteNode — это чистые данные, в них нет ничего о Flutter. Декларация отвечает на вопрос: когда в дереве появляется узел с маршрутом X — что именно показать пользователю?

Декларация связывает YxRoute с построением UI через routeBuilder
Декларация связывает YxRoute с построением UI через routeBuilder

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

Декларация связывает YxRoute с построением UI через routeBuilder. Доступно три варианта routeBuilder
Декларация связывает YxRoute с построением UI через routeBuilder. Доступно три варианта routeBuilder

widget — простая страница. Возвращаете любой виджет:

RouteDeclaration.routeBuilder(
  route: AppRoutes.profile,
  routeBuilder: RouteBuilder.widget(
    builder: (context, routeNode) => ProfilePage(),
  ),
)

outlet — вложенный навигатор (стек страниц внутри экрана). Коллекция children рассматривается как стек навигации:

RouteDeclaration.routeBuilder(
  route: AppRoutes.home,
  routeBuilder: RouteBuilder.outlet(
    outletBuilder: (context, routeNode, outlet) {
      return Scaffold(
        appBar: AppBar(title: Text('Home')),
        body: outlet,
      );
    },
  ),
  declarations: [dashboardDeclaration, settingsDeclaration],
)

indexed — IndexedStack для реализации табов:

RouteDeclaration.routeBuilder(
  route: AppRoutes.home,
  routeBuilder: RouteBuilder.indexed(
    indexedBuilder: (context, routeNode, indexedStack, controller) {
      return Scaffold(
        body: indexedStack,
        bottomNavigationBar: BottomNavigationBar(
          currentIndex: tabs.indexOf(controller.activeRoute),
          onTap: (index) => controller.setActiveRoute(tabs[index]),
          items: [/* ... */],
        ),
      );
    },
  ),
  declarations: [mapTabDeclaration, messagesTabDeclaration],
)

RouteDeclaration.indexedStack — лёгкий способ построить табы (RouteIndexedDeclaration)

Специализированная декларация для табов. Работать с табами можно и при помощи RouteBuilder.indexed, как в примере выше. Но, в отличие от RouteBuilder.indexed, RouteIndexedDeclaration будет рассматривать только вложенные декларации для создания табов. А также автоматически создаёт guards для управления children на основе указанных дочерних деклараций:

inal homeDeclaration = RouteDeclaration.indexedStack(
  route: AppRoutes.home,
  routeBuilder: RouteIndexedStackBuilder(
    indexedBuilder: (context, routeNode, indexedStack, controller) {
      return Scaffold(
        body: indexedStack,
        bottomNavigationBar: BottomNavigationBar(
          currentIndex: tabs.indexOf(controller.activeRoute ?? AppRoutes.map),
          onTap: (index) => controller.setActiveRoute(tabs[index]),
          items: [/* ... */],
        ),
      );
    },
  ),
  //  Все дочерние декларации будут рассматриваться как табы
  declarations: [
    mapTabDeclaration,
    messagesTabDeclaration,
    profileTabDeclaration,
    settingsTabDeclaration,
  ],
);

При переходе на AppRoutes.home дочерние ноды для всех табов создаются автоматически через guards.

RouteDeclaration.scheme — изоляция фич (RouteSchemaDeclaration)

Подключает готовую RouterSchema как изолированный модуль. Автоматически создаёт вложенный навигатор и NavigationController для управления своим поддеревом состояния RouteNode. Подробности дальше.

Модульность и изоляция фич

В предыдущем разделе перечислены типы деклараций, в том числе RouteDeclaration.scheme для подключения изолированной RouterSchema. Рассмотрим типичную продуктовую задачу: фича как модуль в приложении‑хосте, без протаскивания внутренних маршрутов наружу.

Классическая ситуация в крупном проекте: команда A пишет фичу «Профиль», команда B — основное приложение‑хост. Фича должна подключаться одной строкой, не сообщать хосту о своих внутренних маршрутах и ничего не знать о родителе. Полная изоляция.

Ни один из рассмотренных нами пакетов этого не даёт: в go_router и auto_route все маршруты живут в одном дереве, и фича не может скрыть своё внутреннее устройство.

В yx_navigation это возможно через RouteDeclaration.scheme:

final profileSchemaDeclaration = RouteDeclaration.scheme(
  route: DriverRoutes.profile,
  schema: ProfileNavigationSchema(),
);

С точки зрения хоста фича — это просто маршрут DriverRoutes.profile. Чтобы перейти в неё, хосту достаточно вызвать:

routeNavigator.push(DriverRoutes.profile);

Что будет показано внутри, как устроены вложенные экраны, какие маршруты существуют в ProfileNavigationSchema — хост этого не знает и знать не должен. Дальше фича работает самостоятельно через свой NavigationController: навигация внутри модуля — её внутреннее дело.

Важно, что RouteDeclaration.scheme можно использовать не только в хосте, но и внутри другой фичи. Любая RouterSchema может вложить в себя другую схему — механизм одинаков на любом уровне вложенности. Это позволяет строить произвольно глубокие иерархии изолированных модулей:

// Фича «Заказы» включает в себя фичу «Карточка заказа» как вложенный модуль
final orderCardDeclaration = RouteDeclaration.scheme(
  route: OrderRoutes.orderCard,
  schema: OrderCardNavigationSchema(),
);


base class OrdersNavigationSchema extends RouterSchema {
  OrdersNavigationSchema() : super(/* ... */);


  @override
  List<RouteDeclaration> get declarations => [
    ordersListDeclaration,
    orderCardDeclaration, // вложенная фича
  ];
}


// Дальше фича «Заказы» сама переиспользуется, например, на уровне хоста
final ordersDeclaration = RouteDeclaration.scheme(
  route: ApplicationRoutes.orders,
  schema: OrdersNavigationSchema(),
);
Важно: RouteDeclaration.scheme можно использовать не только в хосте, но и внутри другой фичи. Любая RouterSchema может вложить в себя другую схему — механизм одинаков на всех уровнях 
Важно: RouteDeclaration.scheme можно использовать не только в хосте, но и внутри другой фичи. Любая RouterSchema может вложить в себя другую схему — механизм одинаков на всех уровнях 

После подключения фича работает как изолированный модуль, с поддержкой следующего:

  • Собственный навигатор. У фичи свой вложенный Navigator, управляемый через изолированный NavigationController.

  • Изолированное состояние. Фича видит и мутирует только свою ветвь дерева состояния. Повлиять на состояние родителя или соседних фич невозможно.

  • Standalone‑ и Embedded‑режим. Одна и та же фича может работать самостоятельно (например, в example‑приложении) или быть встроенной в родительское приложение. Переключение между режимами — через фабрику зависимостей.

У фичи свой вложенный Navigator, управляемый через изолированный NavigationController. Фича видит и мутирует только свою ветвь дерева состояния. Повлиять на состояние родителя или соседних фич невозможно
У фичи свой вложенный 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. Фича видит и мутирует только свою ветвь дерева состояния. Повлиять на состояние родителя или соседних фич невозможно
У фичи свой изолированный 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() должен продолжать работать, пока мы постепенно мигрируем.

Здесь упираемся в конфликт моделей. Декларативный 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 рассчитан на другой способ обработки маршрутов.»

Compatibility Layer (NavigatorCompatibilityOverrides) перехватывает вызовы Navigator 1.0 и оборачивает/адаптирует их до той же модели, что и остальное приложение: page‑less и прочие Route приводятся к page‑based‑представлению и встраиваются в общее дерево. Как side‑эффект (полезный на период миграции или отладки): всё, куда вы зашли через старый API, также отражается в едином дереве состояния навигации — те же узлы RouteNode, что и у декларативных маршрутов. Их видно в debug‑панели, на них распространяется общая картина стека, проще сопоставить legacy‑поведение с новым.

Подключение — одна строка:

NavigationConfigProvider(
  navigatorOverrides: const NavigatorCompatibilityOverrides(),
  child: MaterialApp.router(routerConfig: config),
)

После этого старый код работает без изменений:

// Всё это продолжает работать
Navigator.of(context).push(MaterialPageRoute(builder: (_) => OldScreen()));
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => NewScreen()));
showDialog(context: context, builder: (_) => MyDialog());
showModalBottomSheet(context: context, builder: (_) => MySheet());

Покрытие — ~95% стандартных Flutter route types:

  • MaterialPageRoute, CupertinoPageRoute — полная поддержка.

  • DialogRoute, CupertinoDialogRoute, RawDialogRoute — специализированные адаптеры.

  • CupertinoModalPopupRoute — специализированный адаптер.

  • ModalBottomSheetRoute — полная поддержка.

  • Любой ModalRoute — fallback через modalRouteProxy.

Единственное исключение — PopupMenuRoute (showMenu) — private class во Flutter SDK, который пока не может быть обёрнут. Он работает в native‑режиме через обычный Navigator.

CompatibilityObserver — мониторинг миграции

Для отслеживания прогресса миграции предусмотрен CompatibilityObserver:

class MigrationTrackingObserver extends CompatibilityObserver {
  final Map<String, int> _routeTypeStats = {};

  @override
  void didCreatePagelessRoute({
    required RouteNodeReadable routeNodeReadable,
    required Route<dynamic> route,
    required String routeId,
    required String routeType,
    required RouteNode routeNode,
  }) {
    //  Собрали статистику
    _routeTypeStats[routeType] = (_routeTypeStats[routeType] ?? 0) + 1;
  }

  //  Распечатали список legacy-роутов
  void printReport() {
    for (final entry in _routeTypeStats.entries) {
      debugPrint('${entry.key}: ${entry.value}');
    }
  }
}

Подключаем:

NavigatorCompatibilityOverrides(
  observer: MigrationTrackingObserver(),
)

Теперь мы видим, какие типы route ещё используют Navigator 1.0, и можем планировать миграцию.

Debug‑инструменты

«А что сейчас в стеке?» — вопрос, который каждый разработчик задавал себе при отладке навигации. yx_navigation предоставляет встроенную debug‑панель, которая визуализирует дерево состояния в реальном времени. Вместо логов и предположений — полный снапшот всего состояния: история мутаций, guards, результаты сериализации в URI.

Включить панель просто — достаточно передать DebugPanelModeNotifier при построении схемы:

config = schema.build(
  debugConfiguration: NavigationDebugConfiguration(
    debugPanelModeNotifier: DebugPanelModeNotifier(enableDebugPanel: true),
  ),
);

Панель показывает:

  • полное дерево RouteNode с аргументами/extra;

  • активный маршрут на каждом уровне вложенности (это просто последняя нода в children);

  • сериализованное представление состояния в адресной строке;

  • историю изменений при каждой мутации.

Для разработки это незаменимо: вместо отладки «а что сейчас в стеке?» вы видите полный снимок дерева навигации.

После запуска приложения с включённой debug‑панелью (enableDebugPanel) будет доступна кнопка‑оверлей, по нажатии на которую откроется панель отладки. По умолчанию показываем дерево состояния.

Кроме состояния, можно проверить сериализованное в строку состояние (то, что можно использовать в качестве URI для flutter web), историю мутаций, а также поменять режим отображения debug‑панели.

Экосистема yx_architecture 

yx_navigation — третий пакет в экосистеме, которую мы называем yx_architecture:

Задача

Пакет

Описание

DI

yx_scope

Скоупы зависимостей с жизненным циклом, compile‑safety, без кодогенерации

State Management

yx_state

Управление состоянием с очередью операций, без бойлерплейта

Навигация

yx_navigation

Декларативная навигация с реактивным состоянием и изоляцией фич

Каждый пакет — независимый инструмент: можно взять только yx_navigation и оставить go_router для части приложения или подключить yx_state для состояния внутри фичи без остальной связки.

Собранные вместе, три пакета задают единый архитектурный контур:

  • yx_scope управляет жизненным циклом зависимостей. Скоуп фичи создаётся при авторизации и удаляется при выходе — вместе со всеми зависимостями.

  • yx_state управляет состоянием внутри фичи. StateManager живёт в скоупе и предоставляет реактивный стейт с очередью операций.

  • yx_navigation управляет навигацией между фичами и внутри них. RouteNodeStateManager навигации живёт в скоупе, интеракторы используют его для программного управления переходами.

У всех пакетов есть общие свойства:

  • Чистый Dart в основе, Flutter‑обвязка отдельно. У каждого пакета есть Dart‑ядро без зависимости от Flutter. Скоупы зависимостей, операции состояния, мутации навигации — всё это тестируется unit‑тестами без запуска приложения.

  • Без кодогенерации. Никакого build_runner ни в одном из пакетов: нет лишних шагов в CI/CD и конфликтов сгенерированных файлов при мёржах.

  • Простота для базовых сценариев, гибкость для сложных. Каждый пакет минимален для старта, но масштабируется до enterprise‑задач: изоляция фич, вложенные скоупы, сложные цепочки guards и прочее.

  • Обкатка в продакшене Яндекс Про. Все три пакета работают под реальной нагрузкой в приложении с десятками команд и сотнями фич.

Заключение

yx_navigation — наше решение проблемы навигации в крупных Flutter‑приложениях. Древовидное реактивное состояние, изоляция фич, Business Logic First, guards, совместимость с Navigator 1.0, debug‑инструменты — всё это выросло из реальных болей: багов на проде, бесконечных разборов «а почему pop() тут ведёт себя иначе» и месяцев исследований того, можно ли адаптировать существующие пакеты под наши нужды.

Пакет уже используется во множестве фич в Яндекс Про. Мы открыли его для сообщества — подключайте, пробуйте и делитесь обратной связью в issues на GitHub. Будем рады любому фидбэку.

Ссылки

Комментарии (2)


  1. Mitai
    15.05.2026 07:11

    к вашим пакетам документацию бы как у феликса с кучей приложений от простого к сложному цены бы не было


    1. dushes_at_habr Автор
      15.05.2026 07:11

      Привет!

      Спасибо за комментарий :+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