4.7. Продвинутая навигация: в вебе и на мобильных платформах

Представляете — у Stack Overflow тоже есть индексная (главная) страница. Но были на ней немногие — в большинстве случаев пользователи заходят на сайт по прямой ссылке. Это типичный сценарий взаимодействия со многими сайтами, который хотелось бы поддержать и во Flutter-приложениях, скомпилированных для использования в вебе.

Также было бы хорошо, чтобы похожее поведение сохранялось и в мобильном приложении — например, чтобы пользователь мог сразу попасть на страницу товара, быстро добавить его в корзину и оплатить.

Такой сценарий называется deep linking — он позволяет открывать конкретные экраны приложения напрямую по ссылке.

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

Примечание

Примечание

Мы рассчитываем, что вы уже знакомы с этим виджетом, — а если нет, то советуем ознакомиться.

Структура URL

Для начала рассмотрим структуру адреса, который более известен как универсальный локатор ресурса (URL, определён в спецификации RFC1738). URL состоит из нескольких частей, среди которых обязательные только схема, название и адрес ресурса — главная страница (чаще всего /index.html).

Flutter 4.4

Разберём адрес сайта https://education.yandex.ru/handbook/:

  • https — это схема (протокол),

  • education.yandex.ru — название (host),

  • /handbook — путь до ресурса (path).

URL может содержать и дополнительные данные. Их размещают в нескольких сегментах:

  • Путь к ресурсу (path) — используется чаще всего при REST-запросах и содержит указание на тип и идентификатор ресурса либо указывает на конкретный файл, размещённый на хостинге.
  • Запрос (query) — содержит дополнительные пары значений (key — value), которые могут использоваться после разбора адреса.
  • Фрагмент (fragment) — определяет указатель на часть ресурса, часто используется для прокрутки веб-страницы до необходимой секции. Но также может использоваться и в мобильных приложениях — например, для перехода на вкладку.

В URL схема (scheme) указывает, как должен интерпретироваться адрес, — например, http или https используются браузером как указание на сетевой протокол для извлечения ресурса. Однако в мобильных приложениях схема может быть произвольной строкой, не связанной с сетевым протоколом, например myapp://product/1. Это используется исключительно для идентификации приложения. Тем не менее, если схема совпадает с http/https и домен настроен должным образом, ссылка может быть перехвачена установленным мобильным приложением (через Universal Links или App Links).

Прежде чем мы разберёмся с адресацией произвольных страниц в мобильном приложении, давайте вспомним способы навигации между состояниями во Flutter.

Это классическая модель навигации, интегрированная в виджете WidgetsApp и, следовательно, в виджетах, которые от него наследуются:MaterialApp/CupertinoApp. Она основана на использовании виджета Navigator, который внутри себя содержит программно-управляемый контроллер OverlayHost для отображения перекрывающихся виджетов (виджетOverlayEntry).

Navigator 1.0 реализует модель стека маршрутов (класс Route), которые могут занимать всё доступное пространство экрана (например, класс PageRoute) или частично перекрывать содержимое экрана (класс PopupRoute).

При вызове методов состояния виджета Navigator (класс NavigatorState) изменяет состояние стека и выполняются несколько действий:

  • определяется новый список объектовRoute;
  • выполняется сравнение предыдущего и нового списка (может переопределяться в атрибуте transitionDelegate), и определяется список эффектов переходов, например для анимации появления и исчезновения;
  • выполняется уведомление всех наблюдателей о произошедших изменениях (из поля observers в виджете Navigator);
  • после завершения анимаций сохраняется новое состояние стека навигации и обновляется состояние виджета OverlayHost.

Таким образом, при изменении состояния стека визуально обновляется стек виджетов, при этом предыдущие страницы не скрываются, но и не обновляются, так как они не видны на экране. В этом легко убедиться: при открытии новой страницы в текущем State от StatefulWidget не вызывается метод dispose.

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

  • список категорий товаров (виджет CategoryListPage);
  • список товаров в выбранной категории (виджет ProductListPage, принимает название категории);
  • страница конкретного товара (виджет ProductDetailPage, принимает описание товара).

При выполнении перехода в это состояние нужно будет отслеживать текущее состояние стека, добавить промежуточные Route, удалить ненужные Route, которых нет в ожидаемом состоянии. Идеально было бы непосредственно передать нужный список объектов классаRoute на основе полученного адреса страницы и связать его с каким-либо абстрактным состоянием навигации. Именно это реализуется при использовании свойства pages в виджетеNavigator.

Свойство pages и навигация через состояние

В навигаторе свойство pages сохраняет список объектов Page. Внутри page реализуется метод создания Route с использованием переданных аргументов (фабрика createRoute), из которых собирается стек страниц. В приложениях часто используется подкласс MaterialPage, который поддерживает анимации переходов для Android и iOS. Кроме этого, доступна реализация CupertinoPage, которая поддерживает только iOS-анимацию и часто используется совместно с CupertinoApp.

Объекты классов MaterialPage и CupertinoPage реализуют createRoute через создание полноэкранного Route, занимающего весь экран устройства и при этом совместимого с Navigator 1.0. Благодаря этому остаётся возможность смешивать механизм pages и стек Route — например, для отображения модальных диалогов.

Для использования Page в виджете Navigator есть переопределяемое значение pages, которое представляет аналог Stack, но с поддержкой дополнительных аргументов, передаваемых в каждую страницу.

Давайте реализуем навигацию через состояние.

Предположим, что у нас уже реализованы виджеты списка категорий, списка товаров в категории и подробности товаров (код можно посмотреть в DartPad по ссылке). Приложение будем запускать в Flutter Web:

1flutter run -d chrome

Начнём с создания статического списка страниц, который соответствует состоянию стека, когда пользователь открыл список товаров и перешёл на страницу товара.

1class _MyAppState extends State<MyApp> {
2  @override
3  Widget build(BuildContext context) {
4    return MaterialApp(
5      title: 'Сладости',
6      theme: ThemeData(
7        primarySwatch: Colors.pink,
8      ),
9      home: Navigator(
10        onPopPage: (route, result) => route.didPop(result),
11        pages: [
12          MaterialPage(child: CategoryListPage(), name: 'category_list'),
13          MaterialPage(child: ProductListPage(category: 'Конфеты'), name: 'product_list'),
14          MaterialPage(
15              child: ProductDetailPage(
16            product: const {
17              'name': 'Мишки на севере',
18              'description': 'Жевательные конфеты с фруктовым вкусом.'
19            },
20          ), name: 'product_detail'),
21        ],
22      ),
23    );
24  }
25}
26

Здесь мы также добавили необязательный атрибут name, что будет удобно в дальнейшем для отладки переходов между страницами. При запуске мы увидим только последнюю страницу (ProductDetailPage), но при нажатии аппаратной кнопки «назад» или выполнения жеста «свайп вправо» будет последовательно отображаться список товаров в категории (ProductListPage) и список категорий (CategoryListPage).

Здесь виджет MyApp реализуется как StatefulWidget для дальнейшего управления состоянием и изменения стека навигации. Добавим дополнительно кнопку «К списку товаров», которая будет исключать из стека страниц ProductDetailPage через изменение состояния виджета с навигацией. Для этого сделаем выбранный товар частью состояния виджета и изменим его при переходе к списку товаров. Полный вариант кода можно посмотреть по ссылке.

1class _MyAppState extends State<MyApp> {
2  Map<String, String>? product = {
3    'name': 'Мишки на севере',
4    'description': 'Жевательные конфеты с фруктовым вкусом.'
5  };
6
7  @override
8  Widget build(BuildContext context) {
9    return MaterialApp(
10      title: 'Сладости',
11      theme: ThemeData(
12        primarySwatch: Colors.pink,
13      ),
14      home: Navigator(
15        onPopPage: (route, result) => route.didPop(result),
16        pages: [
17          MaterialPage(
18            child: CategoryListPage(),
19            name: 'category_list',
20          ),
21          MaterialPage(
22            child: ProductListPage(category: 'Конфеты'),
23            name: 'product_list',
24          ),
25          if (product != null)
26            MaterialPage(
27              child: ProductDetailPage(
28                product: product!,
29                gotoProductList: () {
30                  setState(() {
31                    product = null;
32                  });
33                },
34              ),
35              name: 'product_detail',
36            ),
37        ],
38      ),
39    );
40  }
41}
42

По текущему состоянию кода мы можем реализовать навигацию между списком товаров и описанием конкретного товара. Аналогичным образом может быть добавлен переход между списком категорий и списком товаров категории, для этого необходимо добавить еще одно поле в State и реализовать необходимые callback. Доработки в коде можно посмотреть по ссылке.

Теперь мы сделали все необходимое, чтобы реализовать прямой переход в любое состояние по ссылке. Но если вы попробуете запустить приложение в браузере, то увидите, что адресная строка остаётся неизменной при переходах, плюс не работает кнопка «Назад» в браузере.

Чтобы это исправить, нам нужно:

  • связать адрес страницы с состоянием навигации приложения;

  • обновлять отображаемый адрес в браузере в соответствии с актуальным состоянием.

Для этого мы используем виджет Router. Более подробно мы расскажем о нём в параграфе из следующего модуля, сейчас же рассмотрим его в аспекте обмена данными об адресе с браузером или мобильной операционной системой.

Связываем адрес страницы с состоянием навигации приложения

Для начала скажем пару слов о виджете Router.

Он встраивается в дерево вместо виджета Navigator и взаимодействует с объектом класса RouterDelegate, который создаёт виджет Navigator на основе актуального состояния навигации. При наследовании класса RouterDelegate указывается дженерик-тип T.

Он используется для передачи состояния между RouterDelegate<T> и другими объектами для обработки содержания ссылок. Это позволяет скрыть внутреннее представление состояния навигации в RouterDelegate<T>.

1class NavigationState {
2  final int? product;
3  final String? category;
4
5  NavigationState({this.product, this.category});
6
7  bool get isCategoryList => product == null && category == null;
8
9  bool get isProductList => product == null && category != null;
10
11  bool get isProduct => product != null && category != null;
12
13  NavigationState.categoryList() : this();
14
15  NavigationState.productList(String category) : this(category: category);
16
17  NavigationState.productInfo(
18    String category,
19    int product,
20  ) : this(
21          category: category,
22          product: product,
23        );
24}

Этот класс состояния будет использоваться в реализации двух классов:

  • RouterDelegate<T> — фабрика для создания дерева с виджетомNavigator на основе внутреннего состояния;

  • RouteInformationParser<T> — преобразователь информации о состоянии навигации в строковое представление (например, для обновления адресной строки в браузере) и в обратном направлении;

RouterDelegate должен реализовать несколько обязательных методов:

  • build — создаёт дерево виджетов, обычно включает виджетNavigator.

  • addListener и removeListener — регистрируют подписчиков, которые получают уведомления при изменении состояния навигации (при обработке setNewRoutePath). Часто для реализации используется миксин ChangeNotifier для стандартного поведения уведомления о новом состоянии через вызов notifyListeners().

  • popRoute — реализует действие при нажатии на кнопку назад. Часто используется реализация метода через Navigator.maybePop() из миксина PopNavigatorRouterDelegateMixin<T>.

  • setNewRoutePath — обновляет внутреннее состояние на основе полученного извне (из браузера или операционной системы) объекта передачи состояния навигации.

  • get currentConfiguration — создаёт объект передачи состояния навигации на основе внутреннего состояния RouterDelegate<T> (метод необязательный).

Также дополнительно могут быть определены следующие методы:

  • setInitialRoutePath — вызывается при запуске приложения или открытии веб-приложения, по умолчанию вызывает setNewRoutePath, но может выполнять альтернативную обработку.

  • setRestoredRoutePath — используется при восстановлении состояния приложения с использованием restorationScope (подробнее про это можно почитать в параграфе)

Для нашего приложения мы можем выбрать следующую схему адресов:

  • / - страница со списком категорий;

  • /products/<category> — страница со списком товаров в категории category;

  • /product/<category>/<product> — описание товара в категории category с идентификатором product.

Для преобразования между адресом страницы и состоянием навигации создадим класс, наследующий RouterInformationParser<NavigationState>, с реализацией двух методов:

  • parseRouteInformation — создаёт на основе информации об адресе внутреннее состояние навигации.

  • restoreRouteInformation — по состоянию навигации создаёт адрес для отображения в браузере.

Добавим также в объект состояния возможность отображения ошибки — для случая, когда адрес не связан ни с одним из известных состояний (bool notFound).

Информация об адресе передается в объекте RouteInformation, который объединяет в себе адрес страницы (в виде объекта класса Uri) и произвольный объект состояния, который может быть, например, представлен в хэш-части адреса.

Для удобства разбора адреса можно использовать свойство pathSegments из класса Uri:

1class ShopRouteParser extends RouteInformationParser<NavigationState> {
2  @override
3  Future<NavigationState> parseRouteInformation(
4    RouteInformation routeInformation,
5  ) async {
6    try {
7      final uri = routeInformation.uri;
8      if (uri.pathSegments.isEmpty) return NavigationState.categoryList();
9      if (uri.pathSegments.first == 'products' &&
10          uri.pathSegments.length == 2) {
11        return NavigationState.productList(uri.pathSegments.last);
12      }
13      if (uri.pathSegments.first == 'product' && uri.pathSegments.length == 3) {
14        String category = uri.pathSegments[1];
15        return NavigationState(
16          category: category,
17          product: int.parse(uri.pathSegments[2]),
18        );
19      }
20    } on Object catch (e) {
21      //not found
22    }
23    return NavigationState(notFound: true);
24  }
25
26  @override
27  RouteInformation restoreRouteInformation(NavigationState configuration) {
28    if (configuration.isCategoryList) {
29      return RouteInformation(uri: Uri.parse('/'));
30    }
31    if (configuration.isProductList) {
32      return RouteInformation(
33        uri: Uri.parse('/products/${configuration.category}'),
34      );
35    }
36    return RouteInformation(
37      uri: Uri.parse(
38        '/product/${configuration.category}/${configuration.product}',
39      ),
40    );
41  }
42}

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

Создаем делегат для заполнения pages — RouterDelegate

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

  • Внутри RouterDelegate сохраним текущее состояние приложения, в нашем случае оно будет повторять структуру NavigationState.

  • На основе текущего состояния создадим виджет Navigator в методе build.

  • В методе currentConfiguration определим преобразование состояния приложения в объект класса NavigationState. Этот метод будет использоваться для обновления адресной строки браузера.

  • Создадим несколько методов для переходов между различными состояниями, которые будут изменять внутреннее состояние и уведомлять подписчиков, среди которых также будет выполняться обновление адресной строки браузера.

  • Дополнительно создадим метод setNewRoutePath для обновления состояния при получении NavigationState извне, например при переходе по ссылке в браузере.

Сначала создадим подкласс RouterDelegate<NavigationState> и сразу добавим миксины ChangeNotifier для уведомления подписчиков через notifyListeners() и PopNavigatorRouterDelegateMixin<NavigationState>для поддержки действия скрытия последней страницы из списка pages при нажатии кнопки или выполнении жеста «назад»:

1class ShopRouterDelegate extends RouterDelegate<NavigationState>
2    with ChangeNotifier, PopNavigatorRouterDelegateMixin<NavigationState> {
3  // Состояние при запуске — список категорий.
4  String? _category;
5  int? _product;
6  bool _notFound = false;
7
8  final _navigatorKey = GlobalKey<NavigatorState>();
9
10  @override
11  GlobalKey<NavigatorState> get navigatorKey => _navigatorKey;
12
13  @override
14  Widget build(BuildContext context) => Container();
15
16  @override
17  Future<void> setNewRoutePath(NavigationState configuration) async {
18  }
19}

Вначале добавим обновление состояния при переходах между страницами и создадим вспомогательные методы навигации:

1  void gotoCategoriesList() {
2    _category = null;
3    _product = null;
4    _notFound = false;
5    notifyListeners();
6  }
7
8  void gotoProductsList(String category) {
9    _category = category;
10    _product = null;
11    _notFound = false;
12    notifyListeners();
13  }
14
15  void gotoProductInfo(String category, int product) {
16    _category = category;
17    _product = product;
18    _notFound = false;
19    notifyListeners();
20  }
21
22  void gotoNotFound() {
23    _notFound = true;
24    notifyListeners();
25  }

Теперь поддержим создание виджета Navigator с использованием информации о внутреннем состоянии RouterDelegate:

1  @override
2  Widget build(BuildContext context) =>
3      Navigator(
4        onPopPage: (route, result) => route.didPop(result),
5        pages: [
6          if (_notFound) MaterialPage(child: NotFoundPage()) else
7            ...[
8              MaterialPage(
9                child: CategoryListPage(
10                  showCategory: (category) => gotoProductsList(category),
11                ),
12                name: 'category_list',
13              ),
14              if (_category != null)
15                MaterialPage(
16                  child: ProductListPage(
17                    category: _category!,
18                    showProduct: (category, product) =>
19                        gotoProductInfo(category, product),
20                  ),
21                  name: 'product_list',
22                ),
23              if (_product != null)
24                MaterialPage(
25                  child: ProductDetailPage(
26                    product: categoryData[_category]![_product!],
27                    gotoProductList: () => gotoProductsList(_category!),
28                  ),
29                  name: 'product_detail',
30                ),
31            ],
32        ],
33      );

Здесь мы также дополнительно изменили callback-функции для переходов между страницами, чтобы передавать идентификаторы вместо полной информации о товаре. Все изменения можно увидеть в полном исходном тексте по ссылке.

Для отладки также добавим простую реализацию класса RouterInformationProvider, в которой можно сделать дополнительную обработку при отправке обновления адреса в среду выполнения, например в браузер, для этого отнаследуемся от стандартной реализации PlatformRouteInformationProvider и переопределим метод routerReportsNewRouteInformation.

Таким образом мы сможем видеть обновление адреса при переходах между страницами при запуске из DartPad внутри iframe:

1class ShopRouteProvider extends PlatformRouteInformationProvider {
2  ShopRouteProvider({required super.initialRouteInformation});
3
4  @override
5  void routerReportsNewRouteInformation(
6    RouteInformation routeInformation, {
7    RouteInformationReportingType type = RouteInformationReportingType.none,
8  }) {
9    super.routerReportsNewRouteInformation(routeInformation, type: type);
10    print('NEW ROUTE: ${routeInformation.uri.path}');
11  }
12}
13

При запуске приложения со стороны среды выполнения поступает сообщение о навигации в начальное состояние initialRoute (передается через объект классаRouteInformation), которое проходит через routeInformationProviderrouteInformationParserrouterDelegate в метод setInitialRoutePath, где происходит создание начального состояния и построение виджета Navigator с необходимым набором страниц.

Теперь нужно соединить информацию из RouteInformationParser<NavigationState> и RouterDelegate<NavigationState>, для этого вместо MaterialApp в основном виджете будет использовать именованный конструктор MaterialApp.router.

Обратите внимание, что теперь мы можем использовать StatelessWidget, поскольку состояние навигации сохранено в объекте класса RouterDelegate<NavigationState>:

1// Основное приложение
2class MyApp extends StatelessWidget {
3  @override
4  Widget build(BuildContext context) => MaterialApp.router(
5    title: 'Сладости',
6    theme: ThemeData(primarySwatch: Colors.pink),
7    routerDelegate: ShopRouterDelegate(),
8    routeInformationParser: ShopRouteParser(),
9    routeInformationProvider: ShopRouteProvider(),
10  );
11}

Также для обновления адреса в строке браузера и возможности обработки ссылки мы реализуем два метода:

  • currentConfiguration — возвращает текущее состояние из RouterDelegate<NavigationState> и передаёт его в RouteInformationParser, чтобы обновить адресную строку браузера;

  • setNewRoutePath — получает новое состояние из RouteInformationParser<NavigationState> и обновляет состояние приложения в соответствии с переданным адресом.

1  @override
2  Future<void> setNewRoutePath(NavigationState configuration) async {
3    _category = configuration.category;
4    _product = configuration.product;
5    _notFound = configuration.notFound;
6  }
7
8  @override
9  NavigationState get currentConfiguration => NavigationState(
10    category: _category,
11    product: _product,
12    notFound: _notFound,
13  );

Итак, на текущий момент в нашем приложении уже реализованы функции:

  • переход между страницами с обновлением адресной строки через currentConfigurationrestoreRouteInformation из ShopParser;

  • обработка ссылки через parseRouteInformation из ShopParsersetNewRoutePath — в этом легко убедиться, если запустить приложение через Chrome в сборке для web, перейти на страницу товара и изменить последнее число в адресной строке;

  • внутренняя навигация через изменение состояния, которая также может использоваться через прямое обращение к (Router.of(context).routerDelegate as ShopRouterDelegate).gotoCategoriesList().

Что ещё работает неправильно:

  • при навигации «Назад» адресная строка не обновляется (и routerReportsNewRouteInformation не вызывается), также в коде используется deprecated onPopPage в виджете Navigator;

  • адрес страницы записывается не в привычном виде через путь в URL, а в хеш-части, например /#/product/Конфеты/1.

Полный исходный текст приложения на текущий момент можно посмотреть по ссылке.

Обновление адресной строки при навигации «назад»

Исправим теперь обнаруженные недостатки — и начнём с onPopPageи одновременно исправим обновление адреса при переходе «назад». Теперь правильным способом считается реализация onDidRemovePage, которая получает объект Page и может изменить текущее состояние в соответствии с ним:

1    onDidRemovePage: (page) {
2      switch (page.name) {
3        case 'category_list':
4        // Здесь перехода нет, это первая страница.
5        case 'product_list':
6          gotoCategoriesList();
7        case 'product_detail':
8          gotoProductsList(_category!);
9      }
10    },

Этот способ позволит правильно обработать стрелку «назад» в Scaffold AppBar — теперь переход будет сообщать об обновлении состояния и обновлять адресную строку браузера. Но тут важно понимать, что onDidRemovePage вызывается при любом изменении стека, в том числе при вызове goto-метода делегата из кода.

Например, при нажатии на кнопку «К списку категорий» из страницы описания товара, метод onDidRemovePage будет вызван после того, как будет вызван gotoCategoriesList(). Переход запишет null в _category, а затем попытается в onDidRemovePage вызвать gotoProductsList, так как страница product_detail была удалена из списка страниц, и в этом месте будет ошибка при force-unwrap для значения _category.

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

Также такая реализация не решит проблему с кнопкой «назад» в браузере, например можно нажать на неё при наличии предыдущей истории перемещения из списка категорий. Для ограничения этого добавим PopScope к виджету CategoryListPage c canPop: false

1class CategoryListPage extends StatelessWidget {
2  @override
3  Widget build(BuildContext context) {
4    return PopScope(
5      canPop: false,
6      child: Scaffold(
7...
8    );
9  }
10}

Так как переход «назад» также может выполняться через навигацию браузера, можно управлять историей страниц вручную через вызов метода стратегии pushState, например:

1final strategy = HashUrlStrategy();
2strategy.pushState(null, 'Products', '/products/1');
3setUrlStrategy(strategy);

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

  • Через переопределение backButtonDispatcher (в основном приложении — RootBackButtonDispatcher, во вложенной навигации — ChildBackButtonDispatcher со ссылкой на корневой диспетчер). В этом случае уведомление о сообщении от платформы о необходимости выполнения перехода назад будет отправлено всем дочерним Router, которые самостоятельно принимают решение об обработке события в случае активной вкладки после вызова метода takePriority или игнорирования сообщения — в этом случае оно может быть обработан другим дочерним диспетчером или родительским диспетчером. Более подробно этот вопрос будет рассмотрен в параграфе об использовании Router по ссылке (вписать).

  • Через флаг reportsRouteUpdateToEngine, при значении false навигатор не будет сообщать об изменении состояния, и переход не будет влиять на содержимое адресной строки.

Исправленный исходный текст можно посмотреть по ссылке.

Представление адреса в URL Path

Для использования вместо хеша части пути в адресной строке необходимо изменить стратегию через метод usePathUrlStrategy() из пакета flutter_web_plugins (добавляется в pubspec.yaml dependencies с источником sdk: flutter). Но для правильной обработки также требуется, чтобы со стороны веб-сервера любой путь, кроме доступа к статическим ресурсам, обращался к index.html. Это можно сделать одним из способов после сборки web-приложения через flutter build web или flutter build web --release для подготовки к публикации:

  • запустить dart веб-сервер dhttpd, предварительно его нужно установить dart pub global activate dhttpd, затем запустить dhttpd '--headers=Cross-Origin-Embedder-Policy=credentialless;Cross-Origin-Opener-Policy=same-origin' -p 9080 --path=build/web, приложение будет доступно в браузере на адресе http://localhost:9080;

  • использовать конфигурацию nginx и запустить сервер, например, с использованием Docker:

    docker run -d -p 9080:80 -v ./default.conf:/etc/conf.d/default.conf -v build/web:/usr/share/nginx/html nginx

    1server {
    2    listen       80;
    3    server_name  localhost;
    4
    5    location / {
    6        root   /usr/share/nginx/html;
    7        index  index.html;
    8        try_files $uri $uri/ /index.html;
    9    }
    10}
    

Для сохранения местоположения и состояния также может быть реализована собственная стратегия, по аналогии с существующими HashUrlStrategy() и PathUrlStrategy()через реализацию интерфейса UrlStrategy. Для доступа к управлению адресной строкой внутри стратегий используется объект класса BrowserPlatformLocation из пакета dart:ui_web, который основан на использовании History API для браузера:

  • getPath() — вернуть путь до ресурса (для браузера может использоваться информация из объекта BrowserPlatformLocation);

  • getState() — вернуть внутреннее состояние ресурса, также извлекается из BrowserPlatformLocation;

  • prepareExternalUrl(internalUrl) — создать отображаемый адрес страницы на основе внутреннего URL;

  • pushState(state, title, url) — добавить новую запись в историю браузера;

  • replaceState(state, title, url) — заменить последнюю запись в истории браузера;

  • go(count) — перейти по истории браузера, count может быть отрицательным для перехода назад по истории и положительным для перехода вперёд;

  • addPopStateListener(listener) — регистрация обработчика события снятия состояния со стека (возвращает функцию для отписки обработчика).

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

Рассмотренный выше механизм обновления адресной строки и перехода по прямому адресу также применим для мобильных платформ — Android и iOS.

В мобильных приложениях существует два типа диплинков:

  • Схемные диплинки (myapp://some-path) — работают без подтверждения домена; если приложение не установлено, пользователь увидит диалог вида «Какое приложение открыть для этой ссылки?».

  • HTTP/HTTPS-ссылки (например,https://yourapp.com/some-page) — требуют подтверждения владения доменом, но обеспечивают бесшовный UX.

Для использования HTTP/HTTPS-ссылок используются разные решения для мобильных платформ:

  • iOS — Universal Links

Требуется разместить файл apple-app-site-association в директории /.well-known/ на сервере. Он сообщает системе, что ваше приложение действительно «имеет право» открывать ссылки с указанного домена.

  • Android — App Links

Аналогично нужно разместить файл assetlinks.json в /.well-known/. Кроме того, необходимо указать связанный домен в AndroidManifest.xml в intent-filter.

Нюансы для http/https-схем

Если вы используете http/https-схемы, нужно подтвердить владение указанным доменом. Для этого нужно разместить указанные выше файлы, доступные через веб-запрос, на хостинг домена, более подробно содержание файлов будет рассмотрено ниже.

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

Если на домене опубликовано приложение, собранное как Flutter Web (flutter build web), достаточно разместить соответствующие файлы в подкаталог <PROJECT_DIR>/web/.well-known проекта.

Диплинки работают следующим образом:

  • на стороне платформы регистрируется обработчик событий для перехода по зарегистрированным ссылкам (включается при наличии флагов в конфигурации проекта/метаданных в AndroidManifest.xml);
  • при обнаружении перехода по ссылке извлекается часть адреса, включающая путь и фрагмент (хеш) и отправляется со стороны среды выполнения в RouteInformationProvider;
  • информация о состоянии (RouteInformation) обрабатывается через RouteInformationParser с созданием транспортного объекта, указанного в дженерик-типе, для состояния навигации и уведомляет RouterDelegate через вызов setNewRoutePath;
  • после обновления внутреннего состояния RouterDelegate происходит запрос перестроения дерева виджетов и создается новый объект Navigator для перехода приложения в целевое состояние;
  • среда выполнения также получает информацию об обновлении состояния, например браузер изменяет содержимое адресной строки.

Добавим поддержку диплинков в наше приложение.

Android

Для начала необходимо изменить файл манифеста — зарегистрировать обработчик ссылок (intent-filter) и добавить метаданные для использования реализации из Flutter Engine в Activity:

1<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
2<intent-filter android:autoVerify="true"> 
3<action android:name="android.intent.action.VIEW" /> 
4<category android:name="android.intent.category.DEFAULT" /> 
5<category android:name="android.intent.category.BROWSABLE" /> 
6<data android:scheme="flutteryandex"/> 
7<data android:scheme="http" android:host="flutter.yandex.ru"/> 
8</intent-filter>

android:autoVerify="true" в intent-filter указывает Android автоматически проверять владение доменом и открывать приложение без диалога выбора. При такой регистрации приложение будет обрабатывать ссылки вида flutteryandex://anyhost/path и http://flutter.yandex.ru/path . При использовании ссылок с протоколами http/https также необходимо создать файл assetlinks.json следующего вида на хостинге указанного домена:

1[{ "relation": ["delegate_permission/common.handle_all_urls"],
2   "target": {
3     "namespace": "android_app",
4     "package_name": "ru.flutter.yandex",
5     "sha256_cert_fingerprints": ["HEX_FINGERPRINT"]
6   } 
7}]

Где взять sha256_cert_fingerprints?

  • Если используете собственный keystore:
1keytool -list -v -keystore ~/your-release-key.jks -alias your-key-alias
  • Если используете подпись Google Play — в Google Play Console:
    Release > App integrity > App signing key certificate — SHA256

package_name соответствует пакету приложения (из build.gradle android.namespace).

Для проверки корректности работы диплинка в Android нужно программно создать Intent:

1adb shell 'am start -a android.intent.action.VIEW \
2    -c android.intent.category.BROWSABLE \
3    -d "flutteryandex://flutter.yandex.ru/1"'

Здесь флаг -a определяет действие (совпадает с определением intent-filter: action), -c — категорию (intent-filter: category), a -d — данные с адресом ссылки. При использовании AppLinks также можно указать пакет приложения после команды adb shell, чтобы избежать диалога выбора обработчика ссылки (любая http-/https-ссылка также может быть открыта в браузере).

Для проверки правильности настройки обработки диплинка также можно использовать вкладку DeepLinks в DevTools, на которой отображаются все зарегистрированные домены и схемы и результат проверки принадлежности домена.

iOS

Для обработки DeepLinks нужно добавить в info.plist флаг FlutterDeepLinkingEnabled=true, связать в Runner → Signing & Capabilities связанные домены (AddCapability → AssociatedDomains) в виде applinks:домен, а также создать файл /.well-known/apple-app-site-association на хостинге указанного домена и убедиться, что заголовки ответа корректны. Например, для apple-app-site-association нельзя использовать редиректы, Content-Type обязательно application/json, файл тоже должен быть в json-формате.

1{
2  "applinks": {
3    "apps": [],
4    "details": [
5      {
6        "appIDs": [
7          "teamid.bundleid"
8        ],
9        "paths": [
10          "*"
11        ],
12        "components": [
13          {
14            "/": "/*"
15          }
16        ]
17      }
18    ]
19  },
20  "webcredentials": {
21    "apps": [
22      "teamid.bundleid"
23    ]
24  }
25}

Для обработки произвольных схем URL в info.plist необходимо внести следующий фрагмент:

1<key>CFBundleURLTypes</key> 
2<array> 
3. <dict> 
4    <key>CFBundleTypeRole</key> 
5    <string>Editor</string> 
6    <key>CFBundleURLName</key> 
7    <string></string> 
8    <key>CFBundleURLSchemes</key> 
9    <array> 
10      <string>unilinks</string> 
11    </array>
12  </dict> 
13</array>

Для тестирования ссылок эмулируется запуск браузера с соответствующим доменом:

1xcrun simctl openurl booted http://flutteryandex.ru/1
Что там под капотом у диплинков

На стороне нативных платформ создается платформенный канал flutter/navigation, который используется в направлении от платформы к Flutter Framework. При наступлении событий со стороны платформы (например, обработки диплинка) через метод-канал вызывается один из методов Dart:

  • pushRoute — отправка нового маршрута (передаёт строка для дальнейшего анализа);
  • pushRouteInformation — отправка информации о новом состоянии;
  • popRoute — скрытие последней страницы со стека (например, при аппаратной кнопке «назад»);
  • setInitialRoute — определение начального состояния приложения.

В Android Logcat можно увидеть сообщения с тегом NavigationChannel при обработке событий навигации.

Реализация вызовов методов выполняется в WidgetsBinding (инициализируется при запуске фреймворка или WidgetsFlutterBinging.ensureInitialized()).

При обработке новой ссылки выполняется уведомление подписчиков WidgetsBindingObserver через метод didPushRouteInformation. Первый из них, который возвращает true, останавливает обработку, это можно использовать для обработки нескольких схем ссылок.

Аналогично при скрытии страницы вызывается метод didPopRoute от подписчиков, при получении первого true последовательность вызовов завершается. Если ни один подписчик не вернёт true, вызывается метод SystemNavigator.pop(), что приведёт к скрытию приложения (через платформенный канал flutter/platform, метод SystemNavigator.pop).

Во Flutter-приложении регистрируются следующие подписчики:

  • RootBackButtonDispatcher — обрабатывает событие didPopRoute и делегирует обработку на вложенную навигацию, если требуется;
  • PlatformRouteInformationProvider — обрабатывает новые ссылки и отправляет события через RouteInformationParser в RouterDelegate;
  • _ViewState, _MediaQueryFromViewState, _WidgetsAppState — отслеживает изменение метрик экрана (например, ориентации) и состояние приложения (развёрнуто или свёрнуто);
  • также могут быть добавлены собственные WidgetsObservers для отслеживания изменений экрана и состояния приложения со стороны среды выполнения.

В обратном направлении (от Flutter к платформе) используется класс SystemNavigator, и через платформенный канал flutter/navigation вызов routeInformationUpdated актуализирует платформенное состояние для активного URL, также может быть выполнено обновление истории страниц (методы selectSingleEntryHistory и selectMultiEntryHistory). На текущий момент эти методы реализуются только в Flutter Web для обновления адресной строки, на остальных платформах вызовы методов игнорируются.

В целом поток событий и вызовов методов можно отобразить на следующей диаграмме:

Flutter 4.4

Библиотеки для поддержки диплинков

Существует несколько библиотек, которые можно подключить как для упрощения работы с Router (более подробно они будут рассмотрены в параграфе про Router), так и для обработки непосредственно диплинков.

GoRouter

Библиотека go_router предлагает декларативный способ описания зарегистрированных URL для приложения, при этом в описании могут использоваться подстановки переменных (например, /products/:id), а также контроль доступа с использованием механизма redirect.

Также go_router представляет методы-расширения для context, которые позволяют выполнять переходы между страницами с подходом, аналогичным стековой навигации (pushNamed, ...).

GoRouter поддерживает «из коробки» диплинки, при этом в списке GoRoute должен быть как минимум один объект с path: '/'. Также начальный путь по умолчанию может быть изменён через параметр initialLocation.

В самом простом варианте использования необходимо создать объект GoRouter и передать в него список объектов GoRoute с параметрами, определяющими URL и builder-функцию для создания виджета. Параметры могут быть извлечены из state.pathParameters при определении builder-функции, например:

1final goRouter = GoRouter(
2  routes: [
3//...другие маршруты
4    GoRoute(
5      path: '/products/:category',
6      builder: (context, state) => ProductsList(category: int.parse(state.pathParameters['category'])),
7    ),
8  ]
9)
10
11//...
12MaterialApp.router(
13  routerConfig: goRouter,
14)

Альтернативное решение для обработки диплинка — создаёт Stream объектов URI для уведомлений о получении ссылки. Может использоваться для фоновой обработки ссылок, без активации экрана приложения. Также оно будет работать, когда приложение уже открыто, в этом случае его можно использовать для навигации в приложении, когда модули и их состояние связываются через диплинк-ссылки.

Для использования важно отключить встроенный обработчик диплинков от Flutter:

1Android:
2<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
3
4iOS:
5<key>FlutterDeepLinkingEnabled</key>
6<false/>

Теперь можно подписаться на поток URI с перехваченными DeepLinks:

1final appLinks = AppLinks();
2final streamSub = appLinks.uriLinkStream.listen((uri) {
3  // обработка ссылок
4});

Позволяет обрабатывать диплинки на Windows/MacOS.

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

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

В этом случае необходимо отправить в среду выполнения сообщение о необходимости перехода на URI, в ответ на которое навигатор отправит уведомление в RouterDelegate и обновит состояние на целевое.

Для этого можно, например, использовать плагин url_launcher:

1await launchUrl(Uri.parse('flutteryandex://flutteryandex.ru/1'));

Например, в нашем приложении вызовы goto-методов могут быть заменены на launchUrl с соответствующими адресами страниц. Пример кода с реализацией можно посмотреть по ссылке. Учтите, что для просмотра нужно будет запустить код в приложении Flutter Web, в DartPad установлены ограничения для навигации внутри iframe.

Такой способ навигации может использоваться для связывания фрагментов многомодульного приложения и позволяет переходить к различным функциям приложения, в том числе из встроенных веб-приложений или пользовательского интерфейса, создаваемого на основе информации с бэкэнда (например, на DivKit).


Вот мы и рассмотрели ключевые механизмы, лежащие в основе Deep Links и работы с URL во Flutter-приложениях — как для веба, так и для мобильных платформ.

В рамках этой главы мы:

  • разобрали структуру URL и особенности её применения в веб- и мобильной среде;

  • научились преобразовывать URL в состояние навигации с помощью RouteInformationParser и обратно — формировать URL из состояния приложения; реализовали управление навигацией через RouterDelegate, построение списка страниц и обновление адресной строки;

  • подключили поддержку переходов «назад» и синхронизировали навигацию с браузером;

  • настроили диплинки на мобильных платформах, включая схемные ссылки, Universal Links и App Links;

  • изучили работу с платформенными каналами и механизмами, задействованными при передаче ссылок и переходов в Flutter-приложение;

  • познакомились с библиотеками и инструментами, упрощающими работу с навигацией и обработкой ссылок.

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

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

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

Отмечайте параграфы как прочитанные, чтобы видеть свой прогресс обучения

Предыдущий параграф4.6. Firebase: зачем нужен во Flutter и как подключить
Следующий параграф4.8. Продвинутая асинхронность: Future