13 · 路由与导航
目标:能注册新路由、三种方式传参、理解鉴权守卫,以及底部 Tab 是怎么搭的。
开篇:前端概念对照
| 前端(Vue Router / React Router) | GoRouter |
|---|---|
router.beforeEach | redirect 回调 |
路由表 routes: [...] | routes: [GoRoute(...)] |
嵌套路由 children | 子 routes / 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.id | state.pathParameters['id'] |
route.query.xxx | state.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:路径常量
/// 路由路径常量
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';
}三条约定
abstract class+Routes._()—— 禁止实例化- 注释写清传参方式 —— 这是本项目惯例,非常有用:
/// 电站详情(query: fatherId/systemStatus/plantStatus/plantCategory;phase 电站详情)。
static const String plantDetail = '/plant-detail';看到注释就知道这个页面需要什么参数。
- 常量名用 camelCase,路径用 kebab-case
带参数的便捷构造(可选)
有些项目会这么写:
static String detailOf(int id) => '/detail/$id';本项目多数情况直接用 extra 传参,所以较少见。
三、router.dart:路由表
顶层配置
lib/routing/router.dart:126-140:
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: [ ... ],路由条目(基本形态)
GoRoute(
path: Routes.login,
builder: (context, state) => const LoginPage(),
),底部 Tab:StatefulShellRoute.indexedStack
lib/routing/router.dart:151-200:
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()),
]),
],
),四个 branch:home / device / service / user。
indexedStack 的意义:切 Tab 时各分支的状态被保留(IndexedStack 只隐藏不销毁)。
前端类比:Vue 的 <keep-alive> + <router-view>。
⚠️ 与 HeKeepAliveTab(页面内 Tab)是两个不同层级的东西:
StatefulShellRoute—— 底部导航的 4 个主 TabHeKeepAliveTab—— 某个页面内部的 Tab(如电站详情的概览/设备/资料)
四、鉴权守卫
机制三件套
AuthViewModel(鉴权状态,Riverpod)
↓ 桥接
AuthRefreshListenable(ChangeNotifier)
↓ 触发
GoRouter.redirect(重新执行)AuthRefreshListenable
lib/routing/auth_refresh_listenable.dart:10-18:
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:
@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:复杂对象(本项目最常用)
// 传
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-322(plantDetail)、router.dart:334-355(plantHandover)。
⭐ 类型安全写法:Args 类
比 Map 更好的做法是定义 Args 模型:
// 传
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-217、lib/ui/device_detail/inverterDevice/models/inverter_detail_args.dart。
推荐这种写法:编译期类型安全,且强制处理「参数缺失」的情况。
② queryParameters:URL 查询参数
// 路由
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:路径参数
// 路由
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 传复杂对象。
选型建议
| 场景 | 用 |
|---|---|
| 传复杂对象 / DTO | extra(推荐用 Args 类) |
| 可选参数、需要分享链接 | queryParameters |
| 简单的单一 ID 且需深链 | pathParameters |
六、导航动作
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
Navigator.push(context, MaterialPageRoute(...)) // ❌ 禁止
context.push(Routes.xxx) // ✅ 正确接收返回值
// 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 的注释提到:
从详情返回路径由
PlantCardawait push直接判定(等价),不经此listen
切换底部 Tab
// 跳转到第 index 个 branch
context.go(Routes.home); // 或直接跳对应 path或通过 StatefulShellRoute 的 navigationShell:
navigationShell.goBranch(index);七、tab_configuration.dart
底部 Tab 的可见性由 tabAccessProvider 派生(配合权限):
getVisibleTabs: () => ref.read(tabAccessProvider),service Tab 的注释(router.dart:177-178):
service(占位;显隐由
tabAccessProvider派生)
模式:Tab 的显隐不是写死的,而是根据当前用户角色的权限计算出来。
八、注册新路由的完整步骤
routes.dart加常量 + 注释写清传参方式
/// 告警详情(extra: AlarmDetailArgs)。
static const String alarmDetail = '/alarm-detail';router.dart加GoRoute(放在对应分组内)
GoRoute(
path: Routes.alarmDetail,
builder: (context, state) {
final args = state.extra as AlarmDetailArgs?;
return args == null ? const SizedBox.shrink() : AlarmDetailPage(args: args);
},
),- 页面里导航
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 · 国际化