背景

「小雷达」需要支持从 H5 分享页一键唤起 APP 并跳转到指定页面——比如从微信点开博主名片链接,直接打开 APP 进入博主主页。这涉及 Deep Link(深度链接)的完整链路。

整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
H5 页面(微信/浏览器)


https://share.qushe.zone/app/{key}

├─→ 已安装 APP ──→ rada://open?key=xxx
│ │
│ ▼
│ DeepLinkService
│ │
│ ▼
│ /share/getUri API
│ │
│ ▼
│ appRouter.go/push

└─→ 未安装 ──→ 引导下载页

技术选型

选用 app_links 包(v7.1.1),相比老牌 uni_links,它对 Android App Links 和 iOS Universal Links 的支持更现代,且维护活跃。

核心实现

1. 服务初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
abstract final class DeepLinkService {
static final AppLinks _appLinks = AppLinks();
static StreamSubscription<Uri>? _linkSubscription;

static Future<void> initialize() async {
// 监听热启动链接流
_linkSubscription = _appLinks.uriLinkStream.listen(_handleUri);
// 处理冷启动链接
await _loadInitialLink();
// 补拉热启动遗漏
await onAppResumed();
}
}

2. 冷启动 vs 热启动

两种场景分开处理:

场景 链路 处理方式
冷启动 getInitialLink() 直接跳转
热启动 uriLinkStream + getLatestLink() 延迟 550ms 再跳转
1
2
3
4
5
6
7
8
static Future<void> _handleUri(Uri uri, {bool fromColdStart = false}) async {
final String? location = await resolveNavigationLocation(uri);
if (fromColdStart) {
_navigateTo(location);
} else {
_scheduleWarmStartNavigation(location); // 延迟跳转
}
}

热启动延迟的原因:链接到达时 secure_display / 页面栈可能还未稳定,延迟 550ms 确保 push 不会被原生重组冲掉。

3. 链接解析:多格式兼容

支持三种链接格式:

1
2
3
4
5
6
7
8
9
10
11
static Future<String?> resolveNavigationLocation(Uri uri) async {
// 1. 频道直跳:rada://open?type=groupInfo&id=@TGS#...
final String? channelDirect = extractJoinChannelFromUri(uri);

// 2. 路径直跳:rada://open?path=/blogger/123
final String? direct = extractDirectNavigationFromUri(uri);

// 3. Share Key 跳转:https://share.qushe.zone/app/{key}
final String? key = extractShareKeyFromUri(uri);
return _resolveLocationFromKey(key);
}

Scheme 支持:rada://https://share.qushe.zone,且 query/fragment 中的 key 都能正确提取。

4. Share Key 解析

拿到 key 后按优先级解析:

1
2
3
4
5
key
├─ 是 TIM 群组 ID?→ 直跳加入频道页
├─ 包含嵌入式路由 payload?→ 解码直跳
├─ 长度 ≥ 32?→ 不合法,返回 null
└─ 普通短 key → 调 API /share/getUri 获取路由路径
1
2
3
4
5
6
7
8
9
10
static Future<String?> _resolveLocationFromKey(String key) async {
if (isLikelyTimChannelGroupId(key)) {
return AppRoutes.joinChannelLocation(key);
}
final String? embedded = resolveNativeRouteFromEmbeddedShareKey(key);
if (embedded != null) return embedded;
if (key.length >= 32) return null; // 非法 key

return resolveNativeRouteFromShareKey(apiClient: client, key: key);
}

5. ApiClient 未就绪的处理

冷启动时 ApiClient 可能尚未初始化,需要暂存链接:

1
2
3
4
5
6
7
if (fromColdStart && _apiClient == null) {
_pendingUri = uri; // 暂存
return;
}
// ApiClient 就绪后消费
DeepLinkService.bindApiClient(context.read<ApiClient>());
// bindApiClient 内部会 _flushPendingIfAny()

6. 去重与防抖

同一个 URI 在 800ms 内不重复处理:

1
2
3
4
5
6
static const Duration _dedupeWindow = Duration(milliseconds: 800);

static bool _isDuplicateUri(Uri uri) {
if (_lastHandledUri?.toString() != uri.toString()) return false;
return DateTime.now().difference(_lastHandledAt!) < _dedupeWindow;
}

7. 路径规范化

H5 可能对路径多次 encode,需要解码 + 标准化:

1
2
3
4
5
6
7
8
9
10
11
static String canonicalizeDeepLinkLocation(String location) {
String decoded = location.trim();
// 循环解码(最多 4 次)
for (var i = 0; i < 4; i++) {
final String next = Uri.decodeComponent(decoded);
if (next == decoded) break;
decoded = next;
}
if (!decoded.startsWith('/')) decoded = '/$decoded';
return decoded;
}

8. push vs go 策略

根据目标页面类型决定导航方式:

1
2
3
4
5
6
7
8
9
static bool _shouldPushAboveHome(String location) {
return location.startsWith('${AppRoutes.joinChannel}/') ||
location.startsWith('${AppRoutes.bloggerDetail}/') ||
location.startsWith('${AppRoutes.userIndex}/') ||
location.startsWith('${AppRoutes.channelInfo}/');
}

// push: 在 Home 页之上叠加(保留返回栈)
// go: 替换当前路由

9. 热启动补偿重试

push 可能因页面栈不稳而失败,250ms 后检查是否到达目标页面,未到达则重试一次:

1
2
3
4
5
6
7
8
static Future<void> _retryWarmStartNavigationIfNeeded(String path) async {
await Future<void>.delayed(const Duration(milliseconds: 250));
final String current = appRouter.state.uri.path;
// 如果已在目标页面,不重试
if (current.contains(AppRoutes.joinChannel) || ...) return;
// 否则重试 push
_navigateTo(path);
}

iOS 配置

Info.plist 声明自定义 scheme:

1
2
3
4
5
6
7
8
9
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>rada</string>
</array>
</dict>
</array>

完整流程小结

1
2
3
4
5
6
7
8
9
用户点击 H5 分享链接

├─ APP 已安装
│ ├─ 冷启动 → getInitialLink → 解析 key → 调 API → push/go
│ └─ 热启动 → stream 收到 URI → 去重 → 延迟 550ms → push/go
│ │
│ 250ms 后补偿重试

└─ APP 未安装 → 下载页

踩坑总结

  1. 热启动链接丢失uriLinkStream 订阅晚于链接到达时,原生层会暂存到 latestLink,需要 onAppResumed 主动补拉
  2. 初始化顺序:必须在 runApp 之后初始化,否则 getInitialLink 阻塞启动页
  3. 页面栈冲突:secure_display 的重组可能导致 push 被覆盖,延迟 + 重试双保险
  4. 多次 encode:H5 可能对路径 encode 多次,循环 decode 直到稳定
  5. 重复通知:Android 端可能在短时间内投递多次相同 URI,去重窗口兜底

参考