# 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 间通信" 规则在路由层的具体落地。 ## 参考链接 - [go_router 官方文档](https://pub.dev/packages/go_router) - [go_router | Dart package](https://pub.dev/packages/go_router) ## 附录: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,选错会导致返回键行为不符合预期。 ### 使用示例(底部导航 + 门店详情页 + 登录拦截) ```dart // packages/core_router/lib/src/app_router.dart final goRouterProvider = Provider((ref) { final authState = ref.watch(authStateProvider); return GoRouter( initialLocation: '/store', redirect: (context, state) { final loggingIn = state.matchedLocation == '/login'; if (!authState.isLoggedIn && !loggingIn) return '/login'; if (authState.isLoggedIn && loggingIn) return '/store'; return null; // 不需要重定向 }, routes: [ GoRoute(path: '/login', builder: (context, state) => const LoginPage()), StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) => MainShell(navigationShell: navigationShell), branches: [ StatefulShellBranch(routes: buildStoreRoutes()), StatefulShellBranch(routes: buildPaymentRoutes()), ], ), ], ); }); ``` ```dart // packages/feature_store/lib/feature_store.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` 包内声明,`app_router.dart` 里只 import 这个函数、不 import `feature_store` 的任何页面 widget 类型——保持 [01-project-structure.md](./01-project-structure.md) 的编译期边界。