卷 VI · 交付CH 21深度 21/24

导航:Navigator 这个栈,与 go_router

直觉过境 「返回栈我熟,push / pop 而已。」 改一下

页面栈你确实熟——Navigator.push / pop 就是入栈出栈,和 Android 的返回栈一个概念。但你的学校 App 一旦要「从通知直接跳进某节课」「浏览器地址栏能刷新到当前页」「深链接进详情页后返回键能一层层退回去」,命令式的栈就撑不住了。这一章讲清什么时候该升级到声明式路由,并给你一套能抄的配置。

Navigator 栈go_router深链接路由守卫

Navigator:就是一个页面栈

最基本的导航,和你在 Android 的直觉一致——一个后进先出的栈:

// 推一个新页面(入栈)
Navigator.push(context, MaterialPageRoute(
  builder: (_) => CourseDetailPage(id: 42),
));

// 返回(出栈)
Navigator.pop(context);

// 带结果返回(≈ Android 的 startActivityForResult)
final picked = await Navigator.push<Course>(context, ...);   // 等 pop 的返回值
Navigator.pop(context, selectedCourse);                      // 把结果带回去

这套叫命令式导航——你一步步命令栈「推这个、弹那个」。小 App、纯线性流程(登录 → 首页 → 详情),它够用,而且简单。但它有个致命短板。

亲手跑:两种导航模型

下面这台演示同一组页面,在「Navigator 命令式」和「go_router 声明式」两种模型下的行为差别。切换模式,点那些路径按钮,盯住栈的变化。

关键差别在这里:Navigator 模式下,你只能一步步 push——想直接到「某课程的笔记页」(第 3 层),得先 push 列表、再 push 详情、再 push 笔记,一层层来。而 go_router 模式下,你给一个 URL /courses/42/notes,它自动算出应该有哪三层栈并一次建好

◆ 命令式 vs 声明式,一句话

Navigator 是一个栈:你告诉它「怎么走」(push 这个、pop 那个),它不知道「当前在哪个 URL」。

go_router 是一张地图:你告诉它「要去哪」(context.go('/courses/42')),它根据 URL 算出该有哪几层栈。

为什么这个差别重要——因为「从通知/链接直接落到深层页面,并且返回键还能正确地一层层退回去」这件事,命令式做起来极其别扭(你得手动补齐中间每一层),声明式天生就对。

什么时候必须上 go_router

出现下面任意一条,就别用裸 Navigator 了,换 go_router(Flutter 官方团队维护,是当前的标准答案):

  • 深链接 / 推送通知跳转:点一条「你有新成绩」的通知,要直接进那门课的成绩页。
  • Web 支持:地址栏要显示当前页、要能刷新、前进后退按钮要工作。
  • 底部导航栏 + 各自的栈:几个 Tab,每个 Tab 内部有自己的返回栈(StatefulShellRoute)。
  • 登录守卫:没登录时访问任何页都重定向到登录页。

学校 App 大概率至少中了「推送跳转」和「登录守卫」两条,所以——直接从 go_router 起步,别等到需要时再改。

go_router 的样子:一套能抄的配置

final router = GoRouter(
  initialLocation: '/',
  redirect: (context, state) {
    // 全局登录守卫:没登录且不在登录页 → 去登录页
    final loggedIn = context.read<AuthState>().isLoggedIn;
    final goingToLogin = state.matchedLocation == '/login';
    if (!loggedIn && !goingToLogin) return '/login';
    if (loggedIn && goingToLogin) return '/';
    return null;   // 不重定向
  },
  routes: [
    GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
    GoRoute(
      path: '/',
      builder: (_, __) => const HomePage(),
      routes: [   // 嵌套 = 子路由,会叠在父页面之上形成栈
        GoRoute(
          path: 'courses/:id',                          // 路径参数
          builder: (_, state) => CourseDetailPage(
            id: state.pathParameters['id']!,             // 取出 42
          ),
          routes: [
            GoRoute(path: 'notes', builder: (_, __) => const NotesPage()),
          ],
        ),
      ],
    ),
  ],
);

// 用起来
context.go('/courses/42');       // 换到这个位置(重算整条栈)
context.push('/courses/42');     // 在当前栈上再叠一层
context.pop();                   // 返回
✎ go / push / pop 怎么选

go:「切换到某个位置」,会按 URL 重算整条栈。用于底部导航切 Tab、登录后进首页这类「换场景」。
push:「在当前栈上再叠一页」。用于「详情 → 更详情」这类线性深入。
pop:返回。
经验:大多数「点进去看详情」用 push,大多数「跳去另一个板块」用 go

三个你一定会用到的细节

1. 传参数:路径参数 vs 额外对象

能放进 URL 的(id、类型)用路径参数 /courses/:id——这样深链接才带得上。不适合放 URL 的大对象(整个 Course 对象)用 extracontext.push('/detail', extra: course)但注意 extra 在 Web 刷新后会丢(它不在 URL 里),所以详情页最好还是靠 id 重新取数据。

2. 返回键与 PopScope

Android 的物理返回键、iOS 的侧滑返回,默认都会 pop 当前页。要拦截(比如「表单没保存,确认要离开吗」),用 PopScope

PopScope(
  canPop: !hasUnsavedChanges,
  onPopInvokedWithResult: (didPop, result) async {
    if (didPop) return;
    final leave = await showLeaveDialog(context);
    if (leave && context.mounted) context.pop();
  },
  child: MyForm(),
)

3. 显示对话框、底部弹窗

这些不是「页面」,用专门的函数,它们本质上也是往 Navigator 里推一个特殊路由:

showDialog(context: context, builder: (_) => AlertDialog(...));
showModalBottomSheet(context: context, builder: (_) => ...);
showDatePicker(context: context, ...);
// SnackBar 用 ScaffoldMessenger(第 15 章讲过为什么是它)
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存')));

为什么「一个栈」在今天不够用了

命令式导航的世界观是「App 就是一摞页面,往上摞、往下拿」。这个模型在只有手机、只从 App 内部点击跳转的年代很够用。但今天的 App 活在一个更复杂的处境里,而这个处境恰好是命令式栈的盲区:

页面不再只从 App 内部进入。用户可能点一条推送通知进来、可能从浏览器分享的链接进来、可能从另一个 App 跳进来、可能在 Web 版里直接敲一个网址。这些入口有一个共同点——它们描述的是「要到哪个页面」,而不是「怎么一步步走到那里」。命令式的 push 只会「走一步」,你没法用它表达「直接到达深层的某一页,并且让返回路径也正确」。你得手动补齐:先 push 首页、再 push 列表、再 push 详情——一旦入口多起来,这种手动补栈的逻辑会散落各处、极易出错。

声明式路由把世界观倒过来:App 的当前状态由一个 URL 完整描述,框架负责把 URL 翻译成一摞页面。你不再操心「怎么走」,只声明「在哪」。深链接、Web 地址栏、返回栈,都自动从这个翻译里掉出来。这就是为什么这些年整个行业(Web 的 React Router、Android 的 Navigation、iOS 的各种协调器模式,以及 Flutter 的 go_router)都往声明式路由收敛——不是赶时髦,是「多入口」这个现实逼出来的。

底部导航栏 + 各 Tab 独立栈

学校 App 几乎一定有底部导航(课表 / 通知 / 我的),而且每个 Tab 内部要有自己的返回栈——你在「课表」里点进详情、切到「通知」再切回来,得看到刚才那个详情页,而不是被重置。裸 Navigator 做这个要自己维护多个栈,很痛。go_router 有专门的 StatefulShellRoute

StatefulShellRoute.indexedStack(
  builder: (context, state, shell) => ScaffoldWithNavBar(shell: shell),
  branches: [
    StatefulShellBranch(routes: [   // 分支一:课表 Tab,有自己的栈
      GoRoute(path: '/courses', builder: (_, __) => const CoursesPage(), routes: [
        GoRoute(path: ':id', builder: (_, s) => CourseDetail(id: s.pathParameters['id']!)),
      ]),
    ]),
    StatefulShellBranch(routes: [   // 分支二:通知 Tab,独立的栈
      GoRoute(path: '/notifications', builder: (_, __) => const NotificationsPage()),
    ]),
    StatefulShellBranch(routes: [
      GoRoute(path: '/profile', builder: (_, __) => const ProfilePage()),
    ]),
  ],
)

底栏切换用 shell.goBranch(index)indexedStack 的关键好处:切走的 Tab 整棵子树留在树上(用 IndexedStack 藏起来,不销毁)——所以滚动位置、输入、加载好的数据全都保住,这正是第 8 章「藏起来 vs 删掉」的应用。这块和你在 Android 用 Navigation 的多返回栈(navigation graph 里每个 Tab 一个嵌套图)是同一个设计。

类型安全路由:别到处拼字符串

手写 context.go('/courses/${course.id}') 能用,但字符串路径散落各处、参数名写错编译器不管——和你在 Android 用字符串 route 的痛点一样。go_router 有代码生成的类型安全路由go_router_builder),把每条路由写成一个类:

@TypedGoRoute<CourseDetailRoute>(path: '/courses/:id')
class CourseDetailRoute extends GoRouteData {
  const CourseDetailRoute({required this.id});
  final String id;
  @override
  Widget build(BuildContext context, GoRouterState state) => CourseDetail(id: id);
}

// 导航:编译期检查,参数拼错直接编译不过
const CourseDetailRoute(id: '42').go(context);

它又要走 build_runner(第 22 章的常客)。小 App 用不用看你——但一旦路由超过七八条、参数多起来,类型安全能省下大量「跑起来才发现路径写错」的时间。这对应你在 Android 用 Safe Args / Navigation Compose 的类型安全目标

⇄ Compose 对照 · 你正好赶上一次范式统一

这块你的经验能直接用,而且时机很好。Android 那边你近期用的 Navigation Compose / Navigation 3 也是声明式路由 + 深链接那一套——定义路由图、用类型安全的目标导航、返回栈交给框架管。go_router 就是 Flutter 里的等价物

Navigation Composego_router
NavHost + composable(route)GoRouter + GoRoute
navController.navigate(route)context.go/push(path)
路径参数 {id}路径参数 :id
深链接 deepLinks = ...天生就是 URL,深链接免费
嵌套图 / 底部栏各自栈嵌套 routes / StatefulShellRoute

所以你对「返回栈由框架管、导航是声明一个目标而非命令一步操作」的理解,一比一带过来。裸 Navigator.push 反而更像你更早期用的命令式 startActivity——能用,但深链接和 Web 一来就该升级。

这一章的一句话

Navigator 是个页面栈、push/pop 你早就会,但深链接、Web 地址栏、推送跳转一来它就不够用了——该换成 go_router 的声明式路由:你给一个 URL、它算出整条栈,这正是你在 Navigation Compose 里已经熟悉的那套,直接搬。

页面能跳了,但每个页面都要显示数据、提交数据。下一章搭数据层最后一块:怎么发网络请求、怎么把 JSON 变成对象(Dart 没有反射,这里有讲究)、怎么做本地存储——把第 17 章那个 Repository 真正填满。