背景

用户从微信、短信、浏览器等外部场景点击一个链接,直接打开 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">
<!-- 1. 自定义 scheme:myapp:// -->
<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>

<!-- 2. Android App Links(HTTPS) -->
<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';

/// DeepLink 处理服务
class DeepLinkService {
static final AppLinks _appLinks = AppLinks();
static StreamSubscription<Uri>? _linkSubscription;

/// 初始化:监听外部链接
static Future<void> initialize(BuildContext context) async {
// 1. 监听热启动链接(App 在前台/后台时收到的链接)
_linkSubscription = _appLinks.uriLinkStream.listen(
(Uri uri) => _handleDeepLink(context, uri, fromColdStart: false),
);

// 2. 处理冷启动链接(App 被杀死后通过链接启动)
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
/// 从 URL 中提取导航目标
/// 支持的格式:
/// myapp://open?page=/user/123&from=h5
/// https://myapp.com/app/open?page=/blog/456
/// myapp://user/789
static String? parseNavigationTarget(Uri uri) {
final Map<String, String> query = uri.queryParameters;

// 方式 1:query 参数传路径
// myapp://open?page=/user/123
final String? page = query['page'];
if (page != null && page.isNotEmpty) {
return page;
}

// 方式 2:直接从 path 解析
// myapp://user/123 → /user/123
// https://myapp.com/app/user/123 → /user/123
if (uri.pathSegments.isNotEmpty) {
// 去掉前缀(如 /app),返回纯路由路径
final segments = uri.pathSegments.toList();
if (segments.first == 'app' || segments.first == 'open') {
segments.removeAt(0);
}
if (segments.isNotEmpty) {
return '/${segments.join('/')}';
}
}

return null;
}

/// 从 URL query 中提取参数,附加到目标路由
static Map<String, String> parseExtraParams(Uri uri) {
final Map<String, String> params = Map.from(uri.queryParameters);
params.remove('page'); // page 已经被 parseNavigationTarget 消费
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
/// 处理 DeepLink 跳转
static Future<void> _handleDeepLink(
BuildContext context,
Uri uri, {
required bool fromColdStart,
}) async {
// 1. 解析目标路径
final String? target = parseNavigationTarget(uri);
if (target == null) {
debugPrint('[DeepLink] 无法解析路径: $uri');
return;
}

// 2. 解析附带参数
final Map<String, String> extra = parseExtraParams(uri);

// 3. 构造完整路由(将 extra 参数编码进 query)
String location = target;
if (extra.isNotEmpty) {
location = Uri(path: target, queryParameters: extra).toString();
}

debugPrint('[DeepLink] 跳转至: $location (冷启动: $fromColdStart)');

// 4. 冷启动直接跳,热启动稍延迟(等页面恢复稳定)
if (fromColdStart) {
// 冷启动:GoRouter 可能尚未渲染,需要等 Frame 完成
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.go(location);
}
});
} else {
// 热启动:稍作延迟,确保 App 回到前台渲染稳定后再跳
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
// router/app_router.dart
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']!,
),
),
],
);

第四步:在 Widget Tree 中集成

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);

// 在 Frame 完成后初始化 DeepLink,确保 BuildContext 可用
WidgetsBinding.instance.addPostFrameCallback((_) {
DeepLinkService.initialize(context);
});
}

@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
DeepLinkService.dispose();
super.dispose();
}

/// App 回前台时补拉最新链接
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkLatestLink();
}
}

Future<void> _checkLatestLink() async {
// 冷启动时 AppLinks 可能还没就绪,resumed 时再补一次
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 中执行跳转。

HTTPS 链接仍然弹出浏览器选择框,没有直接唤起 App。

排查清单

  • assetlinks.json 是否正确部署在 https://myapp.com/.well-known/assetlinks.json
  • SHA256 指纹是否匹配 release 签名
  • autoVerify="true" 是否在正确的 intent-filter
  • 首次安装后需要等待系统验证(通常几分钟)

3. 冷启动 GoRouter 未就绪

冷启动时 getInitialLink() 返回的 URI 可能在 GoRouter 初始化之前。使用 SchedulerBinding.addPostFrameCallback 确保在下一帧再跳转。

参考