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

Flutter 导航与路由:Navigator、Router、Deep Link 和返回栈

Flutter 中的“页面跳转”至少涉及四个不同层次:

  • Navigator:维护一组 Route,执行入栈、出栈和返回结果。
  • Router:把外部路由信息,例如 Web URL、系统 Deep Link,转换成应用状态,再根据状态生成页面。
  • Deep Link:从应用外部直接打开应用内部某个资源或页面的链接。
  • 返回栈:用户进入页面后,应用按照什么顺序返回,以及系统返回、浏览器返回和页面内返回如何保持一致。

它们不是四套互相替代的机制。通常可以这样理解:

外部 URL / 系统返回事件
          │
          ▼
       Router
          │  将 URL 转为应用路由状态
          ▼
     Navigator
          │  将页面状态变成 Route/Page
          ▼
     页面树与返回栈

对于简单应用,直接使用 Navigator 就足够;对于需要支持 Deep Link、Web 浏览器前进后退、多窗口或复杂嵌套路由的应用,需要理解 Router 以及声明式页面栈。


一、先区分 Route、Navigator、Page 和 Router

1. Route 是一次导航中的运行时对象

Route<T> 表示 Navigator 管理的一个运行时页面单元。T 是该 Route 出栈时返回给调用方的结果类型。

常见 Route 包括:

  • MaterialPageRoute<T>:Material 风格的普通页面。
  • CupertinoPageRoute<T>:iOS 风格的页面。
  • PopupRoute<T>:对话框、菜单等弹出层的基础类型。
  • PageRouteBuilder<T>:自定义转场。
  • DialogRoute<T>:对话框 Route。

一个 Route 通常负责:

  1. 保存页面对应的 Widget;
  2. 管理进入和离开的动画;
  3. 参与返回操作;
  4. 决定是否遮挡下面的 Route;
  5. 在出栈时传回结果。

因此,下面的代码不是直接“把 Widget 放进栈”,而是创建了一个 MaterialPageRoute,再把它交给 Navigator:

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

2. Navigator 是 Route 的栈管理器

Navigator 的核心状态可以抽象为一个有序栈:

S = [RouteA, RouteB, RouteC]

栈顶是当前可见页面。主要操作如下:

操作 栈变化 含义
push(R) S → S + [R] 进入新页面
pop(result) [..., R] → [...] 移除栈顶并返回结果
pushReplacement(R) [..., A] → [..., R] 用新页面替换栈顶
popUntil(predicate) 移除若干栈顶 Route 返回到满足条件的页面
pushAndRemoveUntil 先入栈,再批量移除 常用于登录完成或重置流程

例如:

final selectedId = await Navigator.of(context).push<int>(
  MaterialPageRoute(
    builder: (_) => const ProductPickerPage(),
  ),
);

if (!context.mounted) {
  return;
}

if (selectedId != null) {
  debugPrint('选择了商品:$selectedId');
}

目标页面返回:

Navigator.of(context).pop(42);

这里的因果关系是:

  1. push<int> 返回一个 Future<int?>
  2. 新 Route 成为栈顶;
  3. 用户在新页面执行 pop(42)
  4. 新 Route 从栈中移除;
  5. 原来的 Future 完成,值为 42
  6. 调用方继续执行。

如果用户直接返回而没有结果,Future 通常完成为 null,所以调用方必须区分“未选择”和“选择了空值”这类业务语义。

3. Page 是声明式导航中的页面描述

Page 不是 Route 本身,而是对“页面应该存在于栈中”的描述。Navigator 会根据 pages 列表创建、复用或移除对应的 Route。

Navigator(
  pages: const [
    MaterialPage(
      key: ValueKey('home'),
      child: HomePage(),
    ),
    MaterialPage(
      key: ValueKey('detail'),
      child: DetailPage(),
    ),
  ],
)

这里表达的是:

当前栈应该是 [HomePage, DetailPage]

而不是“立即调用 push 两次”。

Pagekey 很重要。Navigator 使用页面身份和顺序比较新旧页面列表。若页面身份不稳定,可能导致:

  • 页面 State 被错误复用;
  • 页面 State 被意外销毁;
  • 转场方向不符合预期;
  • 返回后看到错误的旧数据。

这与 StatefulWidgetKey 和更新边界直接相关:路由状态决定页面是否存在,Page 的 key 决定同一位置上的页面是否被视为同一个实体。

4. Router 负责把路由信息与应用状态同步

Router 主要解决的是“外部路由状态如何进入应用,以及应用状态如何回写外部路由”的问题。

它通常包含这些组件:

  • RouteInformationProvider:提供路由信息,例如当前 URL。
  • RouteInformationParser<T>:把 RouteInformation 解析成应用配置类型 T
  • RouterDelegate<T>:根据配置生成页面,并响应应用状态变化。
  • BackButtonDispatcher:把系统返回事件分发给对应 Router。

可以把 Router 的数据流表示为:

URL / 系统 Deep Link
        │
        ▼
RouteInformationParser
        │
        ▼
应用路由配置 T
        │
        ▼
RouterDelegate.setNewRoutePath
        │
        ▼
应用路由状态
        │
        ▼
Navigator.pages

反向同步则是:

用户在应用内导航
        │
        ▼
RouterDelegate 内部状态改变
        │
        ▼
notifyListeners()
        │
        ▼
Router 读取 currentConfiguration
        │
        ▼
浏览器地址栏或系统路由状态更新

因此,Navigator 关注“栈如何执行”,Router 关注“路由状态如何同步”。Router 通常仍然会在内部使用 Navigator,它们不是二选一的两个页面系统。


二、Navigator 的命令式导航

2.1 基本入栈与出栈

一个最小可运行示例:

import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(
    home: HomePage(),
  ));
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('首页')),
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
            final result = await Navigator.of(context).push<String>(
              MaterialPageRoute(
                builder: (_) => const DetailPage(),
              ),
            );

            if (!context.mounted) {
              return;
            }

            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text(result ?? '没有返回结果'),
              ),
            );
          },
          child: const Text('打开详情页'),
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('详情')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).pop('详情页已完成');
          },
          child: const Text('返回并传值'),
        ),
      ),
    );
  }
}

运行后:

  1. 初始栈为 [HomePage]
  2. 点击“打开详情页”后,栈变为 [HomePage, DetailPage]
  3. 点击“返回并传值”后,栈恢复为 [HomePage]
  4. HomePage 中等待的 Future 得到字符串;
  5. HomePage 显示 SnackBar。

BuildContext 必须来自 Navigator 的后代位置。下面的调用可能找不到目标 Navigator:

Navigator.of(context).push(...);

如果这个 context 位于另一个 Navigator 之上,或者位于错误的嵌套层级,就会抛出“找不到 Navigator”的异常。对于需要操作根 Navigator 的场景,可以使用:

final rootNavigator = Navigator.of(context, rootNavigator: true);

但这不是无条件更好的选择。嵌套 Navigator 可能代表登录流程、标签页或 Shell 页面;错误地使用根 Navigator 会绕过局部返回栈。

2.2 替换和清空栈

登录成功后通常不希望用户按返回键回到登录页:

Navigator.of(context).pushAndRemoveUntil(
  MaterialPageRoute(
    builder: (_) => const HomePage(),
  ),
  (route) => false,
);

执行后:

原栈:[LoginPage]
新栈:[HomePage]

(route) => false 表示移除所有旧 Route。

如果只想把当前页面替换掉:

Navigator.of(context).pushReplacement(
  MaterialPageRoute(
    builder: (_) => const HomePage(),
  ),
);

执行前后:

[A, LoginPage] → [A, HomePage]

常见误用是把 push 当成“切换页面”:

Navigator.of(context).push(...);

如果每次切换底部导航标签都 push 一个页面,用户可能得到:

[首页, 搜索, 购物车, 首页, 搜索, ...]

这会导致返回行为与用户预期不符。底部标签通常需要独立的导航栈、IndexedStack,或使用能够表达 Shell 路由的路由架构,而不是无条件 push。

2.3 popUntil 的条件必须稳定

例如返回到首页:

Navigator.of(context).popUntil(
  (route) => route.isFirst,
);

也可以给 Route 设置名称:

MaterialPageRoute(
  settings: const RouteSettings(name: '/home'),
  builder: (_) => const HomePage(),
);

然后:

Navigator.of(context).popUntil(
  ModalRoute.withName('/home'),
);

Route 名称不是页面身份的全部。声明式 Page 列表仍然需要通过稳定的 Key 表达页面实体;名称适合做条件判断,不应被误认为是完整的路由状态模型。


三、返回栈的真实行为

3.1 返回栈不是 Widget 树

Widget 树描述当前构建结果,Navigator 栈描述导航历史,两者相关但不相同。

假设:

栈:[Home, List, Detail]

用户从 Detail 返回 List 时:

  • Detail 对应的 Route 被移除;
  • List 对应的 State 通常仍然存在,因为它原本就在栈中;
  • List 不一定重新执行 initState
  • Listbuild 是否重新执行,取决于状态变化和框架更新;
  • Home 仍然在栈中,不代表它此刻可见。

因此,不能把“页面重新出现”理解为“页面重新创建”。如果页面需要在返回时刷新,应使用明确的结果、状态管理或生命周期策略,而不是假设 initState 会再次执行。

例如:

final changed = await Navigator.of(context).push<bool>(
  MaterialPageRoute(
    builder: (_) => const EditPage(),
  ),
);

if (!context.mounted) {
  return;
}

if (changed == true) {
  await _reload();
}

3.2 异步导航后必须检查 mounted

下面的代码存在生命周期风险:

onPressed: () async {
  await Navigator.of(context).push(...);
  setState(() {
    // 可能已经不安全
  });
}

等待期间,当前页面可能已经被其他流程移除。此时 State 已经不再 mounted,调用 setState 会产生异常或逻辑错误。

正确写法:

onPressed: () async {
  await Navigator.of(context).push(...);

  if (!context.mounted) {
    return;
  }

  setState(() {
    // 当前 State 仍然存在
  });
}

这里的 context.mounted 是对当前 BuildContext 生命周期的检查。它不能解决业务上的竞态条件,例如返回结果已经过期;它只能避免向已经失效的页面提交 UI 更新。

3.3 Android 返回、iOS 手势与 Web 返回不是同一种事件

不同平台的返回来源不同:

  • Android:系统返回按钮或系统返回手势;
  • iOS:导航栏返回按钮或边缘返回手势;
  • Web:浏览器历史记录的后退、前进;
  • 桌面:窗口关闭、键盘快捷键或应用自定义命令。

Navigator 可以处理“当前栈是否能出栈”,但 Web 浏览器还存在浏览器历史;Router 的职责就是让应用内部路由状态与浏览器历史保持同步。

在页面需要阻止返回时,应使用 PopScope

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

  @override
  State<EditPage> createState() => _EditPageState();
}

class _EditPageState extends State<EditPage> {
  bool dirty = true;

  @override
  Widget build(BuildContext context) {
    return PopScope<void>(
      canPop: !dirty,
      onPopInvokedWithResult: (didPop, result) async {
        if (didPop || !dirty) {
          return;
        }

        final shouldDiscard = await showDialog<bool>(
          context: context,
          builder: (context) {
            return AlertDialog(
              title: const Text('放弃修改?'),
              actions: [
                TextButton(
                  onPressed: () => Navigator.pop(context, false),
                  child: const Text('继续编辑'),
                ),
                FilledButton(
                  onPressed: () => Navigator.pop(context, true),
                  child: const Text('放弃'),
                ),
              ],
            );
          },
        );

        if (!context.mounted) {
          return;
        }

        if (shouldDiscard == true) {
          Navigator.of(context).pop();
        }
      },
      child: Scaffold(
        appBar: AppBar(title: const Text('编辑')),
        body: const Center(child: Text('编辑内容')),
      ),
    );
  }
}

关键点有两个:

  1. canPop 表达是否允许这次返回;
  2. onPopInvokedWithResult 是返回尝试发生后的通知,不应把它当作一个可以任意取消返回的旧式回调。

WillPopScope 在当前 Flutter 中属于旧 API,尤其不能正确参与 Android 的预测性返回手势;新代码应优先使用 PopScope。如果页面处于嵌套 Navigator 中,还需要考虑返回事件是否应该由子 Navigator 先消费。


四、嵌套 Navigator 与局部返回栈

嵌套路由常见于:

  • 底部标签页,每个标签保留自己的浏览历史;
  • 登录流程内部的多步页面;
  • 平板或桌面中的左右分栏;
  • 一个页面中的设置向导。

例如,三个标签分别维护自己的栈:

根 Navigator
├── Tab A Navigator: [AHome, ADetail]
├── Tab B Navigator: [BHome]
└── Tab C Navigator: [CHome, CEdit]

用户在 Tab A 的详情页按返回时,应该先变成:

[AHome, ADetail] → [AHome]

而不是直接退出整个应用。

这要求返回事件按层级传播:

  1. 当前子 Navigator 能 pop,则子 Navigator 消费;
  2. 子 Navigator 不能 pop,则交给父 Navigator;
  3. 父 Navigator 也不能 pop,则交给系统或关闭应用。

Router 体系中,BackButtonDispatcher 可以用于组织这种层级关系。实际工程中,复杂嵌套路由通常使用成熟的路由包或自定义 Shell,但无论使用何种工具,返回优先级都应符合上述因果链。

不要只根据当前显示的 Widget 判断返回行为。真正决定返回的是当前生效的 Navigator 以及它所管理的 Route/Page 栈。


五、命令式 Navigator 与声明式 Router 的差异

5.1 命令式模型

命令式导航直接发出动作:

Navigator.of(context).pushNamed('/detail');
Navigator.of(context).pop();

它适合:

  • 页面数量较少;
  • 主要在应用内部点击按钮跳转;
  • 不需要复杂 URL 同步;
  • 导航行为集中在页面交互中。

命令式模型的优势是直观。缺点是路由状态容易散落在各个回调中,外部 URL 很难成为可靠的单一事实来源。

5.2 声明式模型

声明式模型保存应用路由状态:

class AppRouteState {
  final String path;
  final int? productId;

  const AppRouteState({
    required this.path,
    this.productId,
  });
}

然后根据状态生成页面:

final pages = <Page<void>>[
  const MaterialPage(
    key: ValueKey('home'),
    child: HomePage(),
  ),
  if (state.path == '/products/42')
    const MaterialPage(
      key: ValueKey('product-42'),
      child: ProductPage(id: 42),
    ),
];

声明式模型的核心不在于“代码更现代”,而在于它建立了一个可验证的不变量:

页面栈 = f(当前路由状态)

只要路由状态确定,页面栈就应该确定。外部 URL、应用内点击和浏览器后退都可以归约为“改变路由状态”,而不是分别维护多套跳转逻辑。


六、一个可运行的 Router 示例

下面实现一个最小 Router,支持:

  • / 首页;
  • /detail 详情页;
  • 浏览器 URL 或系统路由进入应用;
  • 应用内点击更新路由;
  • Navigator 返回时更新 Router 状态。
import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp.router(
      routerDelegate: AppRouterDelegate(),
      routeInformationParser: AppRouteInformationParser(),
    ),
  );
}

class AppRouteInformationParser
    extends RouteInformationParser<String> {
  @override
  Future<String> parseRouteInformation(
    RouteInformation routeInformation,
  ) async {
    final path = routeInformation.uri.path;

    if (path == '/detail') {
      return '/detail';
    }

    return '/';
  }

  @override
  RouteInformation restoreRouteInformation(String configuration) {
    return RouteInformation(
      uri: Uri.parse(configuration),
    );
  }
}

class AppRouterDelegate extends RouterDelegate<String>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<String> {
  @override
  final GlobalKey<NavigatorState> navigatorKey =
      GlobalKey<NavigatorState>();

  String _path = '/';

  @override
  String get currentConfiguration => _path;

  @override
  Future<void> setNewRoutePath(String configuration) async {
    _path = configuration == '/detail' ? '/detail' : '/';
    notifyListeners();
  }

  void openDetail() {
    if (_path == '/detail') {
      return;
    }

    _path = '/detail';
    notifyListeners();
  }

  void goHome() {
    if (_path == '/') {
      return;
    }

    _path = '/';
    notifyListeners();
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        MaterialPage<void>(
          key: const ValueKey('home'),
          child: HomePage(onOpenDetail: openDetail),
        ),
        if (_path == '/detail')
          const MaterialPage<void>(
            key: ValueKey('detail'),
            child: DetailPage(),
          ),
      ],
      onDidRemovePage: (route, result) {
        if (_path == '/detail') {
          _path = '/';
          notifyListeners();
        }
      },
    );
  }
}

class HomePage extends StatelessWidget {
  final VoidCallback onOpenDetail;

  const HomePage({
    super.key,
    required this.onOpenDetail,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('首页')),
      body: Center(
        child: ElevatedButton(
          onPressed: onOpenDetail,
          child: const Text('打开详情'),
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('详情')),
      body: const Center(
        child: Text('详情内容'),
      ),
    );
  }
}

6.1 初始启动过程

假设应用启动时收到 /detail

1. RouteInformationProvider 提供 /detail
2. AppRouteInformationParser 将其解析为字符串 /detail
3. RouterDelegate.setNewRoutePath('/detail')
4. _path 变为 /detail
5. build 生成 [HomePage, DetailPage]
6. Navigator 显示 DetailPage

假设启动时没有 Deep Link:

1. 解析得到 /
2. _path 为 /
3. pages 只有 HomePage

6.2 应用内点击过程

用户在首页点击“打开详情”:

1. HomePage 调用 onOpenDetail
2. RouterDelegate.openDetail()
3. _path 从 / 变为 /detail
4. notifyListeners()
5. RouterDelegate 重建
6. pages 从 [HomePage] 变为 [HomePage, DetailPage]
7. Navigator 根据 Page 差异创建详情 Route
8. Router 将 currentConfiguration 反向恢复为 /detail

notifyListeners() 是关键。如果只修改 _path 而不通知 Router,页面树不会因为这个状态变化而及时重建。

6.3 用户返回过程

用户在详情页点击系统返回或 AppBar 返回:

1. Navigator 尝试移除 DetailPage 对应的 Route
2. onDidRemovePage 被调用
3. RouterDelegate 将 _path 改回 /
4. notifyListeners()
5. pages 恢复为 [HomePage]
6. 浏览器 URL 或外部路由状态同步为 /

声明式导航中,onDidRemovePage 的职责是把 Navigator 产生的“页面被移除”反映回应用路由状态。若只让 Navigator 出栈,却不更新 _path,下一次 Router 重建时仍可能根据旧状态重新生成详情页,形成“刚返回又出现”的错误。

6.4 代码中的限制

这个示例为了展示机制,把配置简化成了字符串,并且只识别两个路径。生产应用至少需要处理:

  • 路径参数,例如 /products/42
  • 查询参数,例如 /search?q=flutter
  • 未知路径;
  • 登录状态不满足时的重定向;
  • 资源不存在时的 404;
  • 认证恢复和异步初始化;
  • 多层嵌套页面;
  • 页面状态恢复。

路由解析不应把任意字符串直接当作合法业务状态。更稳妥的做法是定义不可变的路由配置类型:

sealed class AppRouteConfig {
  const AppRouteConfig();
}

class HomeRouteConfig extends AppRouteConfig {
  const HomeRouteConfig();
}

class ProductRouteConfig extends AppRouteConfig {
  final int id;

  const ProductRouteConfig(this.id);
}

class NotFoundRouteConfig extends AppRouteConfig {
  const NotFoundRouteConfig();
}

这样可以把“解析失败”“资源不存在”和“首页”区分开,而不是都退化为 /


七、Deep Link 到底是什么

Deep Link 是一个可以直接定位到应用内部资源或页面的外部链接。例如:

https://example.com/products/42
myshop://products/42

它与普通启动的差异在于:应用不是只进入首页,而是需要根据链接恢复目标路由状态。

Deep Link 通常包含三部分:

协议或 scheme:https / myshop
主机:example.com
路径与参数:/products/42

在 Flutter 内部,最终需要把这些外部信息转换成应用能理解的配置:

final uri = Uri.parse('https://example.com/products/42');

print(uri.scheme); // https
print(uri.host);   // example.com
print(uri.path);   // /products/42

不要仅用字符串截取 URL。Uri 能正确处理查询参数、编码和路径结构,例如:

final uri = Uri.parse(
  'https://example.com/search?q=flutter&page=2',
);

final query = uri.queryParameters['q']; // flutter
final page = uri.queryParameters['page']; // 2

7.1 Deep Link 的完整链路

以移动端链接为例:

用户点击链接
   │
   ▼
操作系统判断由哪个应用处理
   │
   ▼
Android Intent / iOS URL 或 Universal Link
   │
   ▼
Flutter 引擎与路由信息通道
   │
   ▼
RouteInformationProvider
   │
   ▼
RouteInformationParser
   │
   ▼
RouterDelegate
   │
   ▼
Navigator.pages
   │
   ▼
目标页面

任何一层配置错误都可能造成不同故障:

  • 操作系统没有把链接交给应用;
  • 应用收到链接,但 Flutter 路由解析为首页;
  • 页面出现,但刷新或返回后 URL 不一致;
  • URL 可以打开应用,但无法定位到正确资源;
  • 未登录时直接打开受保护页面,应用没有定义重定向规则。

八、Android、iOS、桌面和 Web 的差异

8.1 Android

Android 的 Deep Link 通常通过 intent-filter 声明。自定义 scheme 示例:

<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />

        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data
            android:scheme="myshop"
            android:host="products" />
    </intent-filter>
</activity>

实际项目中的 activity 属性应合并到 Flutter 工程现有配置中,不要为了添加 Deep Link 而删除 Flutter 插件、主题或启动配置。

Android App Links 使用 https,还需要域名侧的关联文件和签名配置。仅写 httpsintent-filter 并不等于已经完成可信 App Link;域名验证失败时,系统可能让浏览器打开,或弹出应用选择器。

测试自定义 scheme 可以使用:

adb shell am start \
  -a android.intent.action.VIEW \
  -d "myshop://products/42"

预期结果是:

  1. Android 找到匹配的 Activity;
  2. Flutter 应用启动或恢复;
  3. Flutter 获得对应 URI;
  4. Router 解析出商品 ID 42
  5. 应用显示商品详情页。

如果应用只是打开首页,优先检查三处:

  • AndroidManifest 的 scheme、host、path 是否匹配;
  • Flutter 是否真正收到完整 URI;
  • RouteInformationParser 是否解析了路径参数。

8.2 iOS

iOS 自定义 URL Scheme 通常需要在 Xcode 的 URL Types 中声明。Universal Links 使用 https,还需要:

  • App 的 Associated Domains 能力;
  • applinks:example.com 等域名声明;
  • 网站部署正确的 apple-app-site-association 文件;
  • Bundle ID 和 Team ID 等关联信息正确。

自定义 scheme 的优点是配置相对直接,缺点是 scheme 可能被其他应用抢占,不能提供与 Universal Links 相同的域名归属保证。

Universal Link 打不开应用时,不能只检查 Flutter 代码。系统侧的域名文件、HTTPS、关联能力和设备缓存都可能导致失败。

8.3 Web

Web 中,Deep Link 本质上是浏览器 URL。Flutter 应用需要处理两种问题:

  1. URL 如何传入 Flutter;
  2. 浏览器刷新时,服务器是否仍然返回 Flutter 的入口页面。

如果使用路径 URL,例如:

https://example.com/products/42

服务器必须把未知路径重写到 Flutter Web 的入口 HTML。否则:

  • 应用内点击可以工作;
  • 复制链接后新开页面可能工作;
  • 直接刷新 /products/42 时服务器返回 404。

Flutter Web 还可以使用 hash URL:

https://example.com/#/products/42

Hash URL 通常不要求服务器为每个路径配置回退,但 URL 对搜索引擎、服务器日志和用户体验的表现不同。切换到 path URL 时,应使用 Flutter Web 提供的 URL 策略,并同步配置 Web 服务器;不能只修改客户端代码。

浏览器前进和后退要求 Router 的 currentConfigurationrestoreRouteInformation 一致。若应用内状态改变却没有通知 Router,地址栏不会正确变化;若解析器把多个 URL 都折叠为同一配置,浏览器历史也无法准确恢复页面。

8.4 桌面

Windows、macOS 和 Linux 没有一套由 Flutter 统一封装的跨平台 Deep Link 行为。常见方式包括:

  • 注册自定义协议;
  • 注册文件关联;
  • 平台启动参数;
  • macOS 的 URL Event;
  • Windows 或 Linux 的平台消息与进程参数。

Flutter 层可以复用同一套路由配置和解析逻辑,但“操作系统如何把链接交给应用”仍需平台实现。桌面还要考虑:

  • 应用已经运行时,是新开窗口还是复用现有窗口;
  • 多窗口时链接交给哪个窗口;
  • 同一链接重复打开时是否创建新的页面;
  • 启动参数和运行中平台事件是否使用同一条数据通道。

九、路径参数、查询参数和无效链接

一个实际解析器不能只判断固定路径:

class ParsedRoute {
  final String name;
  final int? productId;
  final String? query;

  const ParsedRoute({
    required this.name,
    this.productId,
    this.query,
  });
}

ParsedRoute parseUri(Uri uri) {
  final segments = uri.pathSegments;

  if (segments.length == 2 &&
      segments[0] == 'products') {
    final id = int.tryParse(segments[1]);

    if (id != null) {
      return ParsedRoute(
        name: 'product',
        productId: id,
      );
    }
  }

  if (uri.path == '/search') {
    return ParsedRoute(
      name: 'search',
      query: uri.queryParameters['q'],
    );
  }

  return const ParsedRoute(name: 'not-found');
}

这里有三个不同结果:

/products/42   → 合法的商品路由,id = 42
/products/abc  → 路径结构看似正确,但 id 无法解析
/unknown       → 未知路由

它们不应全部静默跳转首页。否则用户会看到“链接打开了,但内容不对”,也会让监控无法发现错误。

更合理的行为是:

  • 格式错误:显示 404 或错误页面;
  • 格式正确但资源不存在:显示资源不存在;
  • 需要登录:保存原始目标,完成认证后重定向;
  • 权限不足:显示无权限页面,而不是把用户误导到首页。

重定向必须避免循环。例如:

/protected → /login
/login → /protected

如果登录页又根据旧目标立即跳回受保护页,就会形成循环。路由状态应显式保存认证阶段和待返回目标,而不是通过多个页面回调互相 push。


十、Router 的生命周期与状态边界

10.1 setNewRoutePath 不等于页面构建

RouterDelegate.setNewRoutePath 接收的是解析后的路由配置,不是 Widget。它的职责是更新路由状态:

@override
Future<void> setNewRoutePath(AppRouteConfig configuration) async {
  _configuration = configuration;
  notifyListeners();
}

随后 Router 才会重新构建 delegate,delegate 再生成 Navigator 和页面。

因此不应在解析器中直接操作 BuildContext 或 push 页面:

// 不推荐:解析器不应该直接跳转页面
class BadParser extends RouteInformationParser<String> {
  // 不要在这里寻找 Navigator 并调用 push
}

解析器应该是“外部表示 → 路由配置”的转换层;页面如何生成由 RouterDelegate 决定。

10.2 路由状态不能等同于业务数据

例如:

/product/42

只说明用户想访问商品 42,不代表商品 42 已经从服务器加载成功。正确的数据流通常是:

路由配置 Product(42)
        │
        ▼
Feature 层加载商品 42
        │
   ┌────┴────┐
   ▼         ▼
成功        失败
   │         │
显示详情   显示错误或不存在

路由层应决定“目标资源是谁”,Feature 或数据层决定“资源是否存在、如何加载”。将网络请求、鉴权和页面栈全部写进 RouterDelegate,会让路由状态与业务状态互相污染,也会增加测试和恢复的复杂度。

这与大型应用分层有关:

平台入口 / Deep Link
        ↓
路由解析层
        ↓
应用导航状态
        ↓
Feature 页面
        ↓
领域与数据层

Router 不应成为全局服务定位器。依赖注入、认证状态和资源仓库应通过明确的应用边界传入页面或 Feature。

10.3 异步初始化和导航竞态

应用启动时可能同时发生:

  • 收到 Deep Link;
  • 恢复本地登录状态;
  • 加载远程配置;
  • 恢复页面状态;
  • 用户触发首次点击。

如果路由状态在认证状态确定前就执行,可能出现:

Deep Link /orders/42
    ↓
认证状态未知
    ↓
错误显示登录页
    ↓
认证完成后没有恢复 /orders/42

应把认证状态建模为至少三态:

unknown → signedOut
unknown → signedIn

unknown 阶段显示启动状态或等待配置,而不是把它误判为 signedOut。认证完成后再决定:

signedOut + protected target → login,并保存 target
signedIn + protected target  → 原目标页面
signedIn + public target     → 原目标页面

这是一种状态转换问题,不是简单的 push 顺序问题。


十一、声明式 Page 栈的返回条件

声明式 Navigator 的核心约束可以写成:

P = f(R)

其中:

  • R 是应用当前路由状态;
  • PNavigator.pages 列表;
  • f 是根据路由状态生成页面列表的函数。

假设:

R = Home      → P = [HomePage]
R = Detail    → P = [HomePage, DetailPage]

那么返回操作必须满足:

Detail → Home

而不能只修改 Navigator 的视觉结果,却保留:

R = Detail, P = [HomePage]

因为下一次重建时,f(Detail) 仍然会生成详情页。

因此,onDidRemovePage 中更新路由状态不是形式要求,而是维持不变量 P = f(R) 的必要步骤。

反例:

onDidRemovePage: (route, result) {
  // 什么也不做
}

表现可能是:

  1. 用户第一次返回,详情页暂时消失;
  2. 其他状态触发 Router 重建;
  3. delegate 根据仍然是 /detail 的状态重新生成详情页;
  4. 用户感觉返回失效。

十二、Navigator 1.0 风格的命名路由边界

Flutter 提供命名路由相关 API,例如:

MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (_) => const HomePage(),
    '/detail': (_) => const DetailPage(),
  },
);

调用:

Navigator.of(context).pushNamed('/detail');

它适合非常简单的固定路径。但命名路由表不是完整的路由系统,尤其在以下场景会迅速遇到限制:

  • 路径参数 /products/:id
  • 查询参数;
  • 登录重定向;
  • 嵌套导航;
  • Web URL 同步;
  • 复杂错误页;
  • 多层 Shell;
  • 可测试的路由配置。

onGenerateRoute 可以提供更多控制:

MaterialApp(
  onGenerateRoute: (settings) {
    if (settings.name == '/detail') {
      return MaterialPageRoute(
        settings: settings,
        builder: (_) => const DetailPage(),
      );
    }

    return MaterialPageRoute(
      builder: (_) => const NotFoundPage(),
    );
  },
);

但它仍然主要是命令式 Navigator 的 Route 创建入口。不要因为使用了字符串路径,就认为应用已经拥有 Router 的声明式同步能力。


十三、常见失败表现与诊断方法

13.1 Deep Link 打开应用但总是首页

按层诊断:

  1. 操作系统层:系统是否真的把 URI 交给应用;
  2. Flutter 输入层:应用收到的是完整 URI 还是只有默认 /
  3. 解析层RouteInformationParser 是否识别 path、query 和参数;
  4. 状态层setNewRoutePath 是否更新并通知;
  5. 页面层pages 是否根据新状态包含目标页面;
  6. 数据层:目标资源不存在时是否被错误地回退到首页。

在解析器入口记录:

debugPrint(
  'uri=${routeInformation.uri}',
);

在 delegate 中记录:

debugPrint(
  'new configuration=$configuration',
);

如果第一条日志没有出现,问题不在页面;如果配置正确但页面不变,问题通常在 delegate 状态或 notifyListeners()

13.2 Web 应用内跳转正常,刷新后 404

这通常不是 Flutter Navigator 的问题,而是服务器没有配置 SPA 回退。

验证步骤:

1. 在应用内点击进入 /products/42
2. 复制地址
3. 新开浏览器标签直接访问
4. 观察是服务器返回 404,还是 Flutter 显示 404 页面

两者含义不同:

  • 服务器 404:Web 部署回退配置缺失;
  • Flutter 404 页面:服务器已交给 Flutter,应用路由解析不识别该路径。

13.3 返回后又回到刚才的页面

命令式导航中,可能是重复调用了 push;声明式导航中,常见原因是:

  • Route 出栈后没有更新路由状态;
  • onDidRemovePage 没有处理对应页面;
  • Page key 不稳定导致页面身份比较异常;
  • 外部 URL 又把旧路径写回应用。

需要同时打印:

当前路由配置
Navigator.pages
onDidRemovePage 回调
浏览器地址或收到的外部 URI

只看屏幕现象通常无法区分这几类问题。

13.4 Navigator.of(context) 找不到 Navigator

原因通常是 context 层级错误:

Builder(
  builder: (innerContext) {
    return ElevatedButton(
      onPressed: () {
        Navigator.of(innerContext).push(...);
      },
      child: const Text('打开'),
    );
  },
);

Builder 不是解决所有问题的固定模板,它只是创建了一个位于当前 Widget 树更深处的新 BuildContext。更根本的做法是明确哪个 Navigator 应该负责该操作,以及页面是否应该通过回调或路由状态触发导航。

13.5 页面返回后数据没有刷新

不要依赖 initState。优先使用返回值:

final didSave = await Navigator.of(context).push<bool>(
  MaterialPageRoute(
    builder: (_) => const EditPage(),
  ),
);

if (!context.mounted) {
  return;
}

if (didSave == true) {
  await reload();
}

如果多个页面都可能修改同一实体,则应把实体状态放在共享的 Feature 或状态管理层,由页面订阅状态,而不是让每一层都手动传递刷新信号。


十四、路由状态恢复与返回栈恢复

导航恢复包含两个不同问题:

  1. 恢复当前路由位置:应用重启后知道用户在 /detail
  2. 恢复完整返回栈:不仅知道当前是详情页,还知道前面有哪些页面。

单独保存当前 URL 通常只能恢复第一种:

保存:/detail
恢复:[Home, Detail]

如果用户原来的栈是:

[Home, Search, ProductList, Detail]

仅凭 /detail 无法推导出完整历史。要恢复完整栈,需要把栈结构或可重建的导航状态纳入状态恢复设计。

不过也不应机械地持久化所有 Route。返回栈可能包含:

  • 已过期的临时表单;
  • 不应跨会话恢复的认证页面;
  • 依赖短期内存对象的页面;
  • 已经失效的资源详情。

恢复策略应该按页面语义决定。路由配置应尽量可序列化、可验证,并且不直接保存无法长期使用的对象引用。


十五、生产环境中的取舍

15.1 什么时候直接使用 Navigator

直接使用 Navigator 通常适用于:

  • 小型移动应用;
  • 路由主要由按钮和列表点击触发;
  • 没有复杂 Web URL;
  • 页面栈较浅;
  • 不需要多级嵌套导航。

此时重点是正确处理:

  • push 返回结果;
  • context.mounted
  • 登录后清栈;
  • 嵌套 Navigator;
  • 返回拦截。

15.2 什么时候使用 Router 或路由包

当应用需要以下能力时,Router 或基于 Router 的成熟路由包更合适:

  • 浏览器地址栏与应用状态双向同步;
  • Deep Link;
  • 登录重定向;
  • 嵌套 Shell;
  • 多标签保留独立栈;
  • 路径参数和查询参数;
  • 统一错误页;
  • 路由级鉴权;
  • 多平台一致的路由配置。

路由包可以减少样板代码,但不会消除底层问题。仍然需要理解:

外部路由输入 → 配置解析 → 导航状态 → 页面栈 → 返回事件

否则遇到嵌套返回、刷新 404、重定向循环或页面状态丢失时,只能依赖试错。

15.3 路由层与 Feature 层的边界

一个可维护的边界通常是:

Router:
  解析 URI
  生成路由配置
  处理页面栈
  处理导航级重定向

Feature:
  加载商品、订单、用户数据
  管理页面业务状态
  处理表单和交互

数据层:
  API、数据库、缓存

平台边界:
  Android Intent
  iOS Universal Link
  Web URL
  桌面协议注册

例如 Router 只产生:

ProductRouteConfig(id: 42)

而不是在解析 URL 时直接请求数据库。这样同一份 Feature 页面可以由 Deep Link、列表点击和测试代码共同进入,路由来源不会污染业务逻辑。


十六、总结:用一条状态链理解所有导航问题

可以用下面这条链检查设计是否完整:

外部来源
  ├─ Android Intent
  ├─ iOS URL / Universal Link
  ├─ Web 浏览器 URL
  └─ 应用内点击
          │
          ▼
统一的 URI / 路由配置
          │
          ▼
应用导航状态
          │
          ▼
Navigator 的 Route 或 Page 栈
          │
          ▼
当前页面与返回行为
          │
          └── 返回、前进、Deep Link 再次修改导航状态

其中:

  • Navigator 解决 Route 栈的运行时变化;
  • Router 解决路由状态与外部路由信息的同步;
  • Deep Link 是进入应用导航状态的一种外部输入;
  • 返回栈决定 pop 的顺序,并受到嵌套 Navigator、平台返回事件和页面生命周期的共同影响。

最容易出错的地方,通常不是 push 语法本身,而是状态没有保持一致:URL 是一个页面,Router 状态是另一个页面,Navigator 栈又是第三个页面。无论采用命令式还是声明式实现,都应明确谁是当前路由状态的来源,并让页面栈、外部 URL 和返回行为围绕这个状态保持可验证的一致性。


系列导航与关联阅读

官方资料

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