Введение
Я занимаюсь мобильной разработкой больше десяти лет: сначала 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: решение о переходе поднимается от экрана к родителю.

