开篇:当一条短信里的链接要直接打开你的应用时
当用户收到一条营销短信,点击其中的链接期望直接落到商品的详情页,而不是打开应用首页再手动搜索时,深度链接(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 四大组件协同
| 组件 | 职责 | 对应问题 |
|---|---|---|
RouteInformationParser | URL → 应用路由状态 | 解析深链 |
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 示例 | 服务端配置 |
|---|---|---|
| path | https://x.com/product/123 | 需回退到 index.html |
| hash | https://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。
四、跨端唤起:Android intent 与 iOS universal link
4.1 Android App Links
<!-- 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
4.2 iOS Universal Links
<!-- 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 导航与路由管理 — Navigator 1.0/2.0 与 GoRouter 基础
- Flutter 状态管理 — 深链数据进入 UI 状态的衔接
- Flutter Web 与桌面端 — Web URL 策略与部署
- Flutter Widget 与布局 — 页面结构组件基础
- Flutter 性能优化 — 深链落地页的首帧体验
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。