Flutter 基础体系 · 第 7/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 导航与路由:Navigator、Router、Deep Link 和返回栈
Flutter 中的“页面跳转”至少涉及四个不同层次:
- Navigator:维护一组
Route,执行入栈、出栈和返回结果。 - Router:把外部路由信息,例如 Web URL、系统 Deep Link,转换成应用状态,再根据状态生成页面。
- Deep Link:从应用外部直接打开应用内部某个资源或页面的链接。
- 返回栈:用户进入页面后,应用按照什么顺序返回,以及系统返回、浏览器返回和页面内返回如何保持一致。
它们不是四套互相替代的机制。通常可以这样理解:
外部 URL / 系统返回事件
│
▼
Router
│ 将 URL 转为应用路由状态
▼
Navigator
│ 将页面状态变成 Route/Page
▼
页面树与返回栈
对于简单应用,直接使用 Navigator 就足够;对于需要支持 Deep Link、Web 浏览器前进后退、多窗口或复杂嵌套路由的应用,需要理解 Router 以及声明式页面栈。
一、先区分 Route、Navigator、Page 和 Router
1. Route 是一次导航中的运行时对象
Route<T> 表示 Navigator 管理的一个运行时页面单元。T 是该 Route 出栈时返回给调用方的结果类型。
常见 Route 包括:
MaterialPageRoute<T>:Material 风格的普通页面。CupertinoPageRoute<T>:iOS 风格的页面。PopupRoute<T>:对话框、菜单等弹出层的基础类型。PageRouteBuilder<T>:自定义转场。DialogRoute<T>:对话框 Route。
一个 Route 通常负责:
- 保存页面对应的 Widget;
- 管理进入和离开的动画;
- 参与返回操作;
- 决定是否遮挡下面的 Route;
- 在出栈时传回结果。
因此,下面的代码不是直接“把 Widget 放进栈”,而是创建了一个 MaterialPageRoute,再把它交给 Navigator:
Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => const DetailPage(),
),
);
2. Navigator 是 Route 的栈管理器
Navigator 的核心状态可以抽象为一个有序栈:
S = [RouteA, RouteB, RouteC]
栈顶是当前可见页面。主要操作如下:
| 操作 | 栈变化 | 含义 |
|---|---|---|
push(R) |
S → S + [R] |
进入新页面 |
pop(result) |
[..., R] → [...] |
移除栈顶并返回结果 |
pushReplacement(R) |
[..., A] → [..., R] |
用新页面替换栈顶 |
popUntil(predicate) |
移除若干栈顶 Route | 返回到满足条件的页面 |
pushAndRemoveUntil |
先入栈,再批量移除 | 常用于登录完成或重置流程 |
例如:
final selectedId = await Navigator.of(context).push<int>(
MaterialPageRoute(
builder: (_) => const ProductPickerPage(),
),
);
if (!context.mounted) {
return;
}
if (selectedId != null) {
debugPrint('选择了商品:$selectedId');
}
目标页面返回:
Navigator.of(context).pop(42);
这里的因果关系是:
push<int>返回一个Future<int?>;- 新 Route 成为栈顶;
- 用户在新页面执行
pop(42); - 新 Route 从栈中移除;
- 原来的
Future完成,值为42; - 调用方继续执行。
如果用户直接返回而没有结果,Future 通常完成为 null,所以调用方必须区分“未选择”和“选择了空值”这类业务语义。
3. Page 是声明式导航中的页面描述
Page 不是 Route 本身,而是对“页面应该存在于栈中”的描述。Navigator 会根据 pages 列表创建、复用或移除对应的 Route。
Navigator(
pages: const [
MaterialPage(
key: ValueKey('home'),
child: HomePage(),
),
MaterialPage(
key: ValueKey('detail'),
child: DetailPage(),
),
],
)
这里表达的是:
当前栈应该是 [HomePage, DetailPage]
而不是“立即调用 push 两次”。
Page 的 key 很重要。Navigator 使用页面身份和顺序比较新旧页面列表。若页面身份不稳定,可能导致:
- 页面 State 被错误复用;
- 页面 State 被意外销毁;
- 转场方向不符合预期;
- 返回后看到错误的旧数据。
这与 StatefulWidget、Key 和更新边界直接相关:路由状态决定页面是否存在,Page 的 key 决定同一位置上的页面是否被视为同一个实体。
4. Router 负责把路由信息与应用状态同步
Router 主要解决的是“外部路由状态如何进入应用,以及应用状态如何回写外部路由”的问题。
它通常包含这些组件:
RouteInformationProvider:提供路由信息,例如当前 URL。RouteInformationParser<T>:把RouteInformation解析成应用配置类型T。RouterDelegate<T>:根据配置生成页面,并响应应用状态变化。BackButtonDispatcher:把系统返回事件分发给对应 Router。
可以把 Router 的数据流表示为:
URL / 系统 Deep Link
│
▼
RouteInformationParser
│
▼
应用路由配置 T
│
▼
RouterDelegate.setNewRoutePath
│
▼
应用路由状态
│
▼
Navigator.pages
反向同步则是:
用户在应用内导航
│
▼
RouterDelegate 内部状态改变
│
▼
notifyListeners()
│
▼
Router 读取 currentConfiguration
│
▼
浏览器地址栏或系统路由状态更新
因此,Navigator 关注“栈如何执行”,Router 关注“路由状态如何同步”。Router 通常仍然会在内部使用 Navigator,它们不是二选一的两个页面系统。
二、Navigator 的命令式导航
2.1 基本入栈与出栈
一个最小可运行示例:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(
home: HomePage(),
));
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('首页')),
body: Center(
child: ElevatedButton(
onPressed: () async {
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => const DetailPage(),
),
);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result ?? '没有返回结果'),
),
);
},
child: const Text('打开详情页'),
),
),
);
}
}
class DetailPage extends StatelessWidget {
const DetailPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('详情')),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop('详情页已完成');
},
child: const Text('返回并传值'),
),
),
);
}
}
运行后:
- 初始栈为
[HomePage]; - 点击“打开详情页”后,栈变为
[HomePage, DetailPage]; - 点击“返回并传值”后,栈恢复为
[HomePage]; HomePage中等待的 Future 得到字符串;HomePage显示 SnackBar。
BuildContext 必须来自 Navigator 的后代位置。下面的调用可能找不到目标 Navigator:
Navigator.of(context).push(...);
如果这个 context 位于另一个 Navigator 之上,或者位于错误的嵌套层级,就会抛出“找不到 Navigator”的异常。对于需要操作根 Navigator 的场景,可以使用:
final rootNavigator = Navigator.of(context, rootNavigator: true);
但这不是无条件更好的选择。嵌套 Navigator 可能代表登录流程、标签页或 Shell 页面;错误地使用根 Navigator 会绕过局部返回栈。
2.2 替换和清空栈
登录成功后通常不希望用户按返回键回到登录页:
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (_) => const HomePage(),
),
(route) => false,
);
执行后:
原栈:[LoginPage]
新栈:[HomePage]
(route) => false 表示移除所有旧 Route。
如果只想把当前页面替换掉:
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => const HomePage(),
),
);
执行前后:
[A, LoginPage] → [A, HomePage]
常见误用是把 push 当成“切换页面”:
Navigator.of(context).push(...);
如果每次切换底部导航标签都 push 一个页面,用户可能得到:
[首页, 搜索, 购物车, 首页, 搜索, ...]
这会导致返回行为与用户预期不符。底部标签通常需要独立的导航栈、IndexedStack,或使用能够表达 Shell 路由的路由架构,而不是无条件 push。
2.3 popUntil 的条件必须稳定
例如返回到首页:
Navigator.of(context).popUntil(
(route) => route.isFirst,
);
也可以给 Route 设置名称:
MaterialPageRoute(
settings: const RouteSettings(name: '/home'),
builder: (_) => const HomePage(),
);
然后:
Navigator.of(context).popUntil(
ModalRoute.withName('/home'),
);
Route 名称不是页面身份的全部。声明式 Page 列表仍然需要通过稳定的 Key 表达页面实体;名称适合做条件判断,不应被误认为是完整的路由状态模型。
三、返回栈的真实行为
3.1 返回栈不是 Widget 树
Widget 树描述当前构建结果,Navigator 栈描述导航历史,两者相关但不相同。
假设:
栈:[Home, List, Detail]
用户从 Detail 返回 List 时:
Detail对应的 Route 被移除;List对应的 State 通常仍然存在,因为它原本就在栈中;List不一定重新执行initState;List的build是否重新执行,取决于状态变化和框架更新;Home仍然在栈中,不代表它此刻可见。
因此,不能把“页面重新出现”理解为“页面重新创建”。如果页面需要在返回时刷新,应使用明确的结果、状态管理或生命周期策略,而不是假设 initState 会再次执行。
例如:
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => const EditPage(),
),
);
if (!context.mounted) {
return;
}
if (changed == true) {
await _reload();
}
3.2 异步导航后必须检查 mounted
下面的代码存在生命周期风险:
onPressed: () async {
await Navigator.of(context).push(...);
setState(() {
// 可能已经不安全
});
}
等待期间,当前页面可能已经被其他流程移除。此时 State 已经不再 mounted,调用 setState 会产生异常或逻辑错误。
正确写法:
onPressed: () async {
await Navigator.of(context).push(...);
if (!context.mounted) {
return;
}
setState(() {
// 当前 State 仍然存在
});
}
这里的 context.mounted 是对当前 BuildContext 生命周期的检查。它不能解决业务上的竞态条件,例如返回结果已经过期;它只能避免向已经失效的页面提交 UI 更新。
3.3 Android 返回、iOS 手势与 Web 返回不是同一种事件
不同平台的返回来源不同:
- Android:系统返回按钮或系统返回手势;
- iOS:导航栏返回按钮或边缘返回手势;
- Web:浏览器历史记录的后退、前进;
- 桌面:窗口关闭、键盘快捷键或应用自定义命令。
Navigator 可以处理“当前栈是否能出栈”,但 Web 浏览器还存在浏览器历史;Router 的职责就是让应用内部路由状态与浏览器历史保持同步。
在页面需要阻止返回时,应使用 PopScope:
class EditPage extends StatefulWidget {
const EditPage({super.key});
@override
State<EditPage> createState() => _EditPageState();
}
class _EditPageState extends State<EditPage> {
bool dirty = true;
@override
Widget build(BuildContext context) {
return PopScope<void>(
canPop: !dirty,
onPopInvokedWithResult: (didPop, result) async {
if (didPop || !dirty) {
return;
}
final shouldDiscard = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('放弃修改?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('继续编辑'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('放弃'),
),
],
);
},
);
if (!context.mounted) {
return;
}
if (shouldDiscard == true) {
Navigator.of(context).pop();
}
},
child: Scaffold(
appBar: AppBar(title: const Text('编辑')),
body: const Center(child: Text('编辑内容')),
),
);
}
}
关键点有两个:
canPop表达是否允许这次返回;onPopInvokedWithResult是返回尝试发生后的通知,不应把它当作一个可以任意取消返回的旧式回调。
WillPopScope 在当前 Flutter 中属于旧 API,尤其不能正确参与 Android 的预测性返回手势;新代码应优先使用 PopScope。如果页面处于嵌套 Navigator 中,还需要考虑返回事件是否应该由子 Navigator 先消费。
四、嵌套 Navigator 与局部返回栈
嵌套路由常见于:
- 底部标签页,每个标签保留自己的浏览历史;
- 登录流程内部的多步页面;
- 平板或桌面中的左右分栏;
- 一个页面中的设置向导。
例如,三个标签分别维护自己的栈:
根 Navigator
├── Tab A Navigator: [AHome, ADetail]
├── Tab B Navigator: [BHome]
└── Tab C Navigator: [CHome, CEdit]
用户在 Tab A 的详情页按返回时,应该先变成:
[AHome, ADetail] → [AHome]
而不是直接退出整个应用。
这要求返回事件按层级传播:
- 当前子 Navigator 能 pop,则子 Navigator 消费;
- 子 Navigator 不能 pop,则交给父 Navigator;
- 父 Navigator 也不能 pop,则交给系统或关闭应用。
Router 体系中,BackButtonDispatcher 可以用于组织这种层级关系。实际工程中,复杂嵌套路由通常使用成熟的路由包或自定义 Shell,但无论使用何种工具,返回优先级都应符合上述因果链。
不要只根据当前显示的 Widget 判断返回行为。真正决定返回的是当前生效的 Navigator 以及它所管理的 Route/Page 栈。
五、命令式 Navigator 与声明式 Router 的差异
5.1 命令式模型
命令式导航直接发出动作:
Navigator.of(context).pushNamed('/detail');
Navigator.of(context).pop();
它适合:
- 页面数量较少;
- 主要在应用内部点击按钮跳转;
- 不需要复杂 URL 同步;
- 导航行为集中在页面交互中。
命令式模型的优势是直观。缺点是路由状态容易散落在各个回调中,外部 URL 很难成为可靠的单一事实来源。
5.2 声明式模型
声明式模型保存应用路由状态:
class AppRouteState {
final String path;
final int? productId;
const AppRouteState({
required this.path,
this.productId,
});
}
然后根据状态生成页面:
final pages = <Page<void>>[
const MaterialPage(
key: ValueKey('home'),
child: HomePage(),
),
if (state.path == '/products/42')
const MaterialPage(
key: ValueKey('product-42'),
child: ProductPage(id: 42),
),
];
声明式模型的核心不在于“代码更现代”,而在于它建立了一个可验证的不变量:
页面栈 = f(当前路由状态)
只要路由状态确定,页面栈就应该确定。外部 URL、应用内点击和浏览器后退都可以归约为“改变路由状态”,而不是分别维护多套跳转逻辑。
六、一个可运行的 Router 示例
下面实现一个最小 Router,支持:
/首页;/detail详情页;- 浏览器 URL 或系统路由进入应用;
- 应用内点击更新路由;
- Navigator 返回时更新 Router 状态。
import 'package:flutter/material.dart';
void main() {
runApp(
MaterialApp.router(
routerDelegate: AppRouterDelegate(),
routeInformationParser: AppRouteInformationParser(),
),
);
}
class AppRouteInformationParser
extends RouteInformationParser<String> {
@override
Future<String> parseRouteInformation(
RouteInformation routeInformation,
) async {
final path = routeInformation.uri.path;
if (path == '/detail') {
return '/detail';
}
return '/';
}
@override
RouteInformation restoreRouteInformation(String configuration) {
return RouteInformation(
uri: Uri.parse(configuration),
);
}
}
class AppRouterDelegate extends RouterDelegate<String>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<String> {
@override
final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>();
String _path = '/';
@override
String get currentConfiguration => _path;
@override
Future<void> setNewRoutePath(String configuration) async {
_path = configuration == '/detail' ? '/detail' : '/';
notifyListeners();
}
void openDetail() {
if (_path == '/detail') {
return;
}
_path = '/detail';
notifyListeners();
}
void goHome() {
if (_path == '/') {
return;
}
_path = '/';
notifyListeners();
}
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
MaterialPage<void>(
key: const ValueKey('home'),
child: HomePage(onOpenDetail: openDetail),
),
if (_path == '/detail')
const MaterialPage<void>(
key: ValueKey('detail'),
child: DetailPage(),
),
],
onDidRemovePage: (route, result) {
if (_path == '/detail') {
_path = '/';
notifyListeners();
}
},
);
}
}
class HomePage extends StatelessWidget {
final VoidCallback onOpenDetail;
const HomePage({
super.key,
required this.onOpenDetail,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('首页')),
body: Center(
child: ElevatedButton(
onPressed: onOpenDetail,
child: const Text('打开详情'),
),
),
);
}
}
class DetailPage extends StatelessWidget {
const DetailPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('详情')),
body: const Center(
child: Text('详情内容'),
),
);
}
}
6.1 初始启动过程
假设应用启动时收到 /detail:
1. RouteInformationProvider 提供 /detail
2. AppRouteInformationParser 将其解析为字符串 /detail
3. RouterDelegate.setNewRoutePath('/detail')
4. _path 变为 /detail
5. build 生成 [HomePage, DetailPage]
6. Navigator 显示 DetailPage
假设启动时没有 Deep Link:
1. 解析得到 /
2. _path 为 /
3. pages 只有 HomePage
6.2 应用内点击过程
用户在首页点击“打开详情”:
1. HomePage 调用 onOpenDetail
2. RouterDelegate.openDetail()
3. _path 从 / 变为 /detail
4. notifyListeners()
5. RouterDelegate 重建
6. pages 从 [HomePage] 变为 [HomePage, DetailPage]
7. Navigator 根据 Page 差异创建详情 Route
8. Router 将 currentConfiguration 反向恢复为 /detail
notifyListeners() 是关键。如果只修改 _path 而不通知 Router,页面树不会因为这个状态变化而及时重建。
6.3 用户返回过程
用户在详情页点击系统返回或 AppBar 返回:
1. Navigator 尝试移除 DetailPage 对应的 Route
2. onDidRemovePage 被调用
3. RouterDelegate 将 _path 改回 /
4. notifyListeners()
5. pages 恢复为 [HomePage]
6. 浏览器 URL 或外部路由状态同步为 /
声明式导航中,onDidRemovePage 的职责是把 Navigator 产生的“页面被移除”反映回应用路由状态。若只让 Navigator 出栈,却不更新 _path,下一次 Router 重建时仍可能根据旧状态重新生成详情页,形成“刚返回又出现”的错误。
6.4 代码中的限制
这个示例为了展示机制,把配置简化成了字符串,并且只识别两个路径。生产应用至少需要处理:
- 路径参数,例如
/products/42; - 查询参数,例如
/search?q=flutter; - 未知路径;
- 登录状态不满足时的重定向;
- 资源不存在时的 404;
- 认证恢复和异步初始化;
- 多层嵌套页面;
- 页面状态恢复。
路由解析不应把任意字符串直接当作合法业务状态。更稳妥的做法是定义不可变的路由配置类型:
sealed class AppRouteConfig {
const AppRouteConfig();
}
class HomeRouteConfig extends AppRouteConfig {
const HomeRouteConfig();
}
class ProductRouteConfig extends AppRouteConfig {
final int id;
const ProductRouteConfig(this.id);
}
class NotFoundRouteConfig extends AppRouteConfig {
const NotFoundRouteConfig();
}
这样可以把“解析失败”“资源不存在”和“首页”区分开,而不是都退化为 /。
七、Deep Link 到底是什么
Deep Link 是一个可以直接定位到应用内部资源或页面的外部链接。例如:
https://example.com/products/42
myshop://products/42
它与普通启动的差异在于:应用不是只进入首页,而是需要根据链接恢复目标路由状态。
Deep Link 通常包含三部分:
协议或 scheme:https / myshop
主机:example.com
路径与参数:/products/42
在 Flutter 内部,最终需要把这些外部信息转换成应用能理解的配置:
final uri = Uri.parse('https://example.com/products/42');
print(uri.scheme); // https
print(uri.host); // example.com
print(uri.path); // /products/42
不要仅用字符串截取 URL。Uri 能正确处理查询参数、编码和路径结构,例如:
final uri = Uri.parse(
'https://example.com/search?q=flutter&page=2',
);
final query = uri.queryParameters['q']; // flutter
final page = uri.queryParameters['page']; // 2
7.1 Deep Link 的完整链路
以移动端链接为例:
用户点击链接
│
▼
操作系统判断由哪个应用处理
│
▼
Android Intent / iOS URL 或 Universal Link
│
▼
Flutter 引擎与路由信息通道
│
▼
RouteInformationProvider
│
▼
RouteInformationParser
│
▼
RouterDelegate
│
▼
Navigator.pages
│
▼
目标页面
任何一层配置错误都可能造成不同故障:
- 操作系统没有把链接交给应用;
- 应用收到链接,但 Flutter 路由解析为首页;
- 页面出现,但刷新或返回后 URL 不一致;
- URL 可以打开应用,但无法定位到正确资源;
- 未登录时直接打开受保护页面,应用没有定义重定向规则。
八、Android、iOS、桌面和 Web 的差异
8.1 Android
Android 的 Deep Link 通常通过 intent-filter 声明。自定义 scheme 示例:
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="myshop"
android:host="products" />
</intent-filter>
</activity>
实际项目中的 activity 属性应合并到 Flutter 工程现有配置中,不要为了添加 Deep Link 而删除 Flutter 插件、主题或启动配置。
Android App Links 使用 https,还需要域名侧的关联文件和签名配置。仅写 https 的 intent-filter 并不等于已经完成可信 App Link;域名验证失败时,系统可能让浏览器打开,或弹出应用选择器。
测试自定义 scheme 可以使用:
adb shell am start \
-a android.intent.action.VIEW \
-d "myshop://products/42"
预期结果是:
- Android 找到匹配的 Activity;
- Flutter 应用启动或恢复;
- Flutter 获得对应 URI;
- Router 解析出商品 ID
42; - 应用显示商品详情页。
如果应用只是打开首页,优先检查三处:
- AndroidManifest 的 scheme、host、path 是否匹配;
- Flutter 是否真正收到完整 URI;
RouteInformationParser是否解析了路径参数。
8.2 iOS
iOS 自定义 URL Scheme 通常需要在 Xcode 的 URL Types 中声明。Universal Links 使用 https,还需要:
- App 的 Associated Domains 能力;
applinks:example.com等域名声明;- 网站部署正确的
apple-app-site-association文件; - Bundle ID 和 Team ID 等关联信息正确。
自定义 scheme 的优点是配置相对直接,缺点是 scheme 可能被其他应用抢占,不能提供与 Universal Links 相同的域名归属保证。
Universal Link 打不开应用时,不能只检查 Flutter 代码。系统侧的域名文件、HTTPS、关联能力和设备缓存都可能导致失败。
8.3 Web
Web 中,Deep Link 本质上是浏览器 URL。Flutter 应用需要处理两种问题:
- URL 如何传入 Flutter;
- 浏览器刷新时,服务器是否仍然返回 Flutter 的入口页面。
如果使用路径 URL,例如:
https://example.com/products/42
服务器必须把未知路径重写到 Flutter Web 的入口 HTML。否则:
- 应用内点击可以工作;
- 复制链接后新开页面可能工作;
- 直接刷新
/products/42时服务器返回 404。
Flutter Web 还可以使用 hash URL:
https://example.com/#/products/42
Hash URL 通常不要求服务器为每个路径配置回退,但 URL 对搜索引擎、服务器日志和用户体验的表现不同。切换到 path URL 时,应使用 Flutter Web 提供的 URL 策略,并同步配置 Web 服务器;不能只修改客户端代码。
浏览器前进和后退要求 Router 的 currentConfiguration 与 restoreRouteInformation 一致。若应用内状态改变却没有通知 Router,地址栏不会正确变化;若解析器把多个 URL 都折叠为同一配置,浏览器历史也无法准确恢复页面。
8.4 桌面
Windows、macOS 和 Linux 没有一套由 Flutter 统一封装的跨平台 Deep Link 行为。常见方式包括:
- 注册自定义协议;
- 注册文件关联;
- 平台启动参数;
- macOS 的 URL Event;
- Windows 或 Linux 的平台消息与进程参数。
Flutter 层可以复用同一套路由配置和解析逻辑,但“操作系统如何把链接交给应用”仍需平台实现。桌面还要考虑:
- 应用已经运行时,是新开窗口还是复用现有窗口;
- 多窗口时链接交给哪个窗口;
- 同一链接重复打开时是否创建新的页面;
- 启动参数和运行中平台事件是否使用同一条数据通道。
九、路径参数、查询参数和无效链接
一个实际解析器不能只判断固定路径:
class ParsedRoute {
final String name;
final int? productId;
final String? query;
const ParsedRoute({
required this.name,
this.productId,
this.query,
});
}
ParsedRoute parseUri(Uri uri) {
final segments = uri.pathSegments;
if (segments.length == 2 &&
segments[0] == 'products') {
final id = int.tryParse(segments[1]);
if (id != null) {
return ParsedRoute(
name: 'product',
productId: id,
);
}
}
if (uri.path == '/search') {
return ParsedRoute(
name: 'search',
query: uri.queryParameters['q'],
);
}
return const ParsedRoute(name: 'not-found');
}
这里有三个不同结果:
/products/42 → 合法的商品路由,id = 42
/products/abc → 路径结构看似正确,但 id 无法解析
/unknown → 未知路由
它们不应全部静默跳转首页。否则用户会看到“链接打开了,但内容不对”,也会让监控无法发现错误。
更合理的行为是:
- 格式错误:显示 404 或错误页面;
- 格式正确但资源不存在:显示资源不存在;
- 需要登录:保存原始目标,完成认证后重定向;
- 权限不足:显示无权限页面,而不是把用户误导到首页。
重定向必须避免循环。例如:
/protected → /login
/login → /protected
如果登录页又根据旧目标立即跳回受保护页,就会形成循环。路由状态应显式保存认证阶段和待返回目标,而不是通过多个页面回调互相 push。
十、Router 的生命周期与状态边界
10.1 setNewRoutePath 不等于页面构建
RouterDelegate.setNewRoutePath 接收的是解析后的路由配置,不是 Widget。它的职责是更新路由状态:
@override
Future<void> setNewRoutePath(AppRouteConfig configuration) async {
_configuration = configuration;
notifyListeners();
}
随后 Router 才会重新构建 delegate,delegate 再生成 Navigator 和页面。
因此不应在解析器中直接操作 BuildContext 或 push 页面:
// 不推荐:解析器不应该直接跳转页面
class BadParser extends RouteInformationParser<String> {
// 不要在这里寻找 Navigator 并调用 push
}
解析器应该是“外部表示 → 路由配置”的转换层;页面如何生成由 RouterDelegate 决定。
10.2 路由状态不能等同于业务数据
例如:
/product/42
只说明用户想访问商品 42,不代表商品 42 已经从服务器加载成功。正确的数据流通常是:
路由配置 Product(42)
│
▼
Feature 层加载商品 42
│
┌────┴────┐
▼ ▼
成功 失败
│ │
显示详情 显示错误或不存在
路由层应决定“目标资源是谁”,Feature 或数据层决定“资源是否存在、如何加载”。将网络请求、鉴权和页面栈全部写进 RouterDelegate,会让路由状态与业务状态互相污染,也会增加测试和恢复的复杂度。
这与大型应用分层有关:
平台入口 / Deep Link
↓
路由解析层
↓
应用导航状态
↓
Feature 页面
↓
领域与数据层
Router 不应成为全局服务定位器。依赖注入、认证状态和资源仓库应通过明确的应用边界传入页面或 Feature。
10.3 异步初始化和导航竞态
应用启动时可能同时发生:
- 收到 Deep Link;
- 恢复本地登录状态;
- 加载远程配置;
- 恢复页面状态;
- 用户触发首次点击。
如果路由状态在认证状态确定前就执行,可能出现:
Deep Link /orders/42
↓
认证状态未知
↓
错误显示登录页
↓
认证完成后没有恢复 /orders/42
应把认证状态建模为至少三态:
unknown → signedOut
unknown → signedIn
在 unknown 阶段显示启动状态或等待配置,而不是把它误判为 signedOut。认证完成后再决定:
signedOut + protected target → login,并保存 target
signedIn + protected target → 原目标页面
signedIn + public target → 原目标页面
这是一种状态转换问题,不是简单的 push 顺序问题。
十一、声明式 Page 栈的返回条件
声明式 Navigator 的核心约束可以写成:
P = f(R)
其中:
R是应用当前路由状态;P是Navigator.pages列表;f是根据路由状态生成页面列表的函数。
假设:
R = Home → P = [HomePage]
R = Detail → P = [HomePage, DetailPage]
那么返回操作必须满足:
Detail → Home
而不能只修改 Navigator 的视觉结果,却保留:
R = Detail, P = [HomePage]
因为下一次重建时,f(Detail) 仍然会生成详情页。
因此,onDidRemovePage 中更新路由状态不是形式要求,而是维持不变量 P = f(R) 的必要步骤。
反例:
onDidRemovePage: (route, result) {
// 什么也不做
}
表现可能是:
- 用户第一次返回,详情页暂时消失;
- 其他状态触发 Router 重建;
- delegate 根据仍然是
/detail的状态重新生成详情页; - 用户感觉返回失效。
十二、Navigator 1.0 风格的命名路由边界
Flutter 提供命名路由相关 API,例如:
MaterialApp(
initialRoute: '/',
routes: {
'/': (_) => const HomePage(),
'/detail': (_) => const DetailPage(),
},
);
调用:
Navigator.of(context).pushNamed('/detail');
它适合非常简单的固定路径。但命名路由表不是完整的路由系统,尤其在以下场景会迅速遇到限制:
- 路径参数
/products/:id; - 查询参数;
- 登录重定向;
- 嵌套导航;
- Web URL 同步;
- 复杂错误页;
- 多层 Shell;
- 可测试的路由配置。
onGenerateRoute 可以提供更多控制:
MaterialApp(
onGenerateRoute: (settings) {
if (settings.name == '/detail') {
return MaterialPageRoute(
settings: settings,
builder: (_) => const DetailPage(),
);
}
return MaterialPageRoute(
builder: (_) => const NotFoundPage(),
);
},
);
但它仍然主要是命令式 Navigator 的 Route 创建入口。不要因为使用了字符串路径,就认为应用已经拥有 Router 的声明式同步能力。
十三、常见失败表现与诊断方法
13.1 Deep Link 打开应用但总是首页
按层诊断:
- 操作系统层:系统是否真的把 URI 交给应用;
- Flutter 输入层:应用收到的是完整 URI 还是只有默认
/; - 解析层:
RouteInformationParser是否识别 path、query 和参数; - 状态层:
setNewRoutePath是否更新并通知; - 页面层:
pages是否根据新状态包含目标页面; - 数据层:目标资源不存在时是否被错误地回退到首页。
在解析器入口记录:
debugPrint(
'uri=${routeInformation.uri}',
);
在 delegate 中记录:
debugPrint(
'new configuration=$configuration',
);
如果第一条日志没有出现,问题不在页面;如果配置正确但页面不变,问题通常在 delegate 状态或 notifyListeners()。
13.2 Web 应用内跳转正常,刷新后 404
这通常不是 Flutter Navigator 的问题,而是服务器没有配置 SPA 回退。
验证步骤:
1. 在应用内点击进入 /products/42
2. 复制地址
3. 新开浏览器标签直接访问
4. 观察是服务器返回 404,还是 Flutter 显示 404 页面
两者含义不同:
- 服务器 404:Web 部署回退配置缺失;
- Flutter 404 页面:服务器已交给 Flutter,应用路由解析不识别该路径。
13.3 返回后又回到刚才的页面
命令式导航中,可能是重复调用了 push;声明式导航中,常见原因是:
- Route 出栈后没有更新路由状态;
onDidRemovePage没有处理对应页面;Pagekey 不稳定导致页面身份比较异常;- 外部 URL 又把旧路径写回应用。
需要同时打印:
当前路由配置
Navigator.pages
onDidRemovePage 回调
浏览器地址或收到的外部 URI
只看屏幕现象通常无法区分这几类问题。
13.4 Navigator.of(context) 找不到 Navigator
原因通常是 context 层级错误:
Builder(
builder: (innerContext) {
return ElevatedButton(
onPressed: () {
Navigator.of(innerContext).push(...);
},
child: const Text('打开'),
);
},
);
Builder 不是解决所有问题的固定模板,它只是创建了一个位于当前 Widget 树更深处的新 BuildContext。更根本的做法是明确哪个 Navigator 应该负责该操作,以及页面是否应该通过回调或路由状态触发导航。
13.5 页面返回后数据没有刷新
不要依赖 initState。优先使用返回值:
final didSave = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => const EditPage(),
),
);
if (!context.mounted) {
return;
}
if (didSave == true) {
await reload();
}
如果多个页面都可能修改同一实体,则应把实体状态放在共享的 Feature 或状态管理层,由页面订阅状态,而不是让每一层都手动传递刷新信号。
十四、路由状态恢复与返回栈恢复
导航恢复包含两个不同问题:
- 恢复当前路由位置:应用重启后知道用户在
/detail; - 恢复完整返回栈:不仅知道当前是详情页,还知道前面有哪些页面。
单独保存当前 URL 通常只能恢复第一种:
保存:/detail
恢复:[Home, Detail]
如果用户原来的栈是:
[Home, Search, ProductList, Detail]
仅凭 /detail 无法推导出完整历史。要恢复完整栈,需要把栈结构或可重建的导航状态纳入状态恢复设计。
不过也不应机械地持久化所有 Route。返回栈可能包含:
- 已过期的临时表单;
- 不应跨会话恢复的认证页面;
- 依赖短期内存对象的页面;
- 已经失效的资源详情。
恢复策略应该按页面语义决定。路由配置应尽量可序列化、可验证,并且不直接保存无法长期使用的对象引用。
十五、生产环境中的取舍
15.1 什么时候直接使用 Navigator
直接使用 Navigator 通常适用于:
- 小型移动应用;
- 路由主要由按钮和列表点击触发;
- 没有复杂 Web URL;
- 页面栈较浅;
- 不需要多级嵌套导航。
此时重点是正确处理:
push返回结果;context.mounted;- 登录后清栈;
- 嵌套 Navigator;
- 返回拦截。
15.2 什么时候使用 Router 或路由包
当应用需要以下能力时,Router 或基于 Router 的成熟路由包更合适:
- 浏览器地址栏与应用状态双向同步;
- Deep Link;
- 登录重定向;
- 嵌套 Shell;
- 多标签保留独立栈;
- 路径参数和查询参数;
- 统一错误页;
- 路由级鉴权;
- 多平台一致的路由配置。
路由包可以减少样板代码,但不会消除底层问题。仍然需要理解:
外部路由输入 → 配置解析 → 导航状态 → 页面栈 → 返回事件
否则遇到嵌套返回、刷新 404、重定向循环或页面状态丢失时,只能依赖试错。
15.3 路由层与 Feature 层的边界
一个可维护的边界通常是:
Router:
解析 URI
生成路由配置
处理页面栈
处理导航级重定向
Feature:
加载商品、订单、用户数据
管理页面业务状态
处理表单和交互
数据层:
API、数据库、缓存
平台边界:
Android Intent
iOS Universal Link
Web URL
桌面协议注册
例如 Router 只产生:
ProductRouteConfig(id: 42)
而不是在解析 URL 时直接请求数据库。这样同一份 Feature 页面可以由 Deep Link、列表点击和测试代码共同进入,路由来源不会污染业务逻辑。
十六、总结:用一条状态链理解所有导航问题
可以用下面这条链检查设计是否完整:
外部来源
├─ Android Intent
├─ iOS URL / Universal Link
├─ Web 浏览器 URL
└─ 应用内点击
│
▼
统一的 URI / 路由配置
│
▼
应用导航状态
│
▼
Navigator 的 Route 或 Page 栈
│
▼
当前页面与返回行为
│
└── 返回、前进、Deep Link 再次修改导航状态
其中:
Navigator解决 Route 栈的运行时变化;Router解决路由状态与外部路由信息的同步;Deep Link是进入应用导航状态的一种外部输入;- 返回栈决定
pop的顺序,并受到嵌套 Navigator、平台返回事件和页面生命周期的共同影响。
最容易出错的地方,通常不是 push 语法本身,而是状态没有保持一致:URL 是一个页面,Router 状态是另一个页面,Navigator 栈又是第三个页面。无论采用命令式还是声明式实现,都应明确谁是当前路由状态的来源,并让页面栈、外部 URL 和返回行为围绕这个状态保持可验证的一致性。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 状态与生命周期:StatefulWidget、BuildContext、Key 和更新边界
- 下一篇:Flutter 表单与输入:Controller、Focus、校验、键盘和无障碍
- 延伸:Flutter 大型应用架构:分层、Feature、依赖注入和多端边界
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论