Flutter 基础体系 · 第 48/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter go_router:声明式路由、重定向、Shell、Deep Link 和恢复
go_router 是 Flutter 官方生态中常用的声明式路由包。它把“当前应用状态应该显示什么页面”表示为路由树,并负责把外部地址、浏览器历史、系统返回、重定向和嵌套路由连接起来。
本文示例基于 Dart 3 和当前稳定版 Flutter 的常见 go_router API。go_router 是独立的 Pub package,不属于 Flutter SDK 内置库,因此项目需要显式添加依赖:
flutter pub add go_router
随后执行:
flutter pub get
示例中的具体包版本由项目的 pubspec.yaml 和 pub get 解析结果决定。不同 go_router 大版本可能新增或弃用少量参数,遇到 API 差异时应以当前包的 API 文档和 IDE 类型提示为准。
一、先建立路由问题的模型
1. 路由不是“打开一个页面”
命令式导航通常写成:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const DetailPage(),
),
);
这段代码表达的是一个动作:把 DetailPage 压入某个 Navigator 的栈。
声明式路由表达的是一个状态关系:
当前 URI + 当前应用状态
↓
路由匹配与重定向
↓
页面树和 Navigator 树
例如:
/login → 登录页
/home → 首页
/orders/42 → 订单详情页,订单 ID 为 42
在声明式模型中,页面不是由某次 push 永久决定的,而是由当前路由状态重新计算出来。用户登录状态、权限、外部 Deep Link、浏览器地址栏和系统恢复都可以改变这个状态。
可以把路由结果抽象为:
其中:
- 是当前 URI,例如
/orders/42?tab=items; - 是应用状态,例如是否登录、当前用户角色;
- 是路由匹配和重定向过程;
- 是最终的页面与 Navigator 结构。
重定向并不是简单地“跳转一次”。它通常是一个迭代过程:
直到某个 URI 不再触发重定向,才进行最终路由匹配。
因此,下面两个概念必须区分:
- 路由匹配:URI 是否符合某个
GoRoute的路径模式; - 重定向:根据当前状态,把 URI 改写成另一个 URI。
二、一个可运行的最小完整示例
下面的示例包含:
/login登录页;/home和/settings两个底部导航分支;/orders/:orderId动态参数;- 登录状态重定向;
ShellRoute;- 错误页;
- 外部 URI 进入应用后的处理。
为了让示例可直接运行,认证状态使用内存中的 ChangeNotifier 模拟。真实项目应把它替换成应用级认证服务。
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() {
final auth = AuthState();
final router = GoRouter(
debugLogDiagnostics: true,
initialLocation: '/home',
refreshListenable: auth,
redirect: (context, state) {
final isLoggedIn = auth.isLoggedIn;
final isLoggingIn = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoggingIn) {
final from = state.uri.toString();
return Uri(
path: '/login',
queryParameters: {'from': from},
).toString();
}
if (isLoggedIn && isLoggingIn) {
final from = state.uri.queryParameters['from'];
return from == null || from.isEmpty ? '/home' : from;
}
return null;
},
errorBuilder: (context, state) {
return ErrorPage(error: state.error);
},
routes: [
GoRoute(
path: '/login',
builder: (context, state) {
return LoginPage(auth: auth);
},
),
ShellRoute(
builder: (context, state, child) {
return AppShell(child: child);
},
routes: [
GoRoute(
path: '/home',
builder: (context, state) {
return const HomePage();
},
routes: [
GoRoute(
path: 'orders/:orderId',
builder: (context, state) {
final orderId = state.pathParameters['orderId']!;
final tab = state.uri.queryParameters['tab'] ?? 'summary';
return OrderDetailPage(
orderId: orderId,
tab: tab,
);
},
),
],
),
GoRoute(
path: '/settings',
builder: (context, state) {
return const SettingsPage();
},
),
],
),
],
);
runApp(MyApp(router: router));
}
class MyApp extends StatelessWidget {
const MyApp({required this.router, super.key});
final GoRouter router;
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'go_router Demo',
routerConfig: router,
);
}
}
class AuthState extends ChangeNotifier {
bool _isLoggedIn = false;
bool get isLoggedIn => _isLoggedIn;
void login() {
_isLoggedIn = true;
notifyListeners();
}
void logout() {
_isLoggedIn = false;
notifyListeners();
}
}
class AppShell extends StatelessWidget {
const AppShell({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
final selectedIndex = location.startsWith('/settings') ? 1 : 0;
return Scaffold(
appBar: AppBar(
title: const Text('订单应用'),
actions: [
IconButton(
onPressed: () {
context.read<AuthState>();
},
icon: const Icon(Icons.info_outline),
),
],
),
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
switch (index) {
case 0:
context.go('/home');
case 1:
context.go('/settings');
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: '设置',
),
],
),
);
}
}
class LoginPage extends StatelessWidget {
const LoginPage({required this.auth, super.key});
final AuthState auth;
@override
Widget build(BuildContext context) {
final from = GoRouterState.of(context).uri.queryParameters['from'];
return Scaffold(
appBar: AppBar(title: const Text('登录')),
body: Center(
child: ElevatedButton(
onPressed: () {
auth.login();
// notifyListeners 会触发 GoRouter 重新执行 redirect。
// 这里不必手动 push('/home'),重定向会使用 from 参数恢复原目标。
},
child: Text(from == null ? '登录并进入首页' : '登录并继续'),
),
),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'首页',
style: TextStyle(fontSize: 24),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.go('/home/orders/42?tab=items');
},
child: const Text('打开订单 42 的明细标签'),
),
],
);
}
}
class OrderDetailPage extends StatelessWidget {
const OrderDetailPage({
required this.orderId,
required this.tab,
super.key,
});
final String orderId;
final String tab;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('订单 $orderId')),
body: Center(
child: Text('订单:$orderId\n当前标签:$tab'),
),
);
}
}
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: Text('设置'),
);
}
}
class ErrorPage extends StatelessWidget {
const ErrorPage({required this.error, super.key});
final Exception? error;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('页面不存在')),
body: Center(
child: Text(
error?.toString() ?? '找不到对应页面',
textAlign: TextAlign.center,
),
),
);
}
}
上面的代码中有一处需要注意:AppShell 中的 context.read<AuthState>() 依赖 Provider 的扩展,而示例没有引入 Provider。为了保持代码可以直接编译,应删除这个 IconButton,或者将其替换为不依赖 Provider 的按钮。完整可运行版本应把 actions 改成:
actions: [
IconButton(
onPressed: () => context.go('/settings'),
icon: const Icon(Icons.info_outline),
),
],
这里的关键路径是:
- 应用初始位置是
/home; auth.isLoggedIn为false;- 全局
redirect把/home改写为/login?from=%2Fhome; - 登录按钮调用
auth.login(); AuthState发出通知;GoRouter因refreshListenable重新执行redirect;- 已登录且当前是
/login,于是读取from,返回/home; - 最终显示
ShellRoute包裹的首页。
三、声明式路由树如何匹配 URI
1. 静态路径、动态参数和查询参数
下面三种写法表达的是不同数据来源:
GoRoute(
path: '/orders',
builder: ...,
)
匹配固定路径 /orders。
GoRoute(
path: '/orders/:orderId',
builder: (context, state) {
final orderId = state.pathParameters['orderId']!;
...
},
)
匹配 /orders/42、/orders/abc 等路径。orderId 是路径参数,不应从查询参数中读取。
/orders/42?tab=items
其中:
42来自state.pathParameters['orderId'];items来自state.uri.queryParameters['tab']。
对应代码:
final orderId = state.pathParameters['orderId'];
final tab = state.uri.queryParameters['tab'];
路径参数通常标识资源,例如订单 ID、用户 ID;查询参数通常描述筛选、排序、分页或当前视图,例如 ?tab=items。
不要把所有内容都塞进查询参数。对于 /orders/42,路径本身表达“订单 42 是资源”;对于 /orders?status=paid,查询参数表达“订单列表的筛选条件”。
2. 子路由不是字符串拼接,而是嵌套路由匹配
示例中:
GoRoute(
path: '/home',
routes: [
GoRoute(
path: 'orders/:orderId',
builder: ...,
),
],
)
父路由使用绝对路径 /home,子路由写成相对路径 orders/:orderId,最终组合为:
/home/orders/:orderId
子路由不能再写成 /home/orders/:orderId 作为同一个父节点下的相对子路径,否则会破坏路由树的语义。
go_router 会根据 URI 生成匹配链:
/home/orders/42
├── /home
└── /home/orders/:orderId
这条匹配链随后决定要构建哪些页面、哪些 Shell 需要保留,以及哪个 Navigator 承载页面。
3. go、push、pop 的语义不同
context.go('/settings');
go 表示把当前路由状态切换到目标 URI。它更接近“当前应用应该位于哪个地址”,而不是“在栈顶压入一个页面”。
context.push('/home/orders/42');
push 表示在当前导航上下文中增加一个新的页面历史项。它适合详情页、编辑页等需要返回到当前页面的场景。
context.pop();
返回上一个可返回的页面。
一个常见错误是把所有导航都写成 push。这样可能导致用户反复点击底部导航后,返回键需要经过许多重复页面。另一个错误是把所有导航都写成 go,从而丢失需要保留的页面栈。
四、重定向:从认证状态到最终 URI
1. 重定向函数的契约
全局重定向通常写在 GoRouter 上:
final router = GoRouter(
redirect: (context, state) {
if (...) {
return '/login';
}
return null;
},
);
返回值含义:
- 返回
null:不重定向,继续进行当前 URI 的匹配; - 返回一个 URI 字符串:使用该 URI 重新开始路由处理。
局部重定向也可以写在 GoRoute 上:
GoRoute(
path: '/legacy',
redirect: (context, state) => '/home',
)
局部重定向适合旧路径迁移、某个功能入口的局部规则;认证和全局权限通常放在顶层,避免不同分支重复实现。
2. 为什么 refreshListenable 是必要的
以下代码只在路由初始化或地址变化时检查一次登录状态:
final router = GoRouter(
redirect: (context, state) {
return auth.isLoggedIn ? null : '/login';
},
);
如果登录按钮后来修改了 auth.isLoggedIn,而路由没有收到变化通知,重定向函数不会自动重新执行。
因此需要:
final router = GoRouter(
refreshListenable: auth,
redirect: (context, state) {
...
},
);
AuthState 必须在状态变化时调用 notifyListeners():
class AuthState extends ChangeNotifier {
bool _isLoggedIn = false;
bool get isLoggedIn => _isLoggedIn;
void login() {
_isLoggedIn = true;
notifyListeners();
}
}
数据流可以表示为:
flowchart LR
A[认证状态变化] --> B[ChangeNotifier.notifyListeners]
B --> C[GoRouter refreshListenable]
C --> D[重新执行 redirect]
D --> E{是否产生新 URI}
E -- 是 --> F[重新匹配新 URI]
E -- 否 --> G[构建当前路由页面]
如果使用 Riverpod、Bloc 或其他状态管理库,核心要求不变:认证状态变化必须能够触发路由刷新。具体接法可以是把状态适配为 Listenable,或者在状态变化时调用路由刷新机制;不要假设普通业务状态的变化会自动被 go_router 观察。
3. 登录后恢复原始目标
用户直接打开:
https://example.com/home/orders/42?tab=items
但此时未登录。简单重定向到 /login 会丢失原始目标。示例把目标放进查询参数:
final from = state.uri.toString();
return Uri(
path: '/login',
queryParameters: {'from': from},
).toString();
登录完成后:
final from = state.uri.queryParameters['from'];
return from == null || from.isEmpty ? '/home' : from;
这里必须使用 Uri 构造查询参数,而不是手工拼接:
// 不推荐
return '/login?from=$from';
因为 from 可能包含 ?、&、# 或非 ASCII 字符。手工拼接可能改变 URI 的结构。Uri(queryParameters: ...) 会正确编码。
4. 重定向必须具有终止条件
一个错误的重定向:
redirect: (context, state) {
if (!auth.isLoggedIn) {
return '/login';
}
return '/home';
}
当已经位于 /login 且未登录时,仍然返回 /login。这会形成自重定向。
更严重的情况是:
redirect: (context, state) {
if (state.matchedLocation == '/a') {
return '/b';
}
return '/a';
}
这会在 /a 和 /b 之间循环。
go_router 会限制连续重定向次数,超过限制后显示路由错误,而不是无限递归。这个保护机制只能防止程序失控,不能替代正确的重定向逻辑。
对认证重定向,至少要满足:
未登录且不在登录页 → 登录页
未登录且已经在登录页 → 不再重定向
已登录且在登录页 → 目标页
已登录且不在登录页 → 不重定向
这四种状态正是示例中的两个条件共同实现的结果。
5. 异步认证状态的边界
认证状态常常需要异步读取,例如:
- 从安全存储读取 Token;
- 向服务端验证会话;
- 加载租户和用户权限。
redirect 支持异步返回值,但不应在每次路由评估时重复发起不可控的网络请求。更稳定的状态模型是:
unknown → authenticated
unknown → unauthenticated
启动时先显示启动页或保持初始化状态,认证检查完成后通知路由刷新。否则可能出现:
- 启动时暂时认为未登录;
- 立即重定向到
/login; - Token 恢复完成后又重定向回原页面;
- 用户看到闪烁,甚至产生历史记录混乱。
认证失败、Token 过期和网络不可用也应区分:
- Token 明确失效:进入登录流程;
- 网络暂时不可用:可能保留当前页面并显示离线状态;
- 权限不足:进入 403 页面,而不是统一跳登录页。
五、Shell:共享外壳与嵌套 Navigator
1. ShellRoute 解决什么问题
ShellRoute 的作用是为一组子路由提供共同的页面外壳,例如:
- 底部导航栏;
- 侧边栏;
- 公共 AppBar;
- 登录后统一的布局;
- 桌面端的导航 Rail。
示例:
ShellRoute(
builder: (context, state, child) {
return AppShell(child: child);
},
routes: [
GoRoute(path: '/home', builder: ...),
GoRoute(path: '/settings', builder: ...),
],
)
这里 child 是当前匹配的子路由页面。进入 /home 时,child 是首页;进入 /settings 时,child 是设置页。
Shell 本身不是业务页面,而是路由树中的布局节点。
2. ShellRoute 与 Navigator 的关系
普通 GoRoute 默认使用根 Navigator。ShellRoute 会为其子路由提供一个 Shell Navigator,子页面通常显示在这个 Navigator 中。
这会影响页面的显示层级。例如,在底部导航 Shell 中打开订单详情:
Root Navigator
└── Shell Navigator
├── AppShell
└── OrderDetailPage
如果订单详情应该覆盖底部导航栏,常见做法是把它放到根 Navigator:
final rootNavigatorKey = GlobalKey<NavigatorState>();
final router = GoRouter(
navigatorKey: rootNavigatorKey,
routes: [
ShellRoute(
routes: [
GoRoute(
path: '/home',
builder: ...,
routes: [
GoRoute(
path: 'orders/:orderId',
parentNavigatorKey: rootNavigatorKey,
builder: ...,
),
],
),
],
),
],
);
此时结构近似为:
Root Navigator
├── AppShell
└── OrderDetailPage
OrderDetailPage 会覆盖 Shell,因此底部导航可以不显示。
parentNavigatorKey 必须指向实际存在的祖先 Navigator key。如果 key 层级不匹配,可能在运行时出现路由配置错误或页面显示位置不符合预期。
3. ShellRoute 不等于“每个 Tab 都保留自己的栈”
普通 ShellRoute 适合共享外壳,但底部导航的每个分支是否保留独立页面栈,需要进一步区分。
例如用户经历:
首页 → 订单详情 → 设置 → 回到首页
如果希望回到首页时仍停留在订单详情,就需要每个 Tab 拥有独立 Navigator。此时使用 StatefulShellRoute:
final router = GoRouter(
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return StatefulAppShell(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomePage(),
routes: [
GoRoute(
path: 'orders/:orderId',
builder: (context, state) {
return OrderDetailPage(
orderId: state.pathParameters['orderId']!,
tab: state.uri.queryParameters['tab'] ?? 'summary',
);
},
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
],
),
],
),
],
);
外壳通过 StatefulNavigationShell 切换分支:
class StatefulAppShell extends StatelessWidget {
const StatefulAppShell({
required this.navigationShell,
super.key,
});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
label: '设置',
),
],
),
);
}
}
这里有两个层次:
ShellRoute:共享一个外壳和一个子导航上下文;StatefulShellRoute:为多个分支维护独立的导航状态。
StatefulShellRoute.indexedStack 常用 IndexedStack 展示分支,但“保留页面栈”并不意味着所有页面都一直执行构建、网络请求或动画。页面是否保持资源、是否暂停业务任务,仍取决于页面本身和生命周期设计。
六、Deep Link:外部 URI 如何进入路由树
1. Deep Link 的完整链路
Deep Link 是从应用外部通过 URI 直接打开应用内资源的能力。例如:
https://example.com/home/orders/42?tab=items
完整链路不是只有 go_router:
sequenceDiagram
participant E as 外部来源
participant OS as Android/iOS
participant F as Flutter Engine
participant R as Router
participant G as go_router
participant P as 页面
E->>OS: 点击 HTTPS 或自定义 Scheme
OS->>F: 启动或唤醒应用并传递 URI
F->>R: 提供初始路由信息
R->>G: 交给 GoRouterDelegate/RouteInformationParser
G->>G: 匹配路径、执行 redirect
G->>P: 构建最终页面
如果应用内路由配置正确,但操作系统没有把链接交给应用,Deep Link 仍然不会工作。
2. Android 的差异
Android 常见两种方式:
自定义 Scheme
例如:
myapp://orders/42
优点是配置简单;缺点是其他应用可能注册同一个 Scheme,不能证明链接一定由你的应用处理。
Android App Links
例如:
https://example.com/orders/42
通常需要:
- Manifest 中配置
intent-filter; - 域名提供
assetlinks.json; - 包名和签名证书指纹正确;
- 路径匹配规则与服务端配置一致。
Manifest 的简化示意:
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="example.com" />
</intent-filter>
这不是完整生产配置。assetlinks.json 中的包名、SHA-256 证书指纹和发布渠道必须对应实际安装包。调试包、正式包和不同 flavor 可能需要不同配置。
3. iOS 的差异
iOS 主要使用:
- 自定义 URL Scheme;
- Universal Links。
Universal Links 通常需要:
- Xcode 中启用 Associated Domains;
- 配置类似
applinks:example.com的域名; - 网站提供
apple-app-site-association文件; - Team ID、Bundle ID 和路径配置正确。
自定义 Scheme 的冲突风险与 Android 类似。Universal Links 的优势是同一 HTTPS 地址可以在未安装应用时继续落到网页,在已安装并验证成功时交给应用。
4. Web 的差异
Web 中 Deep Link 通常就是浏览器地址栏 URL。例如用户直接访问:
https://example.com/home/orders/42
Flutter Web 应用需要:
go_router中存在匹配路径;- Web Server 将未知路径回退到
index.html; - 服务器或 CDN 不要直接返回 404。
否则浏览器刷新时,请求会先到服务器,而不是先到 Flutter 路由。服务器若找不到物理文件 /home/orders/42,应用根本没有机会运行。
Web 还涉及 URL 策略:
- 使用 path URL 时地址清晰,但需要服务器 fallback;
- 使用 hash URL 时通常不需要服务器对任意路径做 fallback,但 URL 会包含
#。
浏览器的前进、后退和刷新都依赖 URL 与路由状态同步。不要只在内存中保存页面位置,然后期望刷新后自动恢复。
5. 桌面端的差异
桌面端通常没有 Android Intent 或 iOS Universal Links 的统一系统入口。Deep Link 可能来自:
- 命令行参数;
- 自定义协议注册;
- 文件关联;
- 单实例应用的进程间通信。
因此桌面应用除了路由匹配,还要处理“已有进程收到新 URI”的问题:第二次启动可能需要把 URI 发送给已经运行的第一个实例,而不是创建第二个窗口。这个行为不由 go_router 单独保证,通常需要平台插件或原生代码配合。
七、路由错误、404 与数据加载失败
1. 未匹配路径不等于业务加载失败
下面两种错误需要分开:
/path-does-not-exist
这是路由匹配失败,应该显示 404 或错误页。
/orders/42
路径匹配成功,但请求订单 42 时服务端返回 404,这是业务数据加载失败。
前者由 errorBuilder 或 errorPageBuilder 处理:
final router = GoRouter(
errorBuilder: (context, state) {
return ErrorPage(error: state.error);
},
);
后者应由订单详情页面中的数据层处理,不能把所有异常都误判为路由不存在。
2. builder 与 pageBuilder
builder 返回页面内容:
GoRoute(
path: '/home',
builder: (context, state) => const HomePage(),
)
pageBuilder 返回完整的 Page,用于控制:
- 页面 Key;
- 页面恢复 ID;
- 转场动画;
- 全屏对话框;
- 特定平台页面类型。
例如自定义淡入效果:
GoRoute(
path: '/fade',
pageBuilder: (context, state) {
return CustomTransitionPage<void>(
key: state.pageKey,
child: const HomePage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
);
},
)
页面 Key 很重要。路由从 /orders/42 变为 /orders/43 时,框架需要知道这是同一页面类型但不同路由状态,还是应该复用已有 State。state.pageKey 是 go_router 为当前匹配项提供的常用 key 来源。
3. 路由级异常处理的边界
errorBuilder 适合:
- 路由不存在;
- 路由配置或匹配阶段异常;
- 页面构建过程中被路由系统捕获的错误。
生产环境还应记录:
- 原始 URI;
- 重定向前后的 URI;
- 平台;
- 当前认证状态;
- 异常堆栈。
开发阶段可以启用:
debugLogDiagnostics: true,
它会输出路由匹配和导航诊断日志,有助于确认:
- 实际匹配了哪条路由;
- 重定向执行了几次;
- 当前页面属于哪个 Shell;
go或push后的路由状态是什么。
不要在日志中记录完整 Token、密码或包含敏感信息的查询参数。
八、恢复:重新创建页面与恢复页面状态不是一回事
标题中的“恢复”至少包含三个不同概念。
1. 路由恢复
路由恢复指应用重新启动或重新创建后,能够恢复到某个 URI,例如:
/home/orders/42?tab=items
在 Web 中,地址栏本身就是最主要的恢复载体。刷新页面后,浏览器再次把该 URL 提供给 Flutter。
在移动端,系统可能在应用进程被杀死后保存部分导航状态,但恢复行为取决于平台、引擎、应用配置和页面是否支持状态恢复。不能把“最近一次页面位置”当成所有平台都必然存在的持久化数据。
2. Navigator 状态恢复
go_router 提供与 Flutter Restoration 体系集成的配置入口,例如:
final router = GoRouter(
restorationScopeId: 'app-router',
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomePage(),
),
],
);
应用入口仍然使用:
MaterialApp.router(
routerConfig: router,
)
restorationScopeId 的作用是让路由使用 Flutter 的状态恢复作用域。它不是普通字符串参数,也不是把任意业务对象自动写入磁盘。
3. 页面内部状态恢复
路由恢复只能恢复“在哪个路由”。例如它可能恢复到:
/orders/42
但不会自动知道:
- 列表滚动到了第几项;
- 文本框输入了什么;
- 当前展开了哪个 ExpansionTile;
- 网络请求是否已经完成;
- 内存中的订单对象是什么。
这些状态需要使用 Flutter Restoration API,例如 RestorableTextEditingController、RestorableBool 或 RestorableScrollController 等适合的可恢复对象,并且页面需要位于正确的 Restoration Scope 中。
示例:
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage>
with RestorationMixin {
final RestorableTextEditingController query =
RestorableTextEditingController();
@override
String? get restorationId => 'search-page';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(query, 'query');
}
@override
void dispose() {
query.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: TextField(
controller: query.value,
),
);
}
}
这里的层次关系是:
GoRouter restorationScopeId
↓
Navigator / 路由状态恢复
↓
页面 restorationId
↓
RestorableProperty 恢复具体控件状态
缺少最后一层时,恢复的可能只是页面位置,而不是页面内部输入。
4. 恢复与持久化业务数据无关
恢复机制不适合替代:
- 登录 Token 持久化;
- 购物车数据库;
- 草稿存储;
- 离线缓存;
- 服务端会话。
例如应用恢复到 /orders/42,但订单已被删除,页面仍必须重新请求数据并处理 404。恢复的是导航意图,不是对业务数据当前有效性的保证。
5. 恢复失败的典型路径
以下情况都可能导致恢复结果不同:
- 恢复的 URI 已经不再被路由树匹配;
- 用户权限变化,原页面现在必须重定向;
- 目标资源被删除;
- 应用版本升级后路由路径发生变化;
- 页面使用了不可恢复的临时状态;
- Web Server 没有配置 fallback;
- 移动端系统根本没有保留应用进程或恢复数据。
因此,恢复流程仍应经过正常的路由匹配和重定向,而不是无条件相信旧页面位置:
恢复旧 URI
↓
检查当前版本路由是否支持
↓
检查当前认证与权限
↓
加载最新业务数据
↓
成功显示,失败显示可解释的错误或迁移页面
如果旧路径需要迁移,可以配置局部重定向:
GoRoute(
path: '/old-order/:id',
redirect: (context, state) {
final id = state.pathParameters['id']!;
return '/home/orders/$id';
},
)
九、页面生命周期与状态边界
1. 路由变化会导致哪些对象变化
当 URI 从:
/home/orders/42
变为:
/home/orders/43
通常会发生:
- 当前 URI 改变;
go_router重新匹配;state.pathParameters['orderId']从42变为43;- 页面 Key 或页面配置可能变化;
- Flutter 根据 Widget、Element 和 State 的身份规则决定复用还是重建;
- 页面数据层必须响应订单 ID 变化。
不要只在 initState 中加载一次订单,然后假设同一个页面 State 永远只对应一个 ID。更稳妥的方式是让页面根据 ID 建立可更新的数据输入,或在 didUpdateWidget 中处理参数变化。
2. 参数应尽量保持可序列化
Deep Link、浏览器历史和恢复都依赖 URI。适合放进路径或查询参数的是:
- 字符串;
- 数字;
- 枚举的稳定字符串表示;
- 日期的标准化字符串;
- 可编码的筛选条件。
不适合直接塞进 URI 的是:
- 大型对象;
- 闭包;
- BuildContext;
- 数据库连接;
- 临时 UI Controller。
如果需要传复杂对象,可以传资源 ID,在目标页面重新加载对象:
/orders/42
而不是试图把整个订单对象编码进 URL。这样 Deep Link、刷新和恢复都更可靠。
十、常见误解与失败表现
误解一:go_router 自动完成所有平台 Deep Link 配置
事实是:
go_router 路由树
只负责 Flutter 内部的 URI 解析和页面构建。Android Intent、iOS Universal Links、Web Server fallback、桌面协议注册仍需平台配置。
失败表现包括:
- Android 点击链接只打开浏览器;
- iOS 已安装应用却没有唤起;
- Web 首次进入可以,刷新后 404;
- 桌面端第二次打开链接启动了第二个进程。
诊断时应先确认操作系统是否把正确 URI 交给应用,再确认 debugLogDiagnostics 中的匹配结果。
误解二:调用 context.go 后还必须手动 push
context.go 已经改变了声明式路由状态。若随后再手动 push,可能得到重复页面或错误历史。
例如:
context.go('/home');
context.push('/home');
这会让导航历史与期望的当前 URI 不一致。应先明确自己要的是“切换到目标状态”,还是“在当前状态上追加一个页面”。
误解三:修改认证对象就会自动触发重定向
只有当对象被注册为 refreshListenable,并且确实发出通知时,路由才会重新评估。
以下代码不会触发路由刷新:
auth._isLoggedIn = true;
即使这段代码因访问权限无法编译,问题本质仍是:业务状态变化和路由刷新之间必须建立明确连接。
误解四:Shell 会自动保存所有 Tab 的导航栈
普通 ShellRoute 主要提供共享外壳,不自动等价于每个 Tab 一个独立的持久栈。需要分支导航状态时,应使用 StatefulShellRoute 并明确设计分支行为。
误解五:恢复到某个 URI 就等于恢复到原来的页面
恢复 URI 后,页面仍可能:
- 需要重新请求数据;
- 因权限变化被重定向;
- 因资源不存在显示错误;
- 丢失未注册的控件状态。
恢复是重新建立应用状态的过程,不是把旧内存快照无条件复制回来。
十一、可测试的路由设计
路由规则最好能在不启动完整 UI 的情况下验证。至少应覆盖以下输入:
| 输入状态 | URI | 预期结果 |
|---|---|---|
| 未登录 | /home |
/login?from=/home |
| 未登录 | /login |
保持 /login |
| 已登录 | /login?from=/home/orders/42 |
/home/orders/42 |
| 已登录 | /home/orders/42?tab=items |
匹配订单详情,参数为 42 和 items |
| 任意状态 | /unknown |
错误页或 404 |
| 已登录 | /legacy/42 |
迁移到新路径 |
对于重定向,尤其要测试固定点和循环:
redirect(redirect(U)) = redirect(U)
这里的直觉是:当 URI 已经达到稳定状态时,再次评估不应继续改变它。
还应测试:
- 用户在登录页点击返回;
- Token 过期时当前页面如何处理;
- 外部 Deep Link 在未登录状态下是否保留完整目标;
- 浏览器刷新动态路径是否可用;
- Android 和 iOS 冷启动、热启动、已有进程收到链接的区别;
- Shell 子页面是否应该覆盖底部导航;
- Tab 切换后是否保留各自栈。
十二、选择路由结构时的取舍
可以按页面关系选择结构:
单层页面切换
→ GoRoute
一组页面共享 AppShell
→ ShellRoute
多个 Tab 各自保留独立导航栈
→ StatefulShellRoute
详情页需要覆盖 Shell
→ parentNavigatorKey 指向根 Navigator
认证、权限、旧路径迁移
→ redirect
页面内部滚动位置、输入内容恢复
→ Flutter Restoration API
这些能力可以组合,但不能互相替代:
redirect不能代替权限页面;ShellRoute不能代替状态管理;restorationScopeId不能代替数据库持久化;go_router不能代替 Android、iOS 和 Web 平台链接配置;- Deep Link 不能保证目标业务资源永远存在。
一个稳定的路由系统,最终应保持以下因果链清晰:
外部 URI 或用户操作
↓
GoRouter 当前 URI
↓
全局与局部 redirect
↓
路由树匹配
↓
Shell / Navigator 层级
↓
页面构建与数据加载
↓
平台返回、浏览器历史和状态恢复
当出现“页面打不开”“登录后回不到原页面”“底部导航状态丢失”或“刷新后 404”时,沿着这条链逐层检查,通常比直接修改某个 push 调用更容易定位根因。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter Hero 与页面转场:匹配、飞行、路由和视觉连续性
- 下一篇:Flutter Deep Link 与 Universal Link:配置、解析、登录和安全
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论