成为全栈·Flutter App 篇·go_router 与四 Tab App Shell:保留导航状态、深链和登录回跳

成为全栈·Flutter App 篇·go_router 与四 Tab App Shell:保留导航状态、深链和登录回跳

做导航的时候,我被一个场景卡住了。

用户点了"我的"Tab,然后进会员中心,看到"你还没有登录",跳到登录页。登录成功,回到会员中心。

这一串做对不难。难的是它的变体:

  • 用户在会员中心直接杀 App,重开,应该回到会员中心而不是首页。
  • 用户没登录时从分享链接打开 /member/settings/avatar(一个深层页面),应该先登录再回到那一页。
  • 用户已经登录,从会员中心退出登录,不应该还停在那页。

这三个场景逼出了两样东西:"我在哪个 Tab"是状态,而不只是 UI ;"登录完成后该去哪"必须被显式记住。

这篇讲 app/router.dart 那两百行,以及它背后那个没那么显眼但很关键的设计------四个 Tab 用的是 StatefulShellRoute 而不是普通的 ShellRoute。

为什么不是 push 和 pop

先说一个基础选择。底部四个 Tab 之间切换,用哪种方式?

错误做法一:每个 Tab 一个页面,onTap 里 Navigator.push。

结果:用户从"发现"点到"我的",再点返回,回到"发现"------但"我的"Tab 的滚动位置没了。因为它是被 push 上去的,返回时整个页面被销毁(或者被保留但栈很深)。

错误做法二:把所有页面塞进一个栈,切换时 popUntil 回根。

结果:Tab 之间跳来跳去,栈被反复重置,而且**"我的"这个 Tab 会显示"发现"的内容**。

StatefulShellRoute:为每个分支保留栈

正确的做法是让每个 Tab 拥有自己的导航栈,彼此独立:

dart 复制代码
StatefulShellRoute.indexedStack(
  builder: (c, s, shell) => Scaffold(
    body: shell,
    bottomNavigationBar: ...,
  ),
  branches: _shellBranches(),
)

而分支的声明:

dart 复制代码
List<StatefulShellBranch> _shellBranches() => [
  StatefulShellBranch(
    routes: [GoRoute(path: '/', builder: (c, s) => const HomePage())],
  ),
  StatefulShellBranch(
    routes: [
      GoRoute(path: '/categories', builder: (c, s) => const CategoriesPage()),
      GoRoute(path: '/tags', builder: (c, s) => const TagsPage()),
      GoRoute(
        path: '/browse',
        builder: (c, s) => BrowsePage(...),
      ),
    ],
  ),
  ...
];

关键在 StatefulShellBranch 和它那个 routes 列表。

它做两件事:

  1. 每个分支维护独立的 Navigator ------ 切到别的 Tab 再切回来,这个 Tab 的页面栈还在
  2. 默认用 indexedStack 保留所有分支的 Widget ------ 切 Tab 不销毁页面,所以滚动位置、已加载的数据都还在

第二点尤其重要。如果不保留,"我的"Tab 每次切过去都要重新请求数据------而 M4-10 讲过,会员中心那个概览是要发四个请求的。

而第二个分支里放了三条路由(/categories、/tags、/browse),这体现了一个 Tab 可以有多个页面。用户在分类页点进某个分类,是"发现"Tab 内部的导航,不是跳到另一个 Tab------底部高亮仍然在"发现"。

这个层级关系是靠分支的嵌套表达的,不是靠路径前缀的字符串推断。

三个 Tab 的路由归属

看完整的分支声明,会发现几个有意思的安排:

Tab 分支内路由
首页 /
发现 /categories、/tags、/browse
文章详情 /articles/:id ← 独立分支,不带底栏
我的 /member
(Tab 外) /member/settings、/login、/search 等

文章详情自己占一个分支,而它的界面里没有底部 Tab 栏。

这是有意的:读文章的时候不该有底栏(它占空间,而且用户全神贯注在读)。但它在路由层面属于一个分支,这样从详情页切到详情页(比如"下一篇")不会触发底栏的切换动画。

而 /member/settings 在分支外 ------它在 /member 之上,是会员中心推入的二级页面。

这个层级不是随便定的,判据是:页面显示底栏吗?

  • 显示 → 必须在某个分支里
  • 不显示但属于某个 Tab 的子流程 → 放在那个分支里,用 --tab 参数控制
  • 完全独立(登录、搜索)→ 放分支外

这个判据的好处是不依赖路径字符串 。否则你得维护一张"哪些路径有底栏"的表,而每加一个页面就要更新它------忘一次就是底栏错乱。

登录回跳:把"从哪来"编码进 URL

现在讲开头那个场景。看重定向的实现:

dart 复制代码
String? _redirect(AppSession session, GoRouterState state) {
  final private =
      state.uri.path.startsWith('/member/') &&
      state.uri.path != '/member/settings';
  if (private && session.user == null) {
    if (session.restoring) {
      return '/restoring?from=${Uri.encodeComponent(state.uri.toString())}';
    }
    return '/login?from=${Uri.encodeComponent(state.uri.toString())}';
  }
  if (state.uri.path == '/restoring' && !session.restoring) {
    return state.uri.queryParameters['from'] ?? '/member';
  }
  return null;
}

这段代码解决四个问题。

一、哪些页面需要登录?

dart 复制代码
final private =
    state.uri.path.startsWith('/member/') &&
    state.uri.path != '/member/settings';

注意那个 != '/member/settings'------设置页不要求登录。因为新用户第一次进来应该能改主题、看关于页,而不必先注册。

而 /member(会员中心本身)是不要求 登录的------M4-18 讲过,未登录用户应该能看到登录引导。这才是符合前面 startsWith('/member/') 带斜杠的写法。

startsWith('/member/') 和 startsWith('/member') 的区别就是这一点 :前者只匹配子页面,不匹配 /member 本身。

二、from 参数怎么带?

dart 复制代码
'/login?from=${Uri.encodeComponent(state.uri.toString())}'

为什么要 encodeComponent? 因为 state.uri.toString() 可能包含查询参数(比如 /browse?category=flutter&page=2),而 & 和 ? 在 URL 里有特殊含义。不转义的话,回跳的 URL 会被截断。

这是 M4-11 讲分类导航时同一个问题的另一个例子------用户可编辑的内容(这里是 URL 本身)进入 URL 时必须转义。

三、restoring 这个中间状态。

dart 复制代码
if (session.restoring) {
  return '/restoring?from=${Uri.encodeComponent(state.uri.toString())}';
}

这是整段代码最微妙的一处。考虑这个场景:

App 冷启动,用户直接点分享链接打开 /member/settings/avatar。

此时 App 刚启动,会话还在恢复中 ------令牌在安全存储里,但还没验证是否有效(session.restore() 还没跑完)。

如果这时候按"未登录"处理,跳登录页------但用户其实有有效令牌,几百毫秒后就会登录成功。用户被无谓地踢去登录页。

所以需要一个"还不知道"的中间状态:

dart 复制代码
/// 会话是账号边界:切换账号提升 epoch 并清除私有缓存,草稿仍按账号隔离。
class AppSession extends ChangeNotifier {
  ...
  bool restoring = true;

而重定向的第二条就是处理这个中间页:

dart 复制代码
if (state.uri.path == '/restoring' && !session.restoring) {
  return state.uri.queryParameters['from'] ?? '/member';
}

恢复完成后,/restoring 页面自己把用户送到目的地。 整个过程用户看到的是一个短暂的加载页,而不是"被踢去登录"再跳回来。

这个"不知道"的三态(未登录 / 恢复中 / 已登录)很多 App 都会漏掉,而它的表现形式非常迷惑:用户明明登录着,却被要求登录。

四、退出登录时怎么办?

redirect 只在 session.user == null 时跳登录。而退出登录时 user 变成 null,如果用户当时在 /member/settings,那是个设置页不需要登录,所以不会跳。 用户会停在一个"我已经退出登录"但界面可能还显示着旧数据的页面。

这个处理在 AppSession.authenticate 里:

dart 复制代码
Future<void> authenticate(Map<String, dynamic> data, {bool register = false}) async {
  await api.clear();
  user = null;
  PaintingBinding.instance.imageCache.clear();
  notifyListeners();
  ...

notifyListeners() 之后,所有依赖 sessionProvider 的 Widget 会重建。但当前路由不会自动跳走------所以登出后需要显式导航。

而 PaintingBinding.instance.imageCache.clear() 这一行值得说:它清的是 Flutter 内置的图片缓存。

M4-24 讲了我们自己的 ImageStore,但 Flutter 框架自己也会缓存解码后的图片(比如 Image.network 用的那个)。用户 A 的头像可能还在框架缓存里,所以登出必须显式清掉。

这是"登出要清理什么"这个清单上的一项,而它不在我们自己的缓存层里。

路由错误页

dart 复制代码
Widget _errorBuilder(BuildContext c, GoRouterState s) => PageFrame(
  title: '页面不存在',
  child: StateMessage(
    title: '没有找到这个页面',
    description: '请返回首页继续阅读',
    onRetry: () => c.go('/'),
  ),
);

这个错误页看着普通,但有一个考虑:它是给"用户点了一个不存在的链接"准备的,而链接可能来自站外。

比如有人分享了一个 /articles/999999(文章被删了)。这时不能显示 Flutter 的默认错误页(那是一堆红字,对用户毫无意义)。

而 onRetry: () => c.go('/') 用的是 go 而不是 push------因为错误页本身已经在栈顶了,push 会让返回键失效(返回到一个不存在的页面)。

这一个小细节能避免"用户在错误页上按返回,App 什么都没发生"。

深链:路径参数的编码

/articles/:id 里的 :id 是路径参数。而 M4-14 讲过,Article.route 可能是 id 也可能是 slug:

dart 复制代码
String get route => data.slug?.isNotEmpty == true ? data.slug! : id.toString();

所以路由要接受两种形式:

dart 复制代码
GoRoute(
  path: '/articles/:idOrSlug',
  builder: (c, s) => ArticlePage(idOrSlug: s.pathParameters['idOrSlug']!),
)

而需要登录的深层页面(改头像、通知列表)要额外带路径参数:

dart 复制代码
GoRoute(
  path: '/member/settings/avatar',
  builder: (c, s) => AccountBoundary(
    ...

M4-07 讲的那张缓存策略表里,family() 判断路径用的是 endsWith 和 startsWith:

dart 复制代码
if (path.startsWith(Endpoints.privatePrefix)) return ResourceFamily.member;

所以路径参数的命名(:idOrSlug)和缓存判定(startsWith)是两套机制,它们之间靠的是"约定"而不是"机制"。

这个约定的脆弱之处在于:如果有人把路由从 /member/settings/avatar 改成 /member/avatar,缓存分类还是对的(都是 member),但如果改成 /profile/avatar,它就不再被识别为私有路径 ,缓存键会变成 public。

这类问题只有测试能发现------因为单个路径的判断都是对的,只有组合起来才会错。

一条我一开始想省掉的事

restoring 那个中间状态,第一版我没做。

那时候的判断是"登录态检查很快,等一下就行"------于是未登录就跳登录页。

用户反馈的场景很具体:"我明明登录着,从别人分享的链接点进 App,它说让我登录。"

而根因不是"检查慢",是**"在检查完成之前就做了决定"**。三个状态被当成了两个(登录 / 未登录),中间那个"还不知道"被错误地归到了"未登录"。

修复之后我意识到,这个模式在别的地方也存在:

场景 三态
会话 未登录 / 恢复中 / 已登录
缓存 无 / stale / fresh
加载 无数据 / 有旧数据 / 全新加载

三态里的中间态最容易被漏掉,因为它看起来像"正在进行",而代码通常只为"已完成"设计分支。

而中间态是最需要显式处理的------因为用户在这个状态下看到的东西,可能是错的。

小结

导航看起来是最不需要设计的一层,因为它"就是几个按钮"。但真正做下来,它要解决四个问题:

  1. Tab 之间要保留状态 ------ StatefulShellRoute 给每个分支独立的导航栈和 Widget 树。
  2. 哪些页面需要登录 ------ 用路径判定,但要处理好"设置页不需要登录"这类例外。
  3. 登录完成回哪 ------ from 参数 + encodeComponent + restoring 中间态。
  4. 登出要清什么 ------ 包括 Flutter 框架自己的图片缓存,不只是我们那层。

其中第 3 条的 restoring 是最值得记住的:"还不知道"是一个独立状态,它不能被归入"没有"。

这和 M4-23 讲的 stale 缓存是同一个模式------中间态需要显式的 UI 和决策,因为它是最容易被误判的那个。

下一篇讲 Riverpod 的状态边界------会话、服务端数据、表单草稿这三种状态的生命周期完全不同,用同一种方式管理必然出问题。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
m0_738185822 小时前
Flutter 鸿蒙化实战:screen_protector 适配 OpenHarmony,防截屏/防泄露
flutter·华为·harmonyos·鸿蒙
梦想的颜色7 小时前
【编程实战】AI 时代 APP 开发全栈硬核指南:技术选型 + AI 架构 + 模型落地全维度决策
python·flutter·react native·react.js·ai·桌面应用·milvus
yuanlaile1 天前
2026年全新Flutter 跨平台开发全栈学习资料整理
flutter·getx·鸿蒙 next·flutter开发鸿蒙·flutter入门实战·flutter全栈
恋猫de小郭2 天前
Genkit Dart 1.0 发布,Flutter 原生的 AI Agent 终于完整了
android·前端·flutter
第10086个小白2 天前
flutter桌面端打包方式(Inno Setup Compiler)版本
前端·flutter
传奇开心果编程2 天前
【React Native娓娓道来】第7课 表单与用户输入:TextInput、表单校验与键盘处理
移动开发·表单·编程语言理论·开发范式·编程框架·声明式ui·用户输入
传奇开心果编程2 天前
【React Native娓娓道来】第9课 动画与手势:Animated、Reanimated 与 Pressable 进阶
移动开发·动画·手势·编程语言理论·开发范式·编程框架·声明式ui
传奇开心果编程2 天前
【声明式UI开发实用技术学与练】第2课 状态建模:把界面拆成可预测的数据
移动开发·编程语言理论·开发范式·编程框架·声明式ui
传奇开心果编程2 天前
【React Native娓娓道来】第3课 组件组合与列表渲染:把界面拆成可复用的函数
移动开发·列表渲染·编程语言理论·开发范式·编程框架·声明式ui·组件组合