背景
「小雷达」需要支持从 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 { final String? channelDirect = extractJoinChannelFromUri(uri);
final String? direct = extractDirectNavigationFromUri(uri);
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;
return resolveNativeRouteFromShareKey(apiClient: client, key: key); }
|
5. ApiClient 未就绪的处理
冷启动时 ApiClient 可能尚未初始化,需要暂存链接:
1 2 3 4 5 6 7
| if (fromColdStart && _apiClient == null) { _pendingUri = uri; return; }
DeepLinkService.bindApiClient(context.read<ApiClient>());
|
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(); 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}/'); }
|
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; _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 未安装 → 下载页
|
踩坑总结
- 热启动链接丢失:
uriLinkStream 订阅晚于链接到达时,原生层会暂存到 latestLink,需要 onAppResumed 主动补拉
- 初始化顺序:必须在
runApp 之后初始化,否则 getInitialLink 阻塞启动页
- 页面栈冲突:secure_display 的重组可能导致 push 被覆盖,延迟 + 重试双保险
- 多次 encode:H5 可能对路径 encode 多次,循环 decode 直到稳定
- 重复通知:Android 端可能在短时间内投递多次相同 URI,去重窗口兜底
参考