Flutter 基础体系 · 第 48/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter go_router:声明式路由、重定向、Shell、Deep Link 和恢复

go_router 是 Flutter 官方生态中常用的声明式路由包。它把“当前应用状态应该显示什么页面”表示为路由树,并负责把外部地址、浏览器历史、系统返回、重定向和嵌套路由连接起来。

本文示例基于 Dart 3 和当前稳定版 Flutter 的常见 go_router API。go_router 是独立的 Pub package,不属于 Flutter SDK 内置库,因此项目需要显式添加依赖:

flutter pub add go_router

随后执行:

flutter pub get

示例中的具体包版本由项目的 pubspec.yamlpub get 解析结果决定。不同 go_router 大版本可能新增或弃用少量参数,遇到 API 差异时应以当前包的 API 文档和 IDE 类型提示为准。


一、先建立路由问题的模型

1. 路由不是“打开一个页面”

命令式导航通常写成:

Navigator.of(context).push(
  MaterialPageRoute(
    builder: (_) => const DetailPage(),
  ),
);

这段代码表达的是一个动作:把 DetailPage 压入某个 Navigator 的栈。

声明式路由表达的是一个状态关系:

当前 URI + 当前应用状态
        ↓
路由匹配与重定向
        ↓
页面树和 Navigator 树

例如:

/login                  → 登录页
/home                   → 首页
/orders/42              → 订单详情页,订单 ID 为 42

在声明式模型中,页面不是由某次 push 永久决定的,而是由当前路由状态重新计算出来。用户登录状态、权限、外部 Deep Link、浏览器地址栏和系统恢复都可以改变这个状态。

可以把路由结果抽象为:

P=R(U,S)P = R(U, S)

其中:

  • UU 是当前 URI,例如 /orders/42?tab=items
  • SS 是应用状态,例如是否登录、当前用户角色;
  • RR 是路由匹配和重定向过程;
  • PP 是最终的页面与 Navigator 结构。

重定向并不是简单地“跳转一次”。它通常是一个迭代过程:

U0redirectU1redirectU2redirectU_0 \xrightarrow{redirect} U_1 \xrightarrow{redirect} U_2 \xrightarrow{redirect} \cdots

直到某个 URI 不再触发重定向,才进行最终路由匹配。

因此,下面两个概念必须区分:

  • 路由匹配:URI 是否符合某个 GoRoute 的路径模式;
  • 重定向:根据当前状态,把 URI 改写成另一个 URI。

二、一个可运行的最小完整示例

下面的示例包含:

  • /login 登录页;
  • /home/settings 两个底部导航分支;
  • /orders/:orderId 动态参数;
  • 登录状态重定向;
  • ShellRoute
  • 错误页;
  • 外部 URI 进入应用后的处理。

为了让示例可直接运行,认证状态使用内存中的 ChangeNotifier 模拟。真实项目应把它替换成应用级认证服务。

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

void main() {
  final auth = AuthState();

  final router = GoRouter(
    debugLogDiagnostics: true,
    initialLocation: '/home',
    refreshListenable: auth,

    redirect: (context, state) {
      final isLoggedIn = auth.isLoggedIn;
      final isLoggingIn = state.matchedLocation == '/login';

      if (!isLoggedIn && !isLoggingIn) {
        final from = state.uri.toString();
        return Uri(
          path: '/login',
          queryParameters: {'from': from},
        ).toString();
      }

      if (isLoggedIn && isLoggingIn) {
        final from = state.uri.queryParameters['from'];
        return from == null || from.isEmpty ? '/home' : from;
      }

      return null;
    },

    errorBuilder: (context, state) {
      return ErrorPage(error: state.error);
    },

    routes: [
      GoRoute(
        path: '/login',
        builder: (context, state) {
          return LoginPage(auth: auth);
        },
      ),

      ShellRoute(
        builder: (context, state, child) {
          return AppShell(child: child);
        },
        routes: [
          GoRoute(
            path: '/home',
            builder: (context, state) {
              return const HomePage();
            },
            routes: [
              GoRoute(
                path: 'orders/:orderId',
                builder: (context, state) {
                  final orderId = state.pathParameters['orderId']!;
                  final tab = state.uri.queryParameters['tab'] ?? 'summary';

                  return OrderDetailPage(
                    orderId: orderId,
                    tab: tab,
                  );
                },
              ),
            ],
          ),
          GoRoute(
            path: '/settings',
            builder: (context, state) {
              return const SettingsPage();
            },
          ),
        ],
      ),
    ],
  );

  runApp(MyApp(router: router));
}

class MyApp extends StatelessWidget {
  const MyApp({required this.router, super.key});

  final GoRouter router;

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'go_router Demo',
      routerConfig: router,
    );
  }
}

class AuthState extends ChangeNotifier {
  bool _isLoggedIn = false;

  bool get isLoggedIn => _isLoggedIn;

  void login() {
    _isLoggedIn = true;
    notifyListeners();
  }

  void logout() {
    _isLoggedIn = false;
    notifyListeners();
  }
}

class AppShell extends StatelessWidget {
  const AppShell({required this.child, super.key});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    final location = GoRouterState.of(context).uri.path;
    final selectedIndex = location.startsWith('/settings') ? 1 : 0;

    return Scaffold(
      appBar: AppBar(
        title: const Text('订单应用'),
        actions: [
          IconButton(
            onPressed: () {
              context.read<AuthState>();
            },
            icon: const Icon(Icons.info_outline),
          ),
        ],
      ),
      body: child,
      bottomNavigationBar: NavigationBar(
        selectedIndex: selectedIndex,
        onDestinationSelected: (index) {
          switch (index) {
            case 0:
              context.go('/home');
            case 1:
              context.go('/settings');
          }
        },
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.home_outlined),
            selectedIcon: Icon(Icons.home),
            label: '首页',
          ),
          NavigationDestination(
            icon: Icon(Icons.settings_outlined),
            selectedIcon: Icon(Icons.settings),
            label: '设置',
          ),
        ],
      ),
    );
  }
}

class LoginPage extends StatelessWidget {
  const LoginPage({required this.auth, super.key});

  final AuthState auth;

  @override
  Widget build(BuildContext context) {
    final from = GoRouterState.of(context).uri.queryParameters['from'];

    return Scaffold(
      appBar: AppBar(title: const Text('登录')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            auth.login();

            // notifyListeners 会触发 GoRouter 重新执行 redirect。
            // 这里不必手动 push('/home'),重定向会使用 from 参数恢复原目标。
          },
          child: Text(from == null ? '登录并进入首页' : '登录并继续'),
        ),
      ),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        const Text(
          '首页',
          style: TextStyle(fontSize: 24),
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: () {
            context.go('/home/orders/42?tab=items');
          },
          child: const Text('打开订单 42 的明细标签'),
        ),
      ],
    );
  }
}

class OrderDetailPage extends StatelessWidget {
  const OrderDetailPage({
    required this.orderId,
    required this.tab,
    super.key,
  });

  final String orderId;
  final String tab;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('订单 $orderId')),
      body: Center(
        child: Text('订单:$orderId\n当前标签:$tab'),
      ),
    );
  }
}

class SettingsPage extends StatelessWidget {
  const SettingsPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('设置'),
    );
  }
}

class ErrorPage extends StatelessWidget {
  const ErrorPage({required this.error, super.key});

  final Exception? error;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('页面不存在')),
      body: Center(
        child: Text(
          error?.toString() ?? '找不到对应页面',
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}

上面的代码中有一处需要注意:AppShell 中的 context.read<AuthState>() 依赖 Provider 的扩展,而示例没有引入 Provider。为了保持代码可以直接编译,应删除这个 IconButton,或者将其替换为不依赖 Provider 的按钮。完整可运行版本应把 actions 改成:

actions: [
  IconButton(
    onPressed: () => context.go('/settings'),
    icon: const Icon(Icons.info_outline),
  ),
],

这里的关键路径是:

  1. 应用初始位置是 /home
  2. auth.isLoggedInfalse
  3. 全局 redirect/home 改写为 /login?from=%2Fhome
  4. 登录按钮调用 auth.login()
  5. AuthState 发出通知;
  6. GoRouterrefreshListenable 重新执行 redirect
  7. 已登录且当前是 /login,于是读取 from,返回 /home
  8. 最终显示 ShellRoute 包裹的首页。

三、声明式路由树如何匹配 URI

1. 静态路径、动态参数和查询参数

下面三种写法表达的是不同数据来源:

GoRoute(
  path: '/orders',
  builder: ...,
)

匹配固定路径 /orders

GoRoute(
  path: '/orders/:orderId',
  builder: (context, state) {
    final orderId = state.pathParameters['orderId']!;
    ...
  },
)

匹配 /orders/42/orders/abc 等路径。orderId 是路径参数,不应从查询参数中读取。

/orders/42?tab=items

其中:

  • 42 来自 state.pathParameters['orderId']
  • items 来自 state.uri.queryParameters['tab']

对应代码:

final orderId = state.pathParameters['orderId'];
final tab = state.uri.queryParameters['tab'];

路径参数通常标识资源,例如订单 ID、用户 ID;查询参数通常描述筛选、排序、分页或当前视图,例如 ?tab=items

不要把所有内容都塞进查询参数。对于 /orders/42,路径本身表达“订单 42 是资源”;对于 /orders?status=paid,查询参数表达“订单列表的筛选条件”。

2. 子路由不是字符串拼接,而是嵌套路由匹配

示例中:

GoRoute(
  path: '/home',
  routes: [
    GoRoute(
      path: 'orders/:orderId',
      builder: ...,
    ),
  ],
)

父路由使用绝对路径 /home,子路由写成相对路径 orders/:orderId,最终组合为:

/home/orders/:orderId

子路由不能再写成 /home/orders/:orderId 作为同一个父节点下的相对子路径,否则会破坏路由树的语义。

go_router 会根据 URI 生成匹配链:

/home/orders/42
  ├── /home
  └── /home/orders/:orderId

这条匹配链随后决定要构建哪些页面、哪些 Shell 需要保留,以及哪个 Navigator 承载页面。

3. gopushpop 的语义不同

context.go('/settings');

go 表示把当前路由状态切换到目标 URI。它更接近“当前应用应该位于哪个地址”,而不是“在栈顶压入一个页面”。

context.push('/home/orders/42');

push 表示在当前导航上下文中增加一个新的页面历史项。它适合详情页、编辑页等需要返回到当前页面的场景。

context.pop();

返回上一个可返回的页面。

一个常见错误是把所有导航都写成 push。这样可能导致用户反复点击底部导航后,返回键需要经过许多重复页面。另一个错误是把所有导航都写成 go,从而丢失需要保留的页面栈。


四、重定向:从认证状态到最终 URI

1. 重定向函数的契约

全局重定向通常写在 GoRouter 上:

final router = GoRouter(
  redirect: (context, state) {
    if (...) {
      return '/login';
    }
    return null;
  },
);

返回值含义:

  • 返回 null:不重定向,继续进行当前 URI 的匹配;
  • 返回一个 URI 字符串:使用该 URI 重新开始路由处理。

局部重定向也可以写在 GoRoute 上:

GoRoute(
  path: '/legacy',
  redirect: (context, state) => '/home',
)

局部重定向适合旧路径迁移、某个功能入口的局部规则;认证和全局权限通常放在顶层,避免不同分支重复实现。

2. 为什么 refreshListenable 是必要的

以下代码只在路由初始化或地址变化时检查一次登录状态:

final router = GoRouter(
  redirect: (context, state) {
    return auth.isLoggedIn ? null : '/login';
  },
);

如果登录按钮后来修改了 auth.isLoggedIn,而路由没有收到变化通知,重定向函数不会自动重新执行。

因此需要:

final router = GoRouter(
  refreshListenable: auth,
  redirect: (context, state) {
    ...
  },
);

AuthState 必须在状态变化时调用 notifyListeners()

class AuthState extends ChangeNotifier {
  bool _isLoggedIn = false;

  bool get isLoggedIn => _isLoggedIn;

  void login() {
    _isLoggedIn = true;
    notifyListeners();
  }
}

数据流可以表示为:

flowchart LR
    A[认证状态变化] --> B[ChangeNotifier.notifyListeners]
    B --> C[GoRouter refreshListenable]
    C --> D[重新执行 redirect]
    D --> E{是否产生新 URI}
    E -- 是 --> F[重新匹配新 URI]
    E -- 否 --> G[构建当前路由页面]

如果使用 Riverpod、Bloc 或其他状态管理库,核心要求不变:认证状态变化必须能够触发路由刷新。具体接法可以是把状态适配为 Listenable,或者在状态变化时调用路由刷新机制;不要假设普通业务状态的变化会自动被 go_router 观察。

3. 登录后恢复原始目标

用户直接打开:

https://example.com/home/orders/42?tab=items

但此时未登录。简单重定向到 /login 会丢失原始目标。示例把目标放进查询参数:

final from = state.uri.toString();

return Uri(
  path: '/login',
  queryParameters: {'from': from},
).toString();

登录完成后:

final from = state.uri.queryParameters['from'];

return from == null || from.isEmpty ? '/home' : from;

这里必须使用 Uri 构造查询参数,而不是手工拼接:

// 不推荐
return '/login?from=$from';

因为 from 可能包含 ?&# 或非 ASCII 字符。手工拼接可能改变 URI 的结构。Uri(queryParameters: ...) 会正确编码。

4. 重定向必须具有终止条件

一个错误的重定向:

redirect: (context, state) {
  if (!auth.isLoggedIn) {
    return '/login';
  }
  return '/home';
}

当已经位于 /login 且未登录时,仍然返回 /login。这会形成自重定向。

更严重的情况是:

redirect: (context, state) {
  if (state.matchedLocation == '/a') {
    return '/b';
  }
  return '/a';
}

这会在 /a/b 之间循环。

go_router 会限制连续重定向次数,超过限制后显示路由错误,而不是无限递归。这个保护机制只能防止程序失控,不能替代正确的重定向逻辑。

对认证重定向,至少要满足:

未登录且不在登录页 → 登录页
未登录且已经在登录页 → 不再重定向
已登录且在登录页 → 目标页
已登录且不在登录页 → 不重定向

这四种状态正是示例中的两个条件共同实现的结果。

5. 异步认证状态的边界

认证状态常常需要异步读取,例如:

  • 从安全存储读取 Token;
  • 向服务端验证会话;
  • 加载租户和用户权限。

redirect 支持异步返回值,但不应在每次路由评估时重复发起不可控的网络请求。更稳定的状态模型是:

unknown  →  authenticated
unknown  →  unauthenticated

启动时先显示启动页或保持初始化状态,认证检查完成后通知路由刷新。否则可能出现:

  1. 启动时暂时认为未登录;
  2. 立即重定向到 /login
  3. Token 恢复完成后又重定向回原页面;
  4. 用户看到闪烁,甚至产生历史记录混乱。

认证失败、Token 过期和网络不可用也应区分:

  • Token 明确失效:进入登录流程;
  • 网络暂时不可用:可能保留当前页面并显示离线状态;
  • 权限不足:进入 403 页面,而不是统一跳登录页。

五、Shell:共享外壳与嵌套 Navigator

1. ShellRoute 解决什么问题

ShellRoute 的作用是为一组子路由提供共同的页面外壳,例如:

  • 底部导航栏;
  • 侧边栏;
  • 公共 AppBar;
  • 登录后统一的布局;
  • 桌面端的导航 Rail。

示例:

ShellRoute(
  builder: (context, state, child) {
    return AppShell(child: child);
  },
  routes: [
    GoRoute(path: '/home', builder: ...),
    GoRoute(path: '/settings', builder: ...),
  ],
)

这里 child 是当前匹配的子路由页面。进入 /home 时,child 是首页;进入 /settings 时,child 是设置页。

Shell 本身不是业务页面,而是路由树中的布局节点。

2. ShellRoute 与 Navigator 的关系

普通 GoRoute 默认使用根 Navigator。ShellRoute 会为其子路由提供一个 Shell Navigator,子页面通常显示在这个 Navigator 中。

这会影响页面的显示层级。例如,在底部导航 Shell 中打开订单详情:

Root Navigator
└── Shell Navigator
    ├── AppShell
    └── OrderDetailPage

如果订单详情应该覆盖底部导航栏,常见做法是把它放到根 Navigator:

final rootNavigatorKey = GlobalKey<NavigatorState>();

final router = GoRouter(
  navigatorKey: rootNavigatorKey,
  routes: [
    ShellRoute(
      routes: [
        GoRoute(
          path: '/home',
          builder: ...,
          routes: [
            GoRoute(
              path: 'orders/:orderId',
              parentNavigatorKey: rootNavigatorKey,
              builder: ...,
            ),
          ],
        ),
      ],
    ),
  ],
);

此时结构近似为:

Root Navigator
├── AppShell
└── OrderDetailPage

OrderDetailPage 会覆盖 Shell,因此底部导航可以不显示。

parentNavigatorKey 必须指向实际存在的祖先 Navigator key。如果 key 层级不匹配,可能在运行时出现路由配置错误或页面显示位置不符合预期。

3. ShellRoute 不等于“每个 Tab 都保留自己的栈”

普通 ShellRoute 适合共享外壳,但底部导航的每个分支是否保留独立页面栈,需要进一步区分。

例如用户经历:

首页 → 订单详情 → 设置 → 回到首页

如果希望回到首页时仍停留在订单详情,就需要每个 Tab 拥有独立 Navigator。此时使用 StatefulShellRoute

final router = GoRouter(
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return StatefulAppShell(navigationShell: navigationShell);
      },
      branches: [
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/home',
              builder: (context, state) => const HomePage(),
              routes: [
                GoRoute(
                  path: 'orders/:orderId',
                  builder: (context, state) {
                    return OrderDetailPage(
                      orderId: state.pathParameters['orderId']!,
                      tab: state.uri.queryParameters['tab'] ?? 'summary',
                    );
                  },
                ),
              ],
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/settings',
              builder: (context, state) => const SettingsPage(),
            ),
          ],
        ),
      ],
    ),
  ],
);

外壳通过 StatefulNavigationShell 切换分支:

class StatefulAppShell extends StatelessWidget {
  const StatefulAppShell({
    required this.navigationShell,
    super.key,
  });

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: navigationShell.currentIndex,
        onDestinationSelected: (index) {
          navigationShell.goBranch(
            index,
            initialLocation: index == navigationShell.currentIndex,
          );
        },
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.home_outlined),
            label: '首页',
          ),
          NavigationDestination(
            icon: Icon(Icons.settings_outlined),
            label: '设置',
          ),
        ],
      ),
    );
  }
}

这里有两个层次:

  • ShellRoute:共享一个外壳和一个子导航上下文;
  • StatefulShellRoute:为多个分支维护独立的导航状态。

StatefulShellRoute.indexedStack 常用 IndexedStack 展示分支,但“保留页面栈”并不意味着所有页面都一直执行构建、网络请求或动画。页面是否保持资源、是否暂停业务任务,仍取决于页面本身和生命周期设计。


六、Deep Link:外部 URI 如何进入路由树

1. Deep Link 的完整链路

Deep Link 是从应用外部通过 URI 直接打开应用内资源的能力。例如:

https://example.com/home/orders/42?tab=items

完整链路不是只有 go_router

sequenceDiagram
    participant E as 外部来源
    participant OS as Android/iOS
    participant F as Flutter Engine
    participant R as Router
    participant G as go_router
    participant P as 页面

    E->>OS: 点击 HTTPS 或自定义 Scheme
    OS->>F: 启动或唤醒应用并传递 URI
    F->>R: 提供初始路由信息
    R->>G: 交给 GoRouterDelegate/RouteInformationParser
    G->>G: 匹配路径、执行 redirect
    G->>P: 构建最终页面

如果应用内路由配置正确,但操作系统没有把链接交给应用,Deep Link 仍然不会工作。

2. Android 的差异

Android 常见两种方式:

自定义 Scheme

例如:

myapp://orders/42

优点是配置简单;缺点是其他应用可能注册同一个 Scheme,不能证明链接一定由你的应用处理。

Android App Links

例如:

https://example.com/orders/42

通常需要:

  • Manifest 中配置 intent-filter
  • 域名提供 assetlinks.json
  • 包名和签名证书指纹正确;
  • 路径匹配规则与服务端配置一致。

Manifest 的简化示意:

<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />

    <data
        android:scheme="https"
        android:host="example.com" />
</intent-filter>

这不是完整生产配置。assetlinks.json 中的包名、SHA-256 证书指纹和发布渠道必须对应实际安装包。调试包、正式包和不同 flavor 可能需要不同配置。

3. iOS 的差异

iOS 主要使用:

  • 自定义 URL Scheme;
  • Universal Links。

Universal Links 通常需要:

  • Xcode 中启用 Associated Domains;
  • 配置类似 applinks:example.com 的域名;
  • 网站提供 apple-app-site-association 文件;
  • Team ID、Bundle ID 和路径配置正确。

自定义 Scheme 的冲突风险与 Android 类似。Universal Links 的优势是同一 HTTPS 地址可以在未安装应用时继续落到网页,在已安装并验证成功时交给应用。

4. Web 的差异

Web 中 Deep Link 通常就是浏览器地址栏 URL。例如用户直接访问:

https://example.com/home/orders/42

Flutter Web 应用需要:

  1. go_router 中存在匹配路径;
  2. Web Server 将未知路径回退到 index.html
  3. 服务器或 CDN 不要直接返回 404。

否则浏览器刷新时,请求会先到服务器,而不是先到 Flutter 路由。服务器若找不到物理文件 /home/orders/42,应用根本没有机会运行。

Web 还涉及 URL 策略:

  • 使用 path URL 时地址清晰,但需要服务器 fallback;
  • 使用 hash URL 时通常不需要服务器对任意路径做 fallback,但 URL 会包含 #

浏览器的前进、后退和刷新都依赖 URL 与路由状态同步。不要只在内存中保存页面位置,然后期望刷新后自动恢复。

5. 桌面端的差异

桌面端通常没有 Android Intent 或 iOS Universal Links 的统一系统入口。Deep Link 可能来自:

  • 命令行参数;
  • 自定义协议注册;
  • 文件关联;
  • 单实例应用的进程间通信。

因此桌面应用除了路由匹配,还要处理“已有进程收到新 URI”的问题:第二次启动可能需要把 URI 发送给已经运行的第一个实例,而不是创建第二个窗口。这个行为不由 go_router 单独保证,通常需要平台插件或原生代码配合。


七、路由错误、404 与数据加载失败

1. 未匹配路径不等于业务加载失败

下面两种错误需要分开:

/path-does-not-exist

这是路由匹配失败,应该显示 404 或错误页。

/orders/42

路径匹配成功,但请求订单 42 时服务端返回 404,这是业务数据加载失败。

前者由 errorBuildererrorPageBuilder 处理:

final router = GoRouter(
  errorBuilder: (context, state) {
    return ErrorPage(error: state.error);
  },
);

后者应由订单详情页面中的数据层处理,不能把所有异常都误判为路由不存在。

2. builderpageBuilder

builder 返回页面内容:

GoRoute(
  path: '/home',
  builder: (context, state) => const HomePage(),
)

pageBuilder 返回完整的 Page,用于控制:

  • 页面 Key;
  • 页面恢复 ID;
  • 转场动画;
  • 全屏对话框;
  • 特定平台页面类型。

例如自定义淡入效果:

GoRoute(
  path: '/fade',
  pageBuilder: (context, state) {
    return CustomTransitionPage<void>(
      key: state.pageKey,
      child: const HomePage(),
      transitionsBuilder: (context, animation, secondaryAnimation, child) {
        return FadeTransition(
          opacity: animation,
          child: child,
        );
      },
    );
  },
)

页面 Key 很重要。路由从 /orders/42 变为 /orders/43 时,框架需要知道这是同一页面类型但不同路由状态,还是应该复用已有 State。state.pageKeygo_router 为当前匹配项提供的常用 key 来源。

3. 路由级异常处理的边界

errorBuilder 适合:

  • 路由不存在;
  • 路由配置或匹配阶段异常;
  • 页面构建过程中被路由系统捕获的错误。

生产环境还应记录:

  • 原始 URI;
  • 重定向前后的 URI;
  • 平台;
  • 当前认证状态;
  • 异常堆栈。

开发阶段可以启用:

debugLogDiagnostics: true,

它会输出路由匹配和导航诊断日志,有助于确认:

  • 实际匹配了哪条路由;
  • 重定向执行了几次;
  • 当前页面属于哪个 Shell;
  • gopush 后的路由状态是什么。

不要在日志中记录完整 Token、密码或包含敏感信息的查询参数。


八、恢复:重新创建页面与恢复页面状态不是一回事

标题中的“恢复”至少包含三个不同概念。

1. 路由恢复

路由恢复指应用重新启动或重新创建后,能够恢复到某个 URI,例如:

/home/orders/42?tab=items

在 Web 中,地址栏本身就是最主要的恢复载体。刷新页面后,浏览器再次把该 URL 提供给 Flutter。

在移动端,系统可能在应用进程被杀死后保存部分导航状态,但恢复行为取决于平台、引擎、应用配置和页面是否支持状态恢复。不能把“最近一次页面位置”当成所有平台都必然存在的持久化数据。

2. Navigator 状态恢复

go_router 提供与 Flutter Restoration 体系集成的配置入口,例如:

final router = GoRouter(
  restorationScopeId: 'app-router',
  routes: [
    GoRoute(
      path: '/home',
      builder: (context, state) => const HomePage(),
    ),
  ],
);

应用入口仍然使用:

MaterialApp.router(
  routerConfig: router,
)

restorationScopeId 的作用是让路由使用 Flutter 的状态恢复作用域。它不是普通字符串参数,也不是把任意业务对象自动写入磁盘。

3. 页面内部状态恢复

路由恢复只能恢复“在哪个路由”。例如它可能恢复到:

/orders/42

但不会自动知道:

  • 列表滚动到了第几项;
  • 文本框输入了什么;
  • 当前展开了哪个 ExpansionTile;
  • 网络请求是否已经完成;
  • 内存中的订单对象是什么。

这些状态需要使用 Flutter Restoration API,例如 RestorableTextEditingControllerRestorableBoolRestorableScrollController 等适合的可恢复对象,并且页面需要位于正确的 Restoration Scope 中。

示例:

class SearchPage extends StatefulWidget {
  const SearchPage({super.key});

  @override
  State<SearchPage> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage>
    with RestorationMixin {
  final RestorableTextEditingController query =
      RestorableTextEditingController();

  @override
  String? get restorationId => 'search-page';

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(query, 'query');
  }

  @override
  void dispose() {
    query.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: TextField(
        controller: query.value,
      ),
    );
  }
}

这里的层次关系是:

GoRouter restorationScopeId
    ↓
Navigator / 路由状态恢复
    ↓
页面 restorationId
    ↓
RestorableProperty 恢复具体控件状态

缺少最后一层时,恢复的可能只是页面位置,而不是页面内部输入。

4. 恢复与持久化业务数据无关

恢复机制不适合替代:

  • 登录 Token 持久化;
  • 购物车数据库;
  • 草稿存储;
  • 离线缓存;
  • 服务端会话。

例如应用恢复到 /orders/42,但订单已被删除,页面仍必须重新请求数据并处理 404。恢复的是导航意图,不是对业务数据当前有效性的保证。

5. 恢复失败的典型路径

以下情况都可能导致恢复结果不同:

  1. 恢复的 URI 已经不再被路由树匹配;
  2. 用户权限变化,原页面现在必须重定向;
  3. 目标资源被删除;
  4. 应用版本升级后路由路径发生变化;
  5. 页面使用了不可恢复的临时状态;
  6. Web Server 没有配置 fallback;
  7. 移动端系统根本没有保留应用进程或恢复数据。

因此,恢复流程仍应经过正常的路由匹配和重定向,而不是无条件相信旧页面位置:

恢复旧 URI
    ↓
检查当前版本路由是否支持
    ↓
检查当前认证与权限
    ↓
加载最新业务数据
    ↓
成功显示,失败显示可解释的错误或迁移页面

如果旧路径需要迁移,可以配置局部重定向:

GoRoute(
  path: '/old-order/:id',
  redirect: (context, state) {
    final id = state.pathParameters['id']!;
    return '/home/orders/$id';
  },
)

九、页面生命周期与状态边界

1. 路由变化会导致哪些对象变化

当 URI 从:

/home/orders/42

变为:

/home/orders/43

通常会发生:

  1. 当前 URI 改变;
  2. go_router 重新匹配;
  3. state.pathParameters['orderId']42 变为 43
  4. 页面 Key 或页面配置可能变化;
  5. Flutter 根据 Widget、Element 和 State 的身份规则决定复用还是重建;
  6. 页面数据层必须响应订单 ID 变化。

不要只在 initState 中加载一次订单,然后假设同一个页面 State 永远只对应一个 ID。更稳妥的方式是让页面根据 ID 建立可更新的数据输入,或在 didUpdateWidget 中处理参数变化。

2. 参数应尽量保持可序列化

Deep Link、浏览器历史和恢复都依赖 URI。适合放进路径或查询参数的是:

  • 字符串;
  • 数字;
  • 枚举的稳定字符串表示;
  • 日期的标准化字符串;
  • 可编码的筛选条件。

不适合直接塞进 URI 的是:

  • 大型对象;
  • 闭包;
  • BuildContext;
  • 数据库连接;
  • 临时 UI Controller。

如果需要传复杂对象,可以传资源 ID,在目标页面重新加载对象:

/orders/42

而不是试图把整个订单对象编码进 URL。这样 Deep Link、刷新和恢复都更可靠。


十、常见误解与失败表现

误解一:go_router 自动完成所有平台 Deep Link 配置

事实是:

go_router 路由树

只负责 Flutter 内部的 URI 解析和页面构建。Android Intent、iOS Universal Links、Web Server fallback、桌面协议注册仍需平台配置。

失败表现包括:

  • Android 点击链接只打开浏览器;
  • iOS 已安装应用却没有唤起;
  • Web 首次进入可以,刷新后 404;
  • 桌面端第二次打开链接启动了第二个进程。

诊断时应先确认操作系统是否把正确 URI 交给应用,再确认 debugLogDiagnostics 中的匹配结果。

误解二:调用 context.go 后还必须手动 push

context.go 已经改变了声明式路由状态。若随后再手动 push,可能得到重复页面或错误历史。

例如:

context.go('/home');
context.push('/home');

这会让导航历史与期望的当前 URI 不一致。应先明确自己要的是“切换到目标状态”,还是“在当前状态上追加一个页面”。

误解三:修改认证对象就会自动触发重定向

只有当对象被注册为 refreshListenable,并且确实发出通知时,路由才会重新评估。

以下代码不会触发路由刷新:

auth._isLoggedIn = true;

即使这段代码因访问权限无法编译,问题本质仍是:业务状态变化和路由刷新之间必须建立明确连接。

误解四:Shell 会自动保存所有 Tab 的导航栈

普通 ShellRoute 主要提供共享外壳,不自动等价于每个 Tab 一个独立的持久栈。需要分支导航状态时,应使用 StatefulShellRoute 并明确设计分支行为。

误解五:恢复到某个 URI 就等于恢复到原来的页面

恢复 URI 后,页面仍可能:

  • 需要重新请求数据;
  • 因权限变化被重定向;
  • 因资源不存在显示错误;
  • 丢失未注册的控件状态。

恢复是重新建立应用状态的过程,不是把旧内存快照无条件复制回来。


十一、可测试的路由设计

路由规则最好能在不启动完整 UI 的情况下验证。至少应覆盖以下输入:

输入状态 URI 预期结果
未登录 /home /login?from=/home
未登录 /login 保持 /login
已登录 /login?from=/home/orders/42 /home/orders/42
已登录 /home/orders/42?tab=items 匹配订单详情,参数为 42items
任意状态 /unknown 错误页或 404
已登录 /legacy/42 迁移到新路径

对于重定向,尤其要测试固定点和循环:

redirect(redirect(U)) = redirect(U)

这里的直觉是:当 URI 已经达到稳定状态时,再次评估不应继续改变它。

还应测试:

  • 用户在登录页点击返回;
  • Token 过期时当前页面如何处理;
  • 外部 Deep Link 在未登录状态下是否保留完整目标;
  • 浏览器刷新动态路径是否可用;
  • Android 和 iOS 冷启动、热启动、已有进程收到链接的区别;
  • Shell 子页面是否应该覆盖底部导航;
  • Tab 切换后是否保留各自栈。

十二、选择路由结构时的取舍

可以按页面关系选择结构:

单层页面切换
    → GoRoute

一组页面共享 AppShell
    → ShellRoute

多个 Tab 各自保留独立导航栈
    → StatefulShellRoute

详情页需要覆盖 Shell
    → parentNavigatorKey 指向根 Navigator

认证、权限、旧路径迁移
    → redirect

页面内部滚动位置、输入内容恢复
    → Flutter Restoration API

这些能力可以组合,但不能互相替代:

  • redirect 不能代替权限页面;
  • ShellRoute 不能代替状态管理;
  • restorationScopeId 不能代替数据库持久化;
  • go_router 不能代替 Android、iOS 和 Web 平台链接配置;
  • Deep Link 不能保证目标业务资源永远存在。

一个稳定的路由系统,最终应保持以下因果链清晰:

外部 URI 或用户操作
    ↓
GoRouter 当前 URI
    ↓
全局与局部 redirect
    ↓
路由树匹配
    ↓
Shell / Navigator 层级
    ↓
页面构建与数据加载
    ↓
平台返回、浏览器历史和状态恢复

当出现“页面打不开”“登录后回不到原页面”“底部导航状态丢失”或“刷新后 404”时,沿着这条链逐层检查,通常比直接修改某个 push 调用更容易定位根因。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。