《深度链接与路由进阶:从内部导航到跨端唤起》

Flutter 路由与深度链接进阶:Navigator 2.0 声明式设计、GoRouter 深度集成、Web URL 路径策略、Android App Links 与 iOS Universal Links、参数状态恢复与安全校验。

开篇:当一条短信里的链接要直接打开你的应用时

当用户收到一条营销短信,点击其中的链接期望直接落到商品的详情页,而不是打开应用首页再手动搜索时,深度链接(Deep Link)就在此刻发挥作用了。

  • 邮件/短信里的链接要在冷启动时直达目标页面
  • Web 端刷新浏览器地址栏后要恢复同样的页面状态
  • 分享出去的链接要能被未安装应用的新用户导向下载页
  • 深链参数要能被校验、鉴权,而不是被恶意构造利用

深度链接的本质是"把 URL 变成应用状态的可寻址表达"。本文将带你从 Navigator 2.0 的声明式设计出发,经由 GoRouter 的工程化封装,走通 Web URL 策略、Android/iOS 跨端唤起、参数状态恢复与安全校验的完整链路,让你的应用真正做到"一链直达、处处可恢复"。


一、Navigator 2.0 与 Router API 再探

1.1 从命令式到声明式

Navigator 1.0 用 push/pop 命令式地修改路由栈,路由状态无法被外部观察;Navigator 2.0 把路由当作可序列化的状态:

// 命令式:状态藏在 Navigator 内部
Navigator.push(context, MaterialPageRoute(builder: (_) => DetailPage()));

// 声明式:路由由页面列表推导,状态可导出/恢复
Navigator(
  pages: [
    const MaterialPage(child: HomePage()),
    if (_selectedId != null) MaterialPage(child: DetailPage(id: _selectedId!)),
  ],
  onPopPage: (route, result) { ... },
)

1.2 四大组件协同

组件职责对应问题
RouteInformationParserURL → 应用路由状态解析深链
RouterDelegate路由状态 → 页面列表构建导航栈
RouteInformationProvider平台 URL 变化 → 状态监听系统深链
BackButtonDispatcher系统返回键 → 路由处理返回
class MyRouterDelegate extends RouterDelegate<AppState>
    with ChangeNotifier {
  // 监听平台 URL 变化
  @override
  Future<void> setNewRoutePath(AppState state) async {
    _state = state;
    notifyListeners();
  }
}

1.3 为什么最终选择 GoRouter

裸写 Router API 需要约 200 行样板。GoRouter 把 Router API 封装成"路由表 + 重定向 + 守卫"的声明式配置,同时保留深链、URL 同步等全部能力,因此成为官方推荐的默认路由方案。

一句话总结:Navigator 2.0 提供了"路由即状态"的理论基础,GoRouter 则把这份理论变成了可维护的工程实现。


二、声明式路由与 GoRouter 深度

2.1 路由表与嵌套 ShellRoute

final router = GoRouter(
  initialLocation: '/',
  routes: [
    ShellRoute(
      builder: (context, state, child) => AppScaffold(child: child),
      routes: [
        GoRoute(path: '/', builder: (_, __) => HomePage()),
        GoRoute(path: '/orders', builder: (_, __) => OrdersPage()),
      ],
    ),
    GoRoute(
      path: '/product/:id',
      builder: (context, state) =>
          ProductDetail(id: state.pathParameters['id']!),
    ),
  ],
);

2.2 状态驱动导航

GoRouter 的 refreshListenable 让路由表响应外部状态变化:

final router = GoRouter(
  refreshListenable: authProvider,
  redirect: (context, state) {
    final loggedIn = authProvider.isLoggedIn;
    final needAuth = state.matchedLocation.startsWith('/account');
    if (needAuth && !loggedIn) return '/login';
    return null;
  },
);

2.3 类型安全路由生成

// 用 go_router_builder 生成类型安全的深链参数
@TypedGoRoute<ProductDetailRoute>(path: '/product/:id')
class ProductDetailRoute extends GoRouteData {
  final String id;
  const ProductDetailRoute({required this.id});

  @override
  Widget build(BuildContext context, GoRouterState state) =>
      ProductDetail(id: id);
}

// 深链到达后可以类型安全地拿参数
final route = ProductDetailRoute.fromState(state);
print(route.id);

一句话总结:声明式路由的精髓是"URL 唯一决定页面栈",ShellRoute 处理嵌套布局、守卫处理鉴权、代码生成保证参数类型安全。


三、Web URL 与路径策略

3.1 path 与 hash 策略

Flutter Web 默认 path 策略(干净 URL),也可切换 hash 策略:

// 全局配置:hash 策略(对静态托管更友好)
final router = GoRouter(
  urlPathStrategy: UrlPathStrategy.hash,
  routes: [...],
);
策略URL 示例服务端配置
pathhttps://x.com/product/123需回退到 index.html
hashhttps://x.com/#/product/123无需特殊配置

3.2 刷新与 404 处理

// 刷新深链后页面必须能重建状态
GoRoute(
  path: '/product/:id',
  builder: (context, state) =>
      ProductDetail(id: state.pathParameters['id']!),
);

// 兜底未知路由
final router = GoRouter(
  errorBuilder: (context, state) => NotFoundPage(path: state.uri.toString()),
);

3.3 部署配置

# Nginx:所有请求回退到 index.html
location / {
  try_files $uri $uri/ /index.html;
}

一句话总结:Web 端深度链接的成败一半在路由配置,一半在服务端回退规则——hash 策略能绕开后者但牺牲 SEO。


<!-- AndroidManifest.xml -->
<activity
  android:name=".MainActivity"
  android:launchMode="singleTop"
  android:exported="true">

  <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>
</activity>
  • autoVerify="true" 让系统校验域名下的 assetlinks.json
  • 校验文件放在 https://example.com/.well-known/assetlinks.json
<!-- ios/Runner/Runner.entitlements -->
<key>com.apple.developer.associated-domains</key>
<array>
  <string>applinks:example.com</string>
</array>
  • 服务器需提供 https://example.com/.well-known/apple-app-site-association
  • 用户已安装 App 时优先唤起 App,否则回退 Safari

4.3 冷启动与热启动处理

// 冷启动:应用完全未运行,深链随初始化事件进入
// 热启动:应用已在后台,深链走 onLink 回调
final sub = appLinks.linkStream.listen((Uri uri) {
  router.go(uri.path); // 统一交给 GoRouter 处理
});

// app_links 包会自动区分冷/热启动并合并进同一流

一句话总结:跨端唤起分三段——原生侧注册(intent-filter / associated-domains)、服务端验证文件、Dart 侧统一收口进路由。


五、参数与状态恢复

5.1 路由参数解析

// 路径参数 + 查询参数 + 任意扩展对象
GoRoute(
  path: '/search/:query',
  builder: (context, state) {
    final query = state.pathParameters['query']!;
    final page = int.tryParse(state.uri.queryParameters['page'] ?? '1') ?? 1;
    final filter = state.extra as SearchFilter?; // 复杂对象走 extra
    return SearchPage(query: query, page: page, filter: filter);
  },
);

5.2 Restoration 状态恢复

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
      restorationScopeId: 'app', // 让系统记录导航栈
    );
  }
}

// 页面内用 RestorableInt 等保存可恢复状态
class ProductDetail extends StatefulWidget { ... }
class _ProductDetailState extends State<ProductDetail> with RestorationMixin {
  final RestorableInt _scrollOffset = RestorableInt(0);
  @override
  String? get restorationId => 'product_detail';
  ...
}

5.3 返回栈与导航栈管理

// 深链进入后控制返回行为
context.push('/product/123');       // 允许返回
context.go('/product/123');         // 替换当前页,深链落地常用
context.go('/checkout', extra: cart); // 清栈进入新流程

一句话总结:参数是深链的"输入",Restoration 是深链的"记忆"——两者结合才能让"刷新/杀进程/深链进入"后页面状态依旧。


六、安全校验与状态管理联动

6.1 深链白名单与校验

// 只接受来自可信域名的深链
bool isValidDeepLink(Uri uri) {
  const allowedHosts = {'example.com', 'm.example.com'};
  return allowedHosts.contains(uri.host) && uri.scheme == 'https';
}

// 收口处统一过滤
final safeUri = uri.linkStream
    .where(isValidDeepLink)
    .first;

6.2 鉴权守卫与重定向

final router = GoRouter(
  redirect: (context, state) {
    final loggedIn = context.read<SessionCubit>().state.isLoggedIn;
    // 深链目标需要登录 → 重定向到登录页并记录原路径
    if (!loggedIn && state.matchedLocation.startsWith('/private')) {
      return '/login?redirect=${Uri.encodeComponent(state.matchedLocation)}';
    }
    return null;
  },
);

6.3 与状态管理联动

// 深链携带的数据先进状态管理,再驱动 UI
class ProductDetail extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final productId = GoRouterState.of(context).pathParameters['id']!;
    // 交给 Bloc/Provider 加载并缓存商品数据
    return BlocProvider(
      create: (_) => ProductBloc(productId)..add(LoadProduct(productId)),
      child: const ProductView(),
    );
  }
}

一句话总结:安全校验把深链挡在门外,鉴权守卫把用户导向正确流程,状态管理把深链参数变成可被 UI 订阅的数据源。


FAQ

常见问题:冷启动深链经常丢失,怎么排查?

答:多数是初始化时序问题——在 runApp 前提前订阅深链流,或用 app_links 的 getInitialLink() 显式获取冷启动链接,再补上热启动流订阅,最后统一交给 GoRouter。要留意 Android 上 singleTop 是否配置正确。

常见问题:自定义 scheme(myapp://)和 Universal Link 怎么选?

答:自定义 scheme 实现简单但信任度低(易被其他应用抢注),iOS 甚至会阻止未知 scheme;Universal Links / App Links 基于 HTTPS 域名验证,更安全、体验更统一,推荐作为正式方案。

常见问题:深链会被恶意利用吗?

答:会。伪造参数可能导致越权或打开异常页面。务必校验域名白名单、参数边界(类型、长度、范围),并让业务页面二次鉴权,而不是只靠路由守卫。

常见问题:怎么在开发环境调试深链?

答:Android 用 adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/product/123" <package>;iOS 模拟器用 xcrun simctl openurl booted "https://example.com/product/123";Web 直接在地址栏输入 URL。

常见问题:Web 刷新后 404 怎么处理?

答:服务端把所有路由回退到 index.html(Nginx try_files / Firebase rewrites),Dart 侧再由 GoRouter 按 URL 重建页面;不想碰服务端就用 hash 策略。

常见问题:深链与底部导航/ShellRoute 如何协同?

答:ShellRoute 天然支持——深链命中其子路由时,导航栏按当前路径自动高亮对应 Tab,页面栈由 ShellRoute 统一管理,无需额外代码判断。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. 《响应式与自适应布局:从手机到桌面》
  2. 《本地数据库持久化:sqflite、drift 与对象存储》
  3. 《Isolate 与并发:从 compute 到 Isolate 组》