Files
conti-retail-app/docs/04-routing.md
T
2026-08-17 15:29:55 +08:00

12 KiB
Raw Blame History

04. 路由方案

决策

使用 go_router^17.5.02026-08 快照,Flutter 官方维护),声明式路由 + 嵌套 ShellRoute,不使用 Navigator 1.0 命令式 push/pop 作为主路由方式。

依赖

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 "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

// 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.watchProvidercreate 已经跑完了,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)——这个上报很有价值,能直接暴露出后端下发了 App 不支持的菜单。

后端动态菜单 → 本地路由的映射

PRD §22.2:工作台菜单由后端按角色权限下发,不是写死在 App 里的。但路由表必须是编译期写死的(页面是 Dart 代码,不可能动态下发)。所以中间需要一张映射表。

约定:后端下发的每个菜单项带一个稳定的 code(如 PURCHASE_ORDERINVENTORY_CHECK),core_router 里维护 code → 路由路径 的映射。

// 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 的核心功能(报价开单、施工查车、结算收银)走 Embedded H5。这些页面在路由表里的形态统一为:

/webview?target=<TARGET_CODE>&title=<可选标题>

只传目标标识,不传裸 URL。 真实 URL 由 core_webviewtarget 去 App Backend 换票后拿到(见 10-webview-h5.md)。

理由:如果路由里能直接塞 URL,那么任何能构造深链接的地方(推送、H5 内跳转、剪贴板)都能让 App 打开任意网页,是一个明确的安全洞。target 是一个白名单枚举,能打开哪些页面完全由后端和 App 共同决定。

即便如此,core_webview 拿到后端返回的 URL 后仍要做一次域名白名单校验——纵深防御,后端被打穿或配置写错时还有一道。

门店切换后的路由重置

PRD §11.4:切换门店后所有业务上下文跟着切。导航栈是其中一部分——用户在 A 门店的"采购单详情 /purchase/orders/123"页面切到 B 门店,这个订单 ID 在 B 门店可能不存在,或者更糟,存在但是另一张单。

规则:切换门店成功后,清空导航栈回工作台。

// 门店切换成功的回调里
ref.read(goRouterProvider).go('/home'); // go 而不是 push:替换整个栈

StatefulShellRoute 的各 branch 栈也会跟着重置。这个动作和 provider 失效、缓存清理、H5 会话失效是一组,统一在 11-store-context-and-session.md 里编排,不散在各处调用。

参考链接

附录: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 侧怎么声明自己的路由。

// 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);
        },
      ),
    ],
  ),
];
// 从任意页面跳转到门店详情
context.push('/store/${store.id}');

buildStoreRoutes() 只在 feature_store_mgmt 包内声明,app_router.dart 里只 import 这个函数、不 import 该 feature 的任何页面 widget 类型——保持 01-project-structure.md 的编译期边界。