Представляете — у Stack Overflow тоже есть индексная (главная) страница. Но были на ней немногие — в большинстве случаев пользователи заходят на сайт по прямой ссылке. Это типичный сценарий взаимодействия со многими сайтами, который хотелось бы поддержать и во Flutter-приложениях, скомпилированных для использования в вебе.
Также было бы хорошо, чтобы похожее поведение сохранялось и в мобильном приложении — например, чтобы пользователь мог сразу попасть на страницу товара, быстро добавить его в корзину и оплатить.
Такой сценарий называется deep linking — он позволяет открывать конкретные экраны приложения напрямую по ссылке.
В этом параграфе мы поговорим о том, как его можно реализовать и почему для этого может не хватить возможностей из классической модели стековой навигации на основе виджета Navigator.
Примечание
Примечание
Мы рассчитываем, что вы уже знакомы с этим виджетом, — а если нет, то советуем ознакомиться.
Структура URL
Для начала рассмотрим структуру адреса, который более известен как универсальный локатор ресурса (URL, определён в спецификации RFC1738). URL состоит из нескольких частей, среди которых обязательные только схема, название и адрес ресурса — главная страница (чаще всего /index.html).
Разберём адрес сайта 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.
Navigator 1.0
Это классическая модель навигации, интегрированная в виджете 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
), которое проходит через routeInformationProvider
→ routeInformationParser
→ routerDelegate
в метод 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 );
Итак, на текущий момент в нашем приложении уже реализованы функции:
-
переход между страницами с обновлением адресной строки через
currentConfiguration
→restoreRouteInformation
изShopParser
; -
обработка ссылки через
parseRouteInformation
изShopParser
→setNewRoutePath
— в этом легко убедиться, если запустить приложение через 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 для обновления адресной строки, на остальных платформах вызовы методов игнорируются.
В целом поток событий и вызовов методов можно отобразить на следующей диаграмме:
Библиотеки для поддержки диплинков
Существует несколько библиотек, которые можно подключить как для упрощения работы с 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)
applinks
Альтернативное решение для обработки диплинка — создаёт 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});
uni_links_desktop
Позволяет обрабатывать диплинки на 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-приложение;
-
познакомились с библиотеками и инструментами, упрощающими работу с навигацией и обработкой ссылок.
В результате у нас получилось гибкое и универсальное решение, которое позволяет запускать приложение в нужном состоянии, открывать экраны напрямую по ссылке и обеспечить предсказуемую навигацию на всех платформах.
А в следующем параграфе мы переключимся на другую важную тему — углубленную работу с асинхронным кодом. Это позволит выстраивать более эффективные архитектуры, использовать ресурсы устройства оптимально и лучше управлять асинхронностью в приложении.