# 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 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(); final goRouterProvider = Provider((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 = { '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=&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: Navigation(go 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 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) 的编译期边界。