Files
conti-docs/flutter-app/04-routing.md
T

199 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 04. 路由方案
## 决策
使用 **[go_router](https://pub.dev/packages/go_router)**`^17.5.0`2026-08 快照,Flutter 官方维护),声明式路由 + 嵌套 `ShellRoute`,不使用 `Navigator 1.0` 命令式 push/pop 作为主路由方式。
## 依赖
```yaml
dependencies:
go_router: ^17.5.0
```
## 路由注册规则
- 每个 `feature_*` 包在自己的 `feature_xxx.dart`(对外唯一导出文件)里暴露一个 `List<RouteBase> buildXxxRoutes()` 函数,只声明属于自己的路由,不感知其他 feature。
- `core_router` 包负责把所有 feature 的路由函数聚合成最终的 `GoRouter` 实例,是唯一知道"全部路由长什么样"的地方。
- 路径命名统一用 `kebab-case`,前缀按业务域分组,例如 `/store/:storeId``/payment/confirm`
- 底部导航等常驻 UI 用 `ShellRoute`/`StatefulShellRoute` 包裹对应的 feature 路由,不在每个页面里重复搭一遍导航栏。
- 登录态校验统一在 `core_router` 聚合层用 `redirect` 实现,不在每个页面里各自判断 token 是否过期。
- 跨 feature 跳转只能传**可序列化参数**(path 参数、query 参数,或可序列化的 `extra`),不允许把一个 feature 内部的 Dart 类实例通过 `extra` 传给另一个 feature——这是 [01-project-structure.md](./01-project-structure.md) "Feature 间通信" 规则在路由层的具体落地。
- `feature_*` 不直接依赖 `go_router`,而是依赖 `core_router`,由 `core_router` re-export `GoRoute`/`RouteBase`/`GoRouterState` 等类型。这样将来换路由库或升大版本时,只有 `core_router` 一个地方要动。
## `GoRouter` 实例不能因为登录态变化被重建
这是 go_router + Riverpod 组合里最常见的一个坑,写错了表现是"用户在三级页面停留时 token 刷新了一下,人被弹回首页"。
`GoRouter` 内部持有导航栈。如果 provider 里写 `ref.watch(authStateProvider)`,登录态一变整个 provider 重建、旧 `GoRouter` 被丢弃、新的从 `initialLocation` 开始——导航栈就没了。
**正确写法**`redirect` 里用 `ref.read` 读当前登录态,外面用 `ref.listen` 监听变化并调 `router.refresh()` 让 go_router 重跑一次 `redirect`
```dart
// packages/core_router/lib/src/app_router.dart
final rootNavigatorKey = GlobalKey<NavigatorState>();
final goRouterProvider = Provider<GoRouter>((ref) {
final router = GoRouter(
navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它
initialLocation: '/home',
observers: [NavigationObserver(ref.read(crashReporterProvider))], // 崩溃前的页面路径,见 13
redirect: (context, state) {
// read 不是 watch:这里只要当前值,订阅由下面的 listen 负责
final auth = ref.read(authStateProvider);
final loggingIn = state.matchedLocation == '/login';
if (!auth.isLoggedIn) {
if (loggingIn) return null;
// 带上原目标,登录成功后回跳
return '/login?from=${Uri.encodeComponent(state.uri.toString())}';
}
if (loggingIn) {
final from = state.uri.queryParameters['from'];
return (from == null || from.isEmpty) ? '/home' : Uri.decodeComponent(from);
}
return null;
},
errorBuilder: (context, state) => RouteNotFoundPage(location: state.uri.toString()),
routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => MainShell(navigationShell: navigationShell),
branches: [
StatefulShellBranch(routes: buildHomeRoutes()),
StatefulShellBranch(routes: buildPurchaseRoutes()),
StatefulShellBranch(routes: buildProfileRoutes()),
],
),
],
);
// 登录态变化时只重跑 redirect,不重建 router,导航栈得以保留
ref.listen(authStateProvider, (_, __) => router.refresh());
ref.onDispose(router.dispose);
return router;
});
```
要点:
- `redirect` 里**只能 `ref.read`**,不能 `ref.watch``Provider``create` 已经跑完了,`watch` 在回调里语义也不对)。
- `ref.onDispose(router.dispose)` 不能漏:`GoRouter` 持有 `Listenable`,不 dispose 在热重载和测试里会泄漏。
-`ref.listen` 而不是 `refreshListenable`,是因为登录态本身是一个 Riverpod provider,用 `refreshListenable` 还要额外包一个 `ChangeNotifier` 适配层,没必要。
### `errorBuilder` 是必须的
不写 `errorBuilder`,遇到未注册的路径(深链接拼错、后端下发了一个 App 还不认识的菜单 code、H5 回跳的 URL 有问题)go_router 会显示一个英文的默认错误页,对门店一线员工来说等于崩溃。统一给一个"页面不存在,请检查是否需要升级 App"的兜底页,并把 `state.uri` 上报(见 [13-observability-analytics.md](./13-observability-analytics.md))——这个上报很有价值,能直接暴露出后端下发了 App 不支持的菜单。
## 后端动态菜单 → 本地路由的映射
PRD 第 4.2.5 节(导航收敛与角色化配置):工作台菜单由后端按角色权限下发,不是写死在 App 里的。但**路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发)。所以中间需要一张映射表。
约定:后端下发的每个菜单项带一个稳定的 `code`(如 `PURCHASE_ORDER``INVENTORY_CHECK`),`core_router` 里维护 `code → 路由路径` 的映射。
```dart
// packages/core_router/lib/src/menu_route_map.dart
const menuRouteMap = <String, String>{
'PURCHASE_ORDER': '/purchase/orders',
'INVENTORY_CHECK': '/inventory/check',
'QUOTE_ORDER': '/webview?target=QUOTE_ORDER', // H5 承载的功能也走这张表
// ...
};
/// 未知 code 返回 null,调用方据此决定隐藏还是提示升级
String? resolveMenuRoute(String code) => menuRouteMap[code];
```
**未知 `code` 的兜底策略**:直接**隐藏**该菜单项,同时上报一条 `menu_code_unsupported` 事件(带 code 和 App 版本)。
- 不选"提示升级":老版本 App 上会因为后端加了新菜单就弹升级提示,对完全不需要这个新功能的门店是骚扰。
- 隐藏 + 上报的组合能让我们从数据上看到"有多少用户因为版本旧看不到新功能",需要推升级时再针对性推。
`code` 一旦定义就不能改含义(改了等于老版本 App 跳错页面),新增功能只能加新 `code`。这条要在后端接口评审时对齐。
## H5 页面的路由约定
PRD 第 7.3 节(F6 集成边界)里的核心功能(报价开单、施工查车、结算收银)走 Embedded H5。这些页面在路由表里的形态统一为:
```
/webview?target=<TARGET_CODE>&title=<可选标题>
```
**只传目标标识,不传裸 URL。** 真实 URL 由 `core_webview``target` 去 App Backend 换票后拿到(见 [10-webview-h5.md](./10-webview-h5.md))。
理由:如果路由里能直接塞 URL,那么任何能构造深链接的地方(推送、H5 内跳转、剪贴板)都能让 App 打开任意网页,是一个明确的安全洞。`target` 是一个白名单枚举,能打开哪些页面完全由后端和 App 共同决定。
即便如此,`core_webview` 拿到后端返回的 URL 后**仍要做一次域名白名单校验**——纵深防御,后端被打穿或配置写错时还有一道。
## 门店切换后的路由重置
PRD REQ-LGN-010:切换门店后所有业务上下文跟着切。导航栈是其中一部分——用户在 A 门店的"采购单详情 `/purchase/orders/123`"页面切到 B 门店,这个订单 ID 在 B 门店可能不存在,或者更糟,存在但是另一张单。
**规则:切换门店成功后,清空导航栈回工作台。**
```dart
// 门店切换成功的回调里
ref.read(goRouterProvider).go('/home'); // go 而不是 push:替换整个栈
```
`StatefulShellRoute` 的各 branch 栈也会跟着重置。这个动作和 provider 失效、缓存清理、H5 会话失效是一组,统一在 `11-store-context-and-session.md` 里编排,不散在各处调用。
## 参考链接
- [go_router 官方文档](https://pub.dev/packages/go_router)
- [go_router: Redirection](https://pub.dev/documentation/go_router/latest/topics/Redirection-topic.html)
- [go_router: Navigationgo vs push](https://pub.dev/documentation/go_router/latest/topics/Navigation-topic.html)
- [StatefulShellRoute API](https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html)
## 附录:go_router 是什么,日常怎么用
给还没接触过声明式路由的同学看的入门说明。
### 要解决的问题
`Navigator 1.0` 的命令式写法(`Navigator.push(context, MaterialPageRoute(...))`)在页面不多的时候很直观,但规模上来后有几个明显问题:
1. **深链接(deep link/ Web URL 支持差**:命令式 push 本质是"从当前页面跳到下一个页面",很难直接根据一个 URL 字符串恢复出正确的页面栈——比如从推送通知直接打开"门店详情页",命令式写法需要手动拼一串 `push` 调用重建整个栈。
2. **没有统一的登录拦截点**:每个需要登录态的页面都要自己在 `initState` 里判断要不要跳转到登录页,逻辑散落在各处。
3. **底部导航这种"多个 tab 各自维护自己的页面栈"的场景很难优雅表达**
**go_router** 是 Flutter 官方团队维护的声明式路由方案:路由表是一份**声明式配置**(一棵 `GoRoute` 树),当前 URL 决定当前应该显示什么页面栈,而不是"一步步 push 出来的"。因为路由是声明式的、和 URL 强绑定,深链接、Web 浏览器前进/后退、登录拦截都能用同一套机制解决。
### 核心概念
1. **`GoRoute`**:一条路由规则,`path` 是路径模板(支持 `:id` 这种参数),`builder`/`pageBuilder` 返回对应页面。
2. **`ShellRoute` / `StatefulShellRoute`**:包一层常驻 UI(比如带底部导航栏的外壳),内部嵌套的子路由切换时,外壳本身不重建;`StatefulShellRoute` 还能让每个 tab 各自保留自己的页面栈(切 tab 不丢失之前的浏览位置)。
3. **`GoRouterState`**:在 `builder` 里能拿到当前路由的 path 参数(`state.pathParameters`)、query 参数(`state.uri.queryParameters`)、`extra` 对象。
4. **`redirect`**:每次路由变化前会先跑一遍 `redirect` 回调,返回非空字符串就强制跳转——这是实现"未登录访问需要登录的页面 → 自动跳登录页"的地方。
5. **`context.go()` / `context.push()`**`go` 是替换当前路由(浏览器前进后退语义),`push` 是在当前栈上叠加一层(可以 `pop` 回去)——日常最容易混淆的两个 API,选错会导致返回键行为不符合预期。
### 使用示例(底部导航 + 门店详情页)
> 完整的 `goRouterProvider`(含登录拦截、回跳、错误兜底)见上文「`GoRouter` 实例不能因为登录态变化被重建」,这里只演示 feature 侧怎么声明自己的路由。
```dart
// packages/feature_store_mgmt/lib/feature_store_mgmt.dart
List<RouteBase> buildStoreRoutes() => [
GoRoute(
path: '/store',
builder: (context, state) => const StoreListPage(),
routes: [
GoRoute(
path: ':storeId', // 完整路径 /store/:storeId
builder: (context, state) {
final storeId = state.pathParameters['storeId']!;
return StoreDetailPage(storeId: storeId);
},
),
],
),
];
```
```dart
// 从任意页面跳转到门店详情
context.push('/store/${store.id}');
```
`buildStoreRoutes()` 只在 `feature_store_mgmt` 包内声明,`app_router.dart` 里只 import 这个函数、不 import 该 feature 的任何页面 widget 类型——保持 [01-project-structure.md](./01-project-structure.md) 的编译期边界。