Skip to content

13 · 路由与导航

目标:能注册新路由、三种方式传参、理解鉴权守卫,以及底部 Tab 是怎么搭的。


开篇:前端概念对照

前端(Vue Router / React Router)GoRouter
router.beforeEachredirect 回调
路由表 routes: [...]routes: [GoRoute(...)]
嵌套路由 childrenroutes / ShellRoute
命名路由name: 参数(本项目主要用 path)
router.push('/x')context.push(Routes.x)
router.replace('/x')context.go(Routes.x) / context.replace
router.back()context.pop()
route.params.idstate.pathParameters['id']
route.query.xxxstate.uri.queryParameters['xxx']
router.push({ name, state })context.push(Routes.x, extra: obj)
布局路由 + <router-view>ShellRoute / StatefulShellRoute
路由懒加载builder 天然懒执行

一、路由文件结构

lib/routing/
├── routes.dart                  路径常量(唯一真源)
├── router.dart                  GoRouter 配置(路由表 + 守卫)
├── auth_refresh_listenable.dart 鉴权状态 → 路由刷新信号
└── tab_configuration.dart       底部 Tab 配置与可见性

二、routes.dart:路径常量

dart
/// 路由路径常量
abstract class Routes {
  Routes._();

  // ─── HaierEnergy 产品路由 ──────────────────────
  static const String login = '/login';
  static const String home = '/home';
  static const String device = '/device';

  /// 电站详情(query: fatherId/systemStatus/plantStatus/plantCategory)。
  static const String plantDetail = '/plant-detail';

  /// 电站基本信息页(extra: {plantId})。
  static const String plantBaseInfo = '/plant-base-info';

  /// 电站移交页(extra: {plantId, systemStatus, plantStatus, plantName})。
  static const String plantHandover = '/plant-handover';
}

三条约定

  1. abstract class + Routes._() —— 禁止实例化
  2. 注释写清传参方式 —— 这是本项目惯例,非常有用:
dart
/// 电站详情(query: fatherId/systemStatus/plantStatus/plantCategory;phase 电站详情)。
static const String plantDetail = '/plant-detail';

看到注释就知道这个页面需要什么参数。

  1. 常量名用 camelCase,路径用 kebab-case

带参数的便捷构造(可选)

有些项目会这么写:

dart
static String detailOf(int id) => '/detail/$id';

本项目多数情况直接用 extra 传参,所以较少见。


三、router.dart:路由表

顶层配置

lib/routing/router.dart:126-140

dart
refreshListenable: refreshListenable,
redirect: (context, state) {
  // HaierEnergy 单一守卫(1:1 对齐 uni-app index.vue loginFlag watch reLaunch
  // + App.vue onLaunch):authenticated 在 login -> 首个可见 tab;未
  // authenticated 在 shell tab -> login。
  ...
},
routes: [ ... ],

路由条目(基本形态)

dart
GoRoute(
  path: Routes.login,
  builder: (context, state) => const LoginPage(),
),

底部 Tab:StatefulShellRoute.indexedStack

lib/routing/router.dart:151-200

dart
StatefulShellRoute.indexedStack(
  builder: (context, state, navigationShell) {
    return MainShell(navigationShell: navigationShell);
  },
  branches: [
    StatefulShellBranch(routes: [
      GoRoute(path: Routes.home, builder: (context, state) => const HomePage()),
    ]),
    StatefulShellBranch(routes: [
      GoRoute(path: Routes.device, builder: (context, state) => Consumer(
        builder: (context, ref, _) => ...   // 按 Role 路由 B/C 端设备页
      )),
    ]),
    StatefulShellBranch(routes: [
      GoRoute(path: Routes.service, builder: ...),
    ]),
    StatefulShellBranch(routes: [
      GoRoute(path: Routes.user, builder: (context, state) => const UserTabPage()),
    ]),
  ],
),

四个 branchhome / device / service / user

indexedStack 的意义:切 Tab 时各分支的状态被保留IndexedStack 只隐藏不销毁)。

前端类比:Vue 的 <keep-alive> + <router-view>

⚠️ 与 HeKeepAliveTab(页面内 Tab)是两个不同层级的东西:

  • StatefulShellRoute —— 底部导航的 4 个主 Tab
  • HeKeepAliveTab —— 某个页面内部的 Tab(如电站详情的概览/设备/资料)

四、鉴权守卫

机制三件套

AuthViewModel(鉴权状态,Riverpod)
   ↓ 桥接
AuthRefreshListenable(ChangeNotifier)
   ↓ 触发
GoRouter.redirect(重新执行)

AuthRefreshListenable

lib/routing/auth_refresh_listenable.dart:10-18

dart
class AuthRefreshListenable extends ChangeNotifier {
  final Ref _ref;

  AuthRefreshListenable(this._ref) {
    _ref.listen(authViewModelProvider, (_, __) {
      Future.microtask(notifyListeners);
    });
  }
}

作用:把 Riverpod 的状态变更转成 ChangeNotifier 信号,让 GoRouter 知道「该重新跑 redirect 了」。

为什么用 Future.microtask:避免在 Riverpod 通知过程中同步触发重建(会报错)。

注册

lib/config/dependencies.dart:27-36

dart
@riverpod
GoRouter router(Ref ref) {
  final refreshListenable = AuthRefreshListenable(ref);
  return AppRouter.createRouter(
    refreshListenable: refreshListenable,
    isAuthenticated: () => ref.read(authViewModelProvider).isAuthenticated,
    getPermissionService: () => ref.read(permissionServiceProvider),
    getVisibleTabs: () => ref.read(tabAccessProvider),
  );
}

依赖注入方式很巧妙createRouter 接收的是函数而非值,这样每次 redirect 执行时都能拿到最新状态。

守卫逻辑

router.dart:127-139 的注释说明了规则:

authenticated 在 login → 跳首个可见 tab; 未 authenticated 在 shell tab → 跳 login。 login/switchover/register/retrieve/... 白名单页面不重定向。

前端类比:这就是 router.beforeEach 里的 if (!isLogin && to.path !== '/login') next('/login')


五、三种传参方式

extra:复杂对象(本项目最常用

dart
// 传
context.push(Routes.plantDetail, extra: {'plantId': 123, 'plantName': '青岛电站'});

// 收
GoRoute(
  path: Routes.plantDetail,
  builder: (context, state) {
    final extra = (state.extra as Map<String, dynamic>?) ?? const <String, dynamic>{};
    return PlantDetailPage(
      plantId: (extra['plantId'] as num?)?.toInt() ?? 0,
      plantName: extra['plantName'] as String? ?? '',
    );
  },
)

项目实例 —— router.dart:311-322plantDetail)、router.dart:334-355plantHandover)。

⭐ 类型安全写法:Args 类

Map 更好的做法是定义 Args 模型:

dart
// 传
context.push(Routes.familyDetail, extra: FamilyDetailPageArgs(id: 1));

// 收
GoRoute(
  path: Routes.familyDetail,
  builder: (context, state) {
    final args = state.extra as FamilyDetailPageArgs?;
    return args == null
        ? const SizedBox.shrink()      // ⚠️ 参数缺失的兜底
        : FamilyDetailPage(args: args);
  },
)

项目实例 —— router.dart:210-217lib/ui/device_detail/inverterDevice/models/inverter_detail_args.dart

推荐这种写法:编译期类型安全,且强制处理「参数缺失」的情况。

② queryParameters:URL 查询参数

dart
// 路由
static const String plantAdd = '/plant-add';

// 传
context.push('/plant-add?plantId=123');

// 收
GoRoute(
  path: Routes.plantAdd,
  builder: (context, state) {
    // 编辑模式:/plant-add?plantId=N
    final plantIdStr = state.uri.queryParameters['plantId'];
    final plantId = plantIdStr == null ? null : int.tryParse(plantIdStr);
    return PlantAddPage(plantId: plantId);
  },
)

项目实例 —— router.dart:292-301

适用:可选参数、需要深链接/分享的场景(URL 可复制)。

③ pathParameters:路径参数

dart
// 路由
static const String detail = '/detail/:id';

// 传
context.push('/detail/123');

// 收
GoRoute(
  path: Routes.detail,
  builder: (context, state) {
    final id = int.parse(state.pathParameters['id']!);
    return DetailPage(id: id);
  },
)

⚠️ 本项目很少使用 —— 因为多数参数用 extra 传复杂对象。

选型建议

场景
传复杂对象 / DTOextra(推荐用 Args 类)
可选参数、需要分享链接queryParameters
简单的单一 ID 且需深链pathParameters

六、导航动作

dart
import 'package:go_router/go_router.dart';

context.push(Routes.plantDetail, extra: args);   // 压栈(有返回按钮)
context.go(Routes.home);                          // 替换栈(无返回)
context.replace(Routes.login);                    // 替换当前页
context.pop();                                    // 返回上一页
context.pop(true);                                // 返回并带结果

⚠️ 禁止 Navigator 1.0

dart
Navigator.push(context, MaterialPageRoute(...))   // ❌ 禁止
context.push(Routes.xxx)                           // ✅ 正确

接收返回值

dart
// A 页面
final result = await context.push<bool>(Routes.somePage);
if (result == true) { ... }

// B 页面
context.pop(true);

前端类比router.push 返回 Promise。

项目实例 —— agent_home_page.dart:64-68 的注释提到:

从详情返回路径由 PlantCard await push 直接判定(等价),不经此 listen

切换底部 Tab

dart
// 跳转到第 index 个 branch
context.go(Routes.home);      // 或直接跳对应 path

或通过 StatefulShellRoute 的 navigationShell:

dart
navigationShell.goBranch(index);

七、tab_configuration.dart

底部 Tab 的可见性tabAccessProvider 派生(配合权限):

dart
getVisibleTabs: () => ref.read(tabAccessProvider),

service Tab 的注释(router.dart:177-178):

service(占位;显隐由 tabAccessProvider 派生)

模式:Tab 的显隐不是写死的,而是根据当前用户角色的权限计算出来。


八、注册新路由的完整步骤

  1. routes.dart 加常量 + 注释写清传参方式
dart
/// 告警详情(extra: AlarmDetailArgs)。
static const String alarmDetail = '/alarm-detail';
  1. router.dartGoRoute(放在对应分组内)
dart
GoRoute(
  path: Routes.alarmDetail,
  builder: (context, state) {
    final args = state.extra as AlarmDetailArgs?;
    return args == null ? const SizedBox.shrink() : AlarmDetailPage(args: args);
  },
),
  1. 页面里导航
dart
context.push(Routes.alarmDetail, extra: AlarmDetailArgs(id: item.id));

⚠️ 如果新页面需要登录才能访问,确认 redirect 守卫已覆盖(默认:非白名单页面未登录会跳 login)。


九、自检清单

  • [ ] 知道路由常量在 routes.dart,且注释要写传参方式
  • [ ] 知道 router.dart 注册 GoRoute
  • [ ] 理解 AuthRefreshListenable 的桥接作用
  • [ ] 能分辨 extra / queryParameters / pathParameters 的适用场景
  • [ ] 知道本项目传复杂对象用 extra + Args 类(并做好 null 兜底)
  • [ ] 知道底部 Tab 是 StatefulShellRoute.indexedStack(4 个 branch)
  • [ ] 用 context.push/go/pop不用 Navigator.push
  • [ ] 知道可以用 await context.push<T>() 接收返回值

下一步

14 · 国际化