Введение

Я занимаюсь мобильной разработкой больше десяти лет: сначала iOS, потом Flutter. И всё это время сталкиваюсь с одной и той же проблемой — API навигации устроены так, что следование общепринятым гайдлайнам подталкивает к архитектурно неправильным решениям.

Что касается iOS, в гайдлайнах Apple, независимо от конкретного способа навигации, конфигурация (а часто и инициализация) последующего экрана выполняется в коде предыдущего. Например, при использовании storyboard segues экран настраивается в prepare(for:sender:) исходного контроллера. При программной навигации pushViewController также принимает уже готовый экземпляр, созданный, как правило, в коде предыдущего экрана.

Ранний SwiftUI воспроизвёл ту же схему: NavigationLink(destination:) строит следующий экран непосредственно в месте нажатия. В iOS 16 появился NavigationStack, где пункт назначения объявляется отдельно от ссылки, а путь стека может храниться вне представления, — однако и здесь гайдлайны не указывают, что решение о переходе не относится к зоне ответственности экрана. Поэтому в iOS общепринятой практикой остаётся паттерн Координатор [1], [2] или его упрощённая версия [3].

В Android пришли к тому же с другой стороны: официальный гайд по Jetpack Compose прямо требует не передавать navController внутрь composable, а прокидывать колбэки — чтобы экраны оставались переиспользуемыми и тестируемыми [4].

Во Flutter ситуацию усложняет web: к переходам добавились пути — то, что видно в адресной строке. Штатный API (Navigator 2.0) получился громоздким, и напрямую его используют редко, в основном в простых случаях. Чаще берут сторонние пакеты — популярнее всего go_router и auto_route. Но при их использовании также ничего не меняется: ни документация Flutter, ни гайдлайны самих пакетов нигде не отмечают, что задавать переход в коде виджета — архитектурная проблема. Наоборот, это подаётся как норма. В документации go_router штатный пример выглядит так:

TextButton(
  onPressed: () => context.go('/users/123'),
)

auto_route идёт дальше и специально добавляет расширения context.router.push(...) и context.pushRoute(...) — чтобы вызывать навигацию прямо из виджета было удобнее. А кое-где встречается и прямое создание следующего экрана в коде предыдущего.

Это приводит к следующим проблемам:

  • Сложность и нарушение SRP. Экран отвечает уже не только за интерфейс и реакцию на действия пользователя, но и за навигацию — включая создание других экранов и раздачу им зависимостей. Две ответственности в одном месте, и экран перестаёт читаться с одного взгляда.

  • Плохое переиспользование. Экран, который сам решает, куда идти дальше, трудно вставить в другой контекст, где переход должен быть другим.

  • Жёсткая связность. Каждый экран знает про следующие. Архитектура становится хрупкой: правка в одном экране тянет за собой правки в других.

  • Тестируемость. Проверить, что экран корректно реагирует на действие пользователя, невозможно в отрыве от навигации: для запуска экрана требуется сконфигурированный роутер, а результатом нажатия оказывается не факт запроса, а фактически выполненный переход. Модульный тест экрана превращается в интеграционный.

Решение

Проблема возникает вследствие того, что предыдущий экран владеет информацией о последующем. Наиболее очевидное решение — вынести реализацию перехода из кода экрана.

Сразу возникает вопрос: куда её вынести? Ответ даёт паттерн «информационный эксперт» (Information Expert) — ответственность назначается тому, кто обладает информацией, необходимой для её исполнения.

Подробнее о паттерне

«Информационный эксперт» — один из принципов GRASP (General Responsibility Assignment Software Patterns), сформулированных Крэгом Ларманом в книге «Applying UML and Patterns» (1997). Принцип отвечает на вопрос, какому объекту назначить ответственность, и формулируется так: назначай ответственность классу, который обладает информацией, необходимой для её выполнения.

Информации о том, куда ведёт нажатие — в какой вкладке находится экран, какой стек под ним, открыть следующий экран поверх или переключить раздел, — у экрана нет. Она есть там, где приложение собирается из экранов. Сам же экран пусть включает абстрактный интерфейс такого перехода. Тогда типичный переход выглядит следующим образом:

FilledButton.tonal(
  onPressed: () => coordinator.onUserPostsRoute(context, userId: userId),
  child: Text('Posts by this user ($postCount)'),
)

Реализацию этого интерфейса следует держать как можно ближе к месту, где конфигурируются переходы. Иначе говоря — там, откуда так или иначе был вызван конструктор текущего экрана.

Приём не нов: в iOS он известен как паттерн Координатор [1], [2], во Flutter близкий по смыслу подход описан в [6]. Существенно другое — внедрять его целиком не требуется. Переходы выносятся из экранов по одному экрану за раз, без смены библиотеки навигации и без изменения остальной части приложения. Поэтому подход применим не только к новому проекту, но и к существующему коду: каждый обработанный экран сразу перестаёт зависеть от того, куда он ведёт, и остальная навигация при этом продолжает работать как прежде.

Таким образом, цель этой статьи двойная.

Первая — показать приём (и его вариации), который по принципу «информационного эксперта» выносит переход из кода виджетов туда, где для решения есть вся информация.

Вторая, не менее важная, — попробовать получить место в коде, которое более-менее наглядно описывает навигацию.

Что сделано

Статья состоит из трёх частей, в каждой из которых, абсолютно идентично, реализована наиболее типичная часть навигации в мобильном приложении: две вкладки со своими стеками и общий экран, который в каждой вкладке ведёт себя по-своему. В первой части — императивный подход, без использования каких-либо сторонних пакетов. Во второй и третьей — всё то же самое, но с использованием go_router и auto_route соответственно. В каждой части показан, по сути, один и тот же приём, только в части 1 и в частях 2, 3 он реализован немного по-разному. В части 1 навигация из кода экрана вынесена при помощи колбэков, а в частях 2 и 3 — при помощи абстрактных интерфейсов. Различная реализация обусловлена тем, что для части 1 использование колбэков, на мой взгляд, выглядит синтаксически нагляднее.

Приложение-пример

Код примера находится в репозитории [5]. Каждая реализация вынесена в отдельную ветку: part-1-imperative, part-2-gorouter, part-3-autoroute. Ветка main содержит общую часть — модели, репозиторий и презентационные виджеты — без навигации. Приложение во всех ветках одинаковое, различается только слой навигации.

Во всех трёх ветках навигация размещена в одних и тех же файлах:

lib/router/
  app_router.dart            дерево приложения; единственное место,
                             которому известны обе вкладки
  feed_tab_router.dart       переходы вкладки Feed и ответы на запросы
                             её экранов
  contacts_tab_router.dart   то же для вкладки Contacts

Приложение состоит из двух вкладок, у каждой свой Navigator и свой стек:

Структура экранов и направления переходов
Структура экранов и направления переходов

Требования к навигации:

  • UserProfileScreen используется в обеих вкладках;

  • PostListScreen используется дважды: как корень вкладки Feed и как список постов выбранного автора;

  • UserPickerScreen открывается для получения результата — выбранного пользователя — и возвращает его экрану, инициировавшему запрос;

  • поведение кнопки «Posts by this user» на экране профиля определяется вкладкой, в которой этот экран открыт: во вкладке Feed список постов автора добавляется в стек текущей вкладки, во вкладке Contacts приложение переключается на вкладку Feed и открывает список постов там.

Последнее требование и определяет устройство навигации: один и тот же экран обрабатывает один и тот же запрос пользователя двумя разными способами в зависимости от контекста, в котором он открыт.


Часть 1. Императивная навигация

Реализация не использует сторонних пакетов: только Navigator и GlobalKey. Интерфейс перехода здесь выражен не абстрактным интерфейсом, а колбэком — по синтаксическим соображениям. Обработчик пишется непосредственно в том месте, где собирается экран, поэтому переход и его назначение видны в одной точке, без отдельного класса-реализации и перехода к его объявлению.

Ниже приведены четыре файла в том порядке, в каком их читает разработчик, открывший проект.

main.dart

void main() => runApp(App(appRouter: AppRouter()));

class App extends StatelessWidget {
  final AppRouter appRouter;

  const App({super.key, required this.appRouter});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      ...
      home: RootScreen(appRouter: appRouter),
    );
  }
}

AppRouter — обычный объект, создаётся до построения дерева виджетов и передаётся в корневой экран.

root_screen.dart

Корневой экран отвечает за один уровень навигации — переключение вкладок. Он не знает, какие экраны находятся внутри вкладок: содержимое каждой строит соответствующий роутер вкладки, переданный через rootBuilder.

Листинг root_screen.dart
class RootScreen extends StatelessWidget {
  final AppRouter appRouter;

  const RootScreen({super.key, required this.appRouter});

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: appRouter.currentTab,
      builder: (context, currentTab, child) => Scaffold(
        body: IndexedStack(
          index: currentTab.index,
          children: [
            TabNavigator(
              navigatorKey: appRouter.feedNavigatorKey,
              rootBuilder: appRouter.feedTabRouter.buildRoot,
            ),
            TabNavigator(
              navigatorKey: appRouter.contactsNavigatorKey,
              rootBuilder: appRouter.contactsTabRouter.buildRoot,
            ),
          ],
        ),
        bottomNavigationBar: NavigationBar(
          selectedIndex: currentTab.index,
          onDestinationSelected: (index) =>
              appRouter.switchTo(AppTab.values[index]),
          destinations: const [...],
        ),
      ),
    );
  }
}

app_router.dart

enum AppTab { feed, contacts }

final class AppRouter {
  final feedNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'feed');

  final contactsNavigatorKey = GlobalKey<NavigatorState>(
    debugLabel: 'contacts',
  );

  final currentTab = ValueNotifier(AppTab.feed);

  late final feedTabRouter = FeedTabRouter(navigatorKey: feedNavigatorKey);

  late final contactsTabRouter = ContactsTabRouter(
    navigatorKey: contactsNavigatorKey,
    onRoute: _onContactsTabRoute,
  );

  void switchTo(AppTab tab) => currentTab.value = tab;

  /// Последний обработчик цепочки: переход между вкладками требует знания
  /// обеих, поэтому обрабатывается здесь.
  Future<T?> _onContactsTabRoute<T>(ContactsTabRoute<T> route) async {
    switch (route) {
      case UserPostsRoute(:final userId):
        switchTo(AppTab.feed);
        feedTabRouter.openUserPosts(userId);
        return null;
    }
  }
}

Это единственное место, которому известны обе вкладки. Вкладка контактов не может открыть посты автора у себя — они принадлежат другой вкладке, — поэтому передаёт запрос сюда, а здесь выполняется переключение вкладки и открытие списка в ленте.

feed_tab_router.dart

final class FeedTabRouter with TabRouter {
  @override
  final GlobalKey<NavigatorState> navigatorKey;

  const FeedTabRouter({required this.navigatorKey});

  @override
  Widget buildRoot(BuildContext context) => _postList();

  /// Точка входа для запроса, пришедшего от вкладки контактов.
  void openUserPosts(String userId) => push(_postList(authorId: userId));

  Widget _postList({String? authorId}) => PostListScreen(
    authorId: authorId,
    onRoute: <T>(route) async {
      switch (route) {
        case OpenPostRoute(:final postId):
          push(_postDetails(postId));
          return null;
      }
    },
  );

  Widget _postDetails(String postId) => PostDetailsScreen(
    postId: postId,
    onRoute: <T>(route) async {
      switch (route) {
        case OpenAuthorRoute(:final userId):
          push(_userProfile(userId));
          return null;
        case PickUserRoute():
          return push<T>(_userPicker());
      }
    },
  );

  Widget _userProfile(String userId) => UserProfileScreen(
    userId: userId,
    onRoute: <T>(route) async {
      switch (route) {
        case OpenUserPostsRoute(:final userId):
          openUserPosts(userId);
          return null;
      }
    },
  );

  Widget _userPicker() => UserPickerScreen(
    onRoute: <T>(route) async {
      switch (route) {
        case UserPickedRoute(:final user):
          pop(user);
          return null;
      }
    },
  );
}

Файл содержит все переходы вкладки: метод на экран, внутри метода — все направления, к которым этот экран ведёт. Список постов открывает пост, пост — профиль автора или экран выбора пользователя, профиль — список постов автора.

contacts_tab_router.dart

Вкладка контактов устроена так же, но один из запросов она обслужить не может: посты автора принадлежат другой вкладке. Поэтому она объявляет собственный перечень запросов и передаёт такой запрос выше — тому, кто знает обе вкладки.

sealed class ContactsTabRoute<T> {
  const ContactsTabRoute();
}

final class UserPostsRoute extends ContactsTabRoute<Never> {
  final String userId;

  const UserPostsRoute(this.userId);
}

typedef OnContactsTabRoute = Future<T?> Function<T>(ContactsTabRoute<T> route);

final class ContactsTabRouter with TabRouter {
  ...

  final OnContactsTabRoute onRoute;

  @override
  Widget buildRoot(BuildContext context) => _contacts();

  Widget _contacts() => ContactsScreen(...);

  /// Тот же экран и тот же запрос, что и во вкладке ленты, но ответ другой:
  /// там посты остаются в стеке, здесь запрос уходит выше.
  Widget _userProfile(String userId) => UserProfileScreen(
    userId: userId,
    onRoute: <T>(route) async {
      switch (route) {
        case OpenUserPostsRoute(:final userId):
          onRoute(UserPostsRoute(userId));
          return null;
      }
    },
  );
}

Экран профиля здесь тот же самый, что и в ленте, и запрос от него приходит тот же. Различается только ответ, и находится он в файле вкладки — рядом с остальными её переходами.

Порядок чтения

Навигация читается сверху вниз по файлам, и на каждом уровне видно ровно тот слой, за который этот уровень отвечает:

  • main.dart — приложение и корневой экран;

  • root_screen.dart — две вкладки и переключение между ними;

  • app_router.dart — обе вкладки целиком и переходы между ними;

  • feed_tab_router.dart, contacts_tab_router.dart — все экраны вкладки и переходы внутри неё.

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


Часть 2. go_router

Здесь есть дерево локаций, и переход — это переход к локации. Приём тот же, меняется форма контракта: вместо колбэка экран принимает абстрактный интерфейс. Колбэк здесь не даёт синтаксического выигрыша: обработчик всё равно не пишется в месте объявления локации, поскольку построение экрана и реакция на нажатие разнесены по разным местам дерева.

Порядок файлов тот же.

main.dart

Отличается от части 1 одной строкой: вместо home передаётся routerConfig.

Листинг main.dart
void main() => runApp(App(appRouter: AppRouter()));

class App extends StatelessWidget {
  final AppRouter appRouter;

  const App({super.key, required this.appRouter});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      ...
      routerConfig: appRouter.router,
    );
  }
}

root_screen.dart

class RootScreen extends StatelessWidget {
  final StatefulNavigationShell navigationShell;

  const RootScreen({super.key, required this.navigationShell});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: navigationShell.currentIndex,
        onDestinationSelected: (index) => navigationShell.goBranch(
          index,
          initialLocation: index == navigationShell.currentIndex,
        ),
        destinations: const [...],
      ),
    );
  }
}

Корневой экран отвечает за переключение вкладок и не знает, что внутри них.

app_router.dart

final class AppRouter {
  late final GoRouter router = GoRouter(
    initialLocation: AppRouterPath.feed,
    routes: [
      StatefulShellRoute.indexedStack(
        builder: (context, state, navigationShell) =>
            RootScreen(navigationShell: navigationShell),
        branches: [
          StatefulShellBranch(routes: [feedTabRoute]),
          StatefulShellBranch(routes: [contactsTabRoute]),
        ],
      ),
    ],
  );
}

Две ветки, у каждой свой стек. Переход между вкладками отдельной обработки не требует: локация принадлежит другой ветке, и оболочка переключает вкладку сама.

feed_tab_router.dart

GoRoute get feedTabRoute => GoRoute(
  path: AppRouterPath.feed,
  builder: (context, state) =>
      const PostListScreen(coordinator: FeedPostListCoordinatorImpl()),
  routes: [
    GoRoute(
      path: AppRouterPath.post,
      builder: (context, state) => PostDetailsScreen(
        postId: state.pathParameters[AppRouterParam.postId]!,
        coordinator: const FeedPostDetailsCoordinatorImpl(),
      ),
    ),
    GoRoute(
      path: AppRouterPath.user,
      builder: (context, state) => UserProfileScreen(
        userId: state.pathParameters[AppRouterParam.userId]!,
        coordinator: const FeedUserProfileCoordinatorImpl(),
      ),
    ),
    GoRoute(
      path: AppRouterPath.userPicker,
      builder: (context, state) =>
          const UserPickerScreen(coordinator: UserPickerCoordinatorImpl()),
    ),
    GoRoute(
      path: AppRouterPath.userPosts,
      builder: (context, state) => PostListScreen(
        authorId: state.pathParameters[AppRouterParam.userId]!,
        coordinator: const FeedPostListCoordinatorImpl(),
      ),
    ),
  ],
);

final class FeedPostListCoordinatorImpl implements PostListScreenCoordinator {
  const FeedPostListCoordinatorImpl();

  @override
  void onPostRoute(BuildContext context, {required String postId}) =>
      GoRouter.of(context)
          .push(AppRouterPath.postIn(AppRouterPath.feed, postId));
}

final class FeedPostDetailsCoordinatorImpl
    implements PostDetailsScreenCoordinator {
  ...

  @override
  Future<User?> onPickUserRoute(BuildContext context) => GoRouter.of(context)
      .push<User>(AppRouterPath.userPickerIn(AppRouterPath.feed));
}

/// В ленте посты автора остаются в том же стеке — `push`.
final class FeedUserProfileCoordinatorImpl
    implements UserProfileScreenCoordinator {
  const FeedUserProfileCoordinatorImpl();

  @override
  void onUserPostsRoute(BuildContext context, {required String userId}) =>
      GoRouter.of(context)
          .push(AppRouterPath.userPostsIn(AppRouterPath.feed, userId));
}

...

Файл вкладки состоит из двух ярусов: сверху дерево локаций, снизу реализации интерфейсов её экранов.

contacts_tab_router.dart

Файл вкладки контактов устроен точно так же и целиком помещается на экран:

GoRoute get contactsTabRoute => GoRoute(
  path: AppRouterPath.contacts,
  builder: (context, state) =>
      const ContactsScreen(coordinator: ContactsCoordinatorImpl()),
  routes: [
    // Тот же экран профиля, что и в ленте, с другой реализацией.
    GoRoute(
      path: AppRouterPath.user,
      builder: (context, state) => UserProfileScreen(
        userId: state.pathParameters[AppRouterParam.userId]!,
        coordinator: const ContactsUserProfileCoordinatorImpl(),
      ),
    ),
  ],
);

final class ContactsCoordinatorImpl implements ContactsScreenCoordinator {
  const ContactsCoordinatorImpl();

  @override
  void onUserProfileRoute(BuildContext context, {required String userId}) =>
      GoRouter.of(context)
          .push(AppRouterPath.userIn(AppRouterPath.contacts, userId));
}

/// Тот же экран и тот же запрос, что и во вкладке ленты, но ответ другой.
/// `go` вместо `push`: локация принадлежит другой ветке, поэтому оболочка
/// переключает вкладку сама, а стек контактов остаётся на месте.
final class ContactsUserProfileCoordinatorImpl
    implements UserProfileScreenCoordinator {
  const ContactsUserProfileCoordinatorImpl();

  @override
  void onUserPostsRoute(BuildContext context, {required String userId}) =>
      GoRouter.of(context)
          .go(AppRouterPath.userPostsIn(AppRouterPath.feed, userId));
}

Два файла вкладок устроены одинаково, экран профиля в них один и тот же, а всё различие требований — одно слово в одной реализации: push в ленте, go в контактах.

Порядок чтения

Порядок чтения сохраняется, и на каждом уровне видно ровно свой слой:

  • main.dart — приложение и конфигурация роутера;

  • root_screen.dart — две вкладки и переключение между ними;

  • app_router.dart — обе ветки дерева;

  • feed_tab_router.dart, contacts_tab_router.dart — все локации вкладки и ответы на запросы её экранов.

Разница с частью 1 в том, что делегировать запрос вверх не требуется: переход в чужую вкладку — это обычная локация, и различие между вкладками выражается одним словом в одной реализации.


Часть 3. auto_route

Экраны и их контракты полностью совпадают с частью 2. Отличия два: пункт назначения — сгенерированный объект, а не строка, и между экраном и роутером появляется слой страниц, который удерживает аннотации генератора вне экранов.

Порядок файлов тот же.

main.dart

Отличается от части 2 тем, что конфигурация роутера берётся из сгенерированного config().

Листинг main.dart
void main() => runApp(App(appRouter: AppRouter()));

class App extends StatelessWidget {
  final AppRouter appRouter;

  const App({super.key, required this.appRouter});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      ...
      routerConfig: appRouter.config(),
    );
  }
}

root_page.dart

Корневой экран строится из вкладок средствами AutoTabsScaffold.

Листинг root_page.dart
@RoutePage()
class RootPage extends StatelessWidget {
  const RootPage({super.key});

  @override
  Widget build(BuildContext context) {
    return AutoTabsScaffold(
      routes: [feedTab(), contactsTab()],
      bottomNavigationBuilder: (context, tabsRouter) => NavigationBar(
        selectedIndex: tabsRouter.activeIndex,
        onDestinationSelected: tabsRouter.setActiveIndex,
        destinations: const [...],
      ),
    );
  }
}

Сами вкладки — пустые вложенные роутеры, собственных виджетов им не требуется:

// app_tabs.dart
const feedTab = EmptyShellRoute('FeedTab');
const contactsTab = EmptyShellRoute('ContactsTab');

app_router.dart

@AutoRouterConfig()
class AppRouter extends RootStackRouter {
  @override
  List<AutoRoute> get routes => [
    AutoRoute(
      path: AppRouterPath.root,
      page: RootRoute.page,
      initial: true,
      children: [feedTabRoute, contactsTabRoute],
    ),
  ];
}

feed_tab_router.dart

AutoRoute get feedTabRoute => AutoRoute(
  path: AppRouterPath.feed,
  page: feedTab,
  children: [
    AutoRoute(path: AppRouterPath.initial, page: FeedRoute.page),
    AutoRoute(path: AppRouterPath.post, page: FeedPostDetailsRoute.page),
    AutoRoute(path: AppRouterPath.user, page: FeedUserProfileRoute.page),
    AutoRoute(path: AppRouterPath.userPosts, page: FeedUserPostsRoute.page),
    AutoRoute(path: AppRouterPath.userPicker, page: FeedUserPickerRoute.page),
  ],
);

@RoutePage()
class FeedUserProfilePage extends StatelessWidget {
  final String userId;

  const FeedUserProfilePage({
    super.key,
    @PathParam('userId') required this.userId,
  });

  @override
  Widget build(BuildContext context) => UserProfileScreen(
    userId: userId,
    coordinator: const FeedUserProfileCoordinatorImpl(),
  );
}

...

final class FeedUserProfileCoordinatorImpl
    implements UserProfileScreenCoordinator {
  const FeedUserProfileCoordinatorImpl();

  @override
  void onUserPostsRoute(BuildContext context, {required String userId}) =>
      AutoRouter.of(context).push(FeedUserPostsRoute(userId: userId));
}

...

Ярусов здесь три: дерево локаций, страницы и реализации интерфейсов. Страница связывает экран с той реализацией, которая ему полагается в этой вкладке.

Переход в другую вкладку требует отдельного решения: вложенный стековый роутер не может добавить экран в соседнюю вкладку, поэтому навигируется корневой роутер, а нужную вкладку auto_route активирует сам.

// contacts_tab_router.dart
final class ContactsUserProfileCoordinatorImpl
    implements UserProfileScreenCoordinator {
  const ContactsUserProfileCoordinatorImpl();

  @override
  void onUserPostsRoute(BuildContext context, {required String userId}) =>
      AutoRouter.of(context)
          .root
          .navigatePath(AppRouterPath.feedUserPosts(userId));
}

Порядок чтения

Порядок чтения не изменился:

  • main.dart — приложение и конфигурация роутера;

  • root_page.dart — две вкладки и переключение между ними;

  • app_router.dart — дерево целиком;

  • feed_tab_router.dart — локации вкладки, её страницы и ответы на запросы экранов.

Кодогенерация добавляет слой страниц, но не меняет ни порядок чтения, ни распределение ответственности между уровнями.


Применимость

Приём не привязан к конкретной библиотеке. Экран сообщает о запросе пользователя; решение принимает тот, у кого есть необходимая для этого информация. Форма контракта — колбэк или абстрактный интерфейс — и механика перехода определяются выбранным инструментом, но распределение ответственности остаётся прежним. В статье показаны три реализации — на штатном Navigator, на go_router и на auto_route, — и тем же образом приём применяется к любому другому.

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

Ссылки

1. Khanlou, «The Coordinator» (2015) — статья, с которой пошёл паттерн Координатор.

2. Hudson, «How to use the coordinator pattern in iOS apps» — подробный разбор Координатора на практике.

3. Попков, «iOS Navigation: A Compact Router Approach» — упрощённая версия приёма для iOS. Часть 1 — её перенос на Flutter.

4. Navigation with Compose — официальный гайд Android: не передавать navController внутрь composable, а прокидывать колбэки.

5. example_routing_coordinator_flutter — репозиторий с кодом всех трёх реализаций.

6. Shevchuk, «Navigation done right: a case for hierarchical routing with Flutter» (2020) — близкий по смыслу подход во Flutter: решение о переходе поднимается от экрана к родителю.