背景
用户从微信、短信、浏览器等外部场景点击一个链接,直接打开 App 并跳转到指定页面。这涉及DeepLink(深度链接):一个 URL 映射到 App 内的一个具体路由。
整体流程
1 2 3 4 5 6 7 8 9 10 11
| 用户在 H5/浏览器中点击链接 ↓ https://myapp.com/open?page=/user/123 ↓ 操作系统拦截 → 唤起 App ↓ app_links 包捕获 URL ↓ 解析参数,提取页面路径和参数 ↓ GoRouter / Navigator 跳转到目标页面
|
第一步:原生平台配置
Android
android/app/src/main/AndroidManifest.xml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <activity android:name=".MainActivity" android:launchMode="singleTop"> <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="myapp" /> </intent-filter>
<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="myapp.com" android:pathPrefix="/app" /> </intent-filter> </activity>
|
autoVerify="true" 表示系统会自动验证你的域名所有权(需要 myapp.com/.well-known/assetlinks.json),验证通过后 HTTPS 链接会直接唤起 App,不弹选择框。
iOS
ios/Runner/Info.plist:
1 2 3 4 5 6 7 8 9 10 11
| <key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLName</key> <string>com.example.myapp</string> <key>CFBundleURLSchemes</key> <array> <string>myapp</string> </array> </dict> </array>
|
iOS Universal Links 还需要在开发者后台配置 Associated Domains,在 Xcode 中添加 applinks:myapp.com。
第二步:安装依赖
1
| flutter pub add app_links go_router
|
| 包 |
用途 |
app_links |
捕获外部链接(冷启动 + 热启动) |
go_router |
声明式路由,支持 URL 路径导航 |
第三步:解析 URL 并跳转
核心服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| import 'dart:async'; import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart';
class DeepLinkService { static final AppLinks _appLinks = AppLinks(); static StreamSubscription<Uri>? _linkSubscription;
static Future<void> initialize(BuildContext context) async { _linkSubscription = _appLinks.uriLinkStream.listen( (Uri uri) => _handleDeepLink(context, uri, fromColdStart: false), );
final Uri? initialUri = await _appLinks.getInitialLink(); if (initialUri != null) { _handleDeepLink(context, initialUri, fromColdStart: true); } }
static void dispose() { _linkSubscription?.cancel(); _linkSubscription = null; } }
|
URL 参数解析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
|
static String? parseNavigationTarget(Uri uri) { final Map<String, String> query = uri.queryParameters;
final String? page = query['page']; if (page != null && page.isNotEmpty) { return page; }
if (uri.pathSegments.isNotEmpty) { final segments = uri.pathSegments.toList(); if (segments.first == 'app' || segments.first == 'open') { segments.removeAt(0); } if (segments.isNotEmpty) { return '/${segments.join('/')}'; } }
return null; }
static Map<String, String> parseExtraParams(Uri uri) { final Map<String, String> params = Map.from(uri.queryParameters); params.remove('page'); return params; }
|
导航执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| static Future<void> _handleDeepLink( BuildContext context, Uri uri, { required bool fromColdStart, }) async { final String? target = parseNavigationTarget(uri); if (target == null) { debugPrint('[DeepLink] 无法解析路径: $uri'); return; }
final Map<String, String> extra = parseExtraParams(uri);
String location = target; if (extra.isNotEmpty) { location = Uri(path: target, queryParameters: extra).toString(); }
debugPrint('[DeepLink] 跳转至: $location (冷启动: $fromColdStart)');
if (fromColdStart) { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) { context.go(location); } }); } else { Future.delayed(const Duration(milliseconds: 500), () { if (context.mounted) { context.push(location); } }); } }
|
冷启动 vs 热启动:
| 场景 |
方法 |
特点 |
| 冷启动 |
getInitialLink() |
App 完全被杀后唤起,需等 postFrameCallback 确保路由就绪 |
| 热启动 |
uriLinkStream |
App 在后台,链接通过 stream 推送,建议延迟 500ms 再跳 |
GoRouter 路由表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import 'package:go_router/go_router.dart';
final appRouter = GoRouter( initialLocation: '/', routes: [ GoRoute(path: '/', builder: (_, __) => const HomePage()), GoRoute( path: '/user/:userId', builder: (_, state) => UserPage( userId: state.pathParameters['userId']!, from: state.uri.queryParameters['from'], ), ), GoRoute( path: '/blog/:blogId', builder: (_, state) => BlogDetailPage( blogId: state.pathParameters['blogId']!, ), ), ], );
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| import 'package:flutter/material.dart';
class App extends StatefulWidget { @override State<App> createState() => _AppState(); }
class _AppState extends State<App> with WidgetsBindingObserver { @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) { DeepLinkService.initialize(context); }); }
@override void dispose() { WidgetsBinding.instance.removeObserver(this); DeepLinkService.dispose(); super.dispose(); }
@override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _checkLatestLink(); } }
Future<void> _checkLatestLink() async { final Uri? uri = await AppLinks().getLatestLink(); if (uri != null) { DeepLinkService._handleDeepLink(context, uri, fromColdStart: false); } }
@override Widget build(BuildContext context) { return MaterialApp.router( routerConfig: appRouter, ); } }
|
支持的 URL 格式总结
| URL 格式 |
解析结果 |
myapp://open?page=/user/123 |
→ /user/123 |
myapp://open?page=/blog/456&from=share |
→ /blog/456?from=share |
https://myapp.com/app/user/789 |
→ /user/789 |
myapp://user/123?from=h5 |
→ /user/123?from=h5 |
踩坑
1. 热启动跳转被吞
热启动(App 在后台)时收到的链接,uriLinkStream 触发时机可能在页面渲染之前。过早执行 context.go() 会被框架吞掉。
解决:延迟 300-500ms,或放在 addPostFrameCallback 中执行跳转。
2. Android App Links 不生效
HTTPS 链接仍然弹出浏览器选择框,没有直接唤起 App。
排查清单:
assetlinks.json 是否正确部署在 https://myapp.com/.well-known/assetlinks.json
- SHA256 指纹是否匹配 release 签名
autoVerify="true" 是否在正确的 intent-filter 上
- 首次安装后需要等待系统验证(通常几分钟)
3. 冷启动 GoRouter 未就绪
冷启动时 getInitialLink() 返回的 URI 可能在 GoRouter 初始化之前。使用 SchedulerBinding.addPostFrameCallback 确保在下一帧再跳转。
参考