Flutter 基础体系 · 第 49/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter Deep Link 与 Universal Link:配置、解析、登录和安全
1. 先区分几个容易混淆的术语
Deep Link 是“能够直接把用户带到应用内某个资源或页面的链接”。例如:
https://example.com/orders/123
用户点击后,应用可以直接打开订单 123,而不是只打开首页。
Deep Link 是一个行为和能力的统称,不限定实现方式。常见实现包括:
- Custom URL Scheme:例如
myapp://orders/123。 - Android App Link:Android 对
https链接的域名归属验证机制。 - iOS Universal Link:iOS 对
https链接的应用关联机制。 - Web URL:Flutter Web 直接由浏览器访问同一个
https地址。
Universal Link 是 iOS 的专有名称。它通常要求应用声明自己与某个 HTTPS 域名关联,并由域名上的 apple-app-site-association 文件证明这种关联。用户点击普通 HTTPS 链接时,iOS 可以在已安装应用的情况下直接打开应用;没有安装应用时,仍然可以打开网站。
Android 中功能相近的机制叫 Android App Links。两者都属于“经过域名验证的 HTTPS Deep Link”,但配置文件、系统行为和验证流程不同:
| 机制 | 平台 | 链接形式 | 域名验证 |
|---|---|---|---|
| Custom URL Scheme | Android、iOS、桌面等 | myapp://... |
没有强制的域名所有权验证 |
| Android App Links | Android | https://... |
assetlinks.json |
| Universal Links | iOS | https://... |
apple-app-site-association |
| Web 路由 | Web | https://... |
由网站和浏览器处理 |
因此,“Deep Link”是问题域,“Universal Link”是其中一种 iOS 实现。不能把所有 Deep Link 都称为 Universal Link。
2. 一条 Deep Link 的完整数据流
一个链接从点击到 Flutter 页面,通常经过以下组件:
sequenceDiagram
participant U as 用户
participant OS as 操作系统
participant W as 网站验证文件
participant A as Flutter 应用
participant R as 路由解析器
participant S as 会话/登录状态
participant P as 页面
U->>OS: 点击 HTTPS 或自定义 Scheme
OS->>W: 验证应用与域名的关联
W-->>OS: 返回允许的应用、路径和签名
OS->>A: 冷启动或唤醒应用并传入 URI
A->>R: 读取初始链接或后续链接
R->>R: 解析 scheme、host、path、query、fragment
R->>S: 检查登录和授权状态
alt 已登录且有权限
S-->>R: 允许访问
R->>P: 构造目标页面
else 未登录
S-->>R: 暂存受保护目标
R->>P: 显示登录页
P->>S: 登录成功
S->>R: 恢复并重新校验目标
R->>P: 打开目标页面
else 无权限或链接非法
R->>P: 显示错误、登录页或首页
end
这里有三个不同层次,必须分别处理:
- 操作系统分发:这个链接交给哪个应用。
- Flutter 接收:应用冷启动、后台恢复或前台运行时如何取得链接。
- 应用路由:链接中的路径和参数如何转换成页面,并经过登录、授权和安全校验。
配置文件只能解决第一层,不能自动解决后两层。即使系统成功唤醒了应用,如果 Flutter 没有处理新 URI,用户仍然可能只看到首页。
3. 链接的形式化结构与解析规则
一个 URI 可以抽象为:
scheme://authority/path?query#fragment
例如:
https://example.com/orders/123?from=email#summary
各部分含义如下:
scheme:协议,如https、myapp。authority:通常包含host和可选的端口。host:域名,如example.com。path:资源路径,如/orders/123。query:查询参数,如from=email。fragment:片段,如summary。Fragment 通常不会发送给服务器,但应用可以读取它。
Flutter/Dart 使用 Uri 解析 URI:
void main() {
final uri = Uri.parse(
'https://example.com/orders/123?from=email#summary',
);
print(uri.scheme); // https
print(uri.host); // example.com
print(uri.path); // /orders/123
print(uri.pathSegments); // [orders, 123]
print(uri.queryParameters); // {from: email}
print(uri.fragment); // summary
}
3.1 不要直接用字符串切割路径
下面的代码存在边界问题:
final id = uri.toString().split('/').last;
它没有正确处理查询参数、尾部斜杠、编码字符和错误 URI。例如:
/orders/123?from=email
最后一段可能变成 123?from=email,而不是订单 ID 123。
应使用 pathSegments:
String? parseOrderId(Uri uri) {
final segments = uri.pathSegments;
if (segments.length == 2 &&
segments[0] == 'orders' &&
segments[1].isNotEmpty) {
return segments[1];
}
return null;
}
解析过程是:
/orders/123被拆成['orders', '123']。- 第一段必须是固定资源名
orders。 - 第二段被视为候选 ID。
- 不符合结构时返回
null,而不是猜测用户意图。
3.2 查询参数不是可信数据
final returnTo = uri.queryParameters['return_to'];
这只能表示“用户提供了一个字符串”,不能表示它是一个安全的返回地址。下面这个链接可能造成开放重定向:
https://example.com/login?return_to=https://attacker.example
如果应用登录后无条件跳转到 return_to,攻击者可以利用可信域名把用户引到钓鱼页面。
更安全的做法是将返回目标表示为内部路由,而不是任意 URL:
sealed class AppTarget {
const AppTarget();
}
final class OrderTarget extends AppTarget {
const OrderTarget(this.orderId);
final String orderId;
}
final class HomeTarget extends AppTarget {
const HomeTarget();
}
AppTarget parseTarget(Uri uri) {
if (uri.scheme != 'https' || uri.host != 'example.com') {
return const HomeTarget();
}
final segments = uri.pathSegments;
if (segments.length == 2 &&
segments[0] == 'orders' &&
segments[1].isNotEmpty) {
return OrderTarget(segments[1]);
}
return const HomeTarget();
}
这个解析器只允许应用定义过的资源类型。它不会把任意外部 URI 当成内部导航指令。
3.3 URL 编码必须在正确的层次解码
例如订单 ID 是:
orders/a%2Fb
Uri 解析后,pathSegments 会处理路径段编码。应用不应先手动调用 decodeComponent,再把解码后的 / 当成路径分隔符,否则一个 ID 可能被错误地拆成两个路径段。
同样,查询参数应使用:
uri.queryParameters['keyword']
而不是手动按 & 和 = 分割。Dart 的 Uri 会处理查询参数编码和重复键的基本解析;如果业务允许重复键,应明确使用 queryParametersAll。
4. Android:Custom Scheme 与 App Links
4.1 Custom URL Scheme
Android 可以通过 intent-filter 声明一个自定义 Scheme:
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="example"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<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>
</activity>
</application>
</manifest>
下面的链接将匹配该过滤器:
myapp://orders/123
BROWSABLE 允许来自浏览器等外部上下文的跳转,DEFAULT 允许普通 Intent 匹配。自定义 Scheme 的主要问题是:任何其他应用都可以注册同一个 Scheme,系统不一定能区分真正的应用。因此它不适合单独承担高价值登录回调或敏感数据传递。
4.2 Android App Links
对于生产环境的 HTTPS 链接,通常使用 App Links:
<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"
android:pathPrefix="/orders"/>
</intent-filter>
android:autoVerify="true" 请求 Android 验证应用是否拥有该域名的关联声明。它不是“强制验证成功”的保证;域名文件错误、HTTPS 证书问题、应用签名不匹配或系统验证失败,都会使系统退化为普通网页链接或弹出选择器。
在网站上部署:
https://example.com/.well-known/assetlinks.json
示例内容:
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.example.example",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}
]
关键字段的因果关系是:
package_name必须与 Android 应用的 application ID 一致。sha256_cert_fingerprints必须是签名证书指纹,不是上传密钥、调试密钥或包名。- 生产包、内部测试包和调试包可能使用不同签名,因此可以需要不同的应用条目。
- 文件必须通过 HTTPS 提供,并位于精确的
/.well-known/assetlinks.json路径。
可用以下命令查看签名证书指纹:
keytool -list -v \
-keystore release-keystore.jks \
-alias release
输出中的 SHA256 值需要转换为 assetlinks.json 使用的冒号分隔大写格式。不要把真实 keystore 密码提交到脚本或日志中。
Android 端可以使用 ADB 发起测试:
adb shell am start \
-a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://example.com/orders/123"
这个命令只验证 Intent 能否发出和应用能否收到;它不能单独证明域名验证正确。验证失败时,应进一步检查:
adb shell pm get-app-links com.example.example
不同 Android 版本的输出格式可能不同,但应关注域名的验证状态。还要检查:
assetlinks.json是否返回 HTTP 200。- 是否被重定向。
Content-Type是否合理。- JSON 是否有效。
- 包名和签名指纹是否对应当前安装的 APK。
- Manifest 中的 host、scheme、path 是否与实际 URL 一致。
5. iOS:Universal Link 的配置与限制
5.1 Associated Domains 能做什么
在 iOS 工程中启用 Associated Domains 能力,并加入:
applinks:example.com
如果使用多个域名,需要分别声明:
applinks:example.com
applinks:staging.example.com
这个配置表达的是:应用希望接收这些域名的 Universal Link。它本身还不够,网站必须提供对应的关联文件。
5.2 apple-app-site-association
部署到以下任一位置:
https://example.com/.well-known/apple-app-site-association
或者:
https://example.com/apple-app-site-association
典型文件如下:
{
"applinks": {
"details": [
{
"appIDs": [
"ABCDE12345.com.example.example"
],
"components": [
{
"/": "/orders/*"
}
]
}
]
}
}
appIDs 的格式是:
TeamID.BundleID
例如:
ABCDE12345.com.example.example
这里的 TeamID 是 Apple Developer Team ID,不是 App Store ID。BundleID 必须与应用的 Bundle Identifier 相同。
兼容较旧系统或既有项目时,也可能看到旧格式:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "ABCDE12345.com.example.example",
"paths": [
"/orders/*"
]
}
]
}
}
实际使用的字段能力受目标 iOS 版本和项目配置影响,生产项目应根据支持的最低系统版本验证文件格式。
文件要求包括:
- 文件名没有
.json后缀。 - 通过 HTTPS 提供。
- 不依赖登录。
- 内容是有效 JSON。
- 服务器不能要求 Cookie 或特殊请求头。
- 不应被错误的重定向、缓存策略或 CDN 规则拦截。
5.3 Universal Link 的用户行为不是绝对强制
即使配置完全正确,iOS 也可能因为用户之前选择在 Safari 打开、系统缓存、应用状态或具体点击上下文而打开网页。Universal Link 是系统的链接分发机制,不是应用可以强制夺取所有网页点击的权限。
因此,产品必须允许以下结果同时存在:
- 应用已安装,打开应用。
- 应用未安装,打开网站。
- 用户明确选择后,后续倾向于继续打开网站。
- 某些应用内浏览器不完全遵循系统行为。
iOS 端测试时,应使用真实 HTTPS URL,并从备忘录、邮件、Safari 等实际入口测试。直接在某些调试工具中粘贴 URL,不一定复现用户点击链接的行为。
6. Flutter 如何接收链接
Flutter 处理 Deep Link 有两个阶段:
- 初始链接:应用因链接冷启动或从终止状态恢复时得到的 URI。
- 后续链接:应用已经运行,用户再次点击链接时收到的 URI。
Flutter 的路由系统可以处理初始路由信息,但“前台运行时持续监听平台链接”通常需要平台集成或插件。常见做法是使用社区插件,例如 app_links;它不是 Flutter SDK API,必须锁定版本并根据所用版本检查 API。
在 pubspec.yaml 中加入依赖后,下面是一个典型入口:
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const App());
}
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
final _appLinks = AppLinks();
StreamSubscription<Uri>? _subscription;
Uri? _initialUri;
String? _error;
@override
void initState() {
super.initState();
_readInitialLink();
_listenToLinks();
}
Future<void> _readInitialLink() async {
try {
final uri = await _appLinks.getInitialLink();
if (!mounted) return;
setState(() {
_initialUri = uri;
});
if (uri != null) {
_handleUri(uri);
}
} catch (error) {
if (!mounted) return;
setState(() {
_error = '读取初始链接失败:$error';
});
}
}
void _listenToLinks() {
_subscription = _appLinks.uriLinkStream.listen(
_handleUri,
onError: (Object error, StackTrace stackTrace) {
if (!mounted) return;
setState(() {
_error = '监听链接失败:$error';
});
},
);
}
void _handleUri(Uri uri) {
final target = parseTarget(uri);
if (target is OrderTarget) {
// 实际项目中应提交给统一导航协调器,
// 而不是在任意回调里直接 push。
debugPrint('收到订单链接:${target.orderId}');
} else {
debugPrint('收到不支持的链接:$uri');
}
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Deep Link Demo')),
body: Center(
child: Text(_error ?? '等待链接;初始链接:$_initialUri'),
),
),
);
}
}
这个例子展示了接收生命周期,但仍有一个重要工程约束:_handleUri 不应直接在任意时机调用 Navigator.push。应用可能正在:
- 显示启动页;
- 恢复登录状态;
- 构造 Navigator;
- 处理另一个链接;
- 显示登录页面;
- 恢复系统状态。
因此更可靠的结构是:平台链接先进入一个“导航意图队列”,等应用完成初始化后统一消费。
Flutter 官方路由体系包括 Navigator、RouteInformationParser、RouterDelegate 和 MaterialApp.router。简单应用可以使用 Navigator;需要 URL 与页面状态双向同步时,应使用 Router API 或基于它的路由包。无论采用哪种路由方案,Deep Link 的安全解析都应位于路由入口之前或入口之内的统一层,而不应散落在各个页面。
7. 一个可运行的安全解析器
下面的代码不依赖 Flutter,只依赖 Dart 3 的模式匹配和 sealed class,可以直接保存为 bin/parse_link.dart 运行。
sealed class LinkTarget {
const LinkTarget();
}
final class OrderTarget extends LinkTarget {
const OrderTarget(this.id);
final String id;
@override
String toString() => 'OrderTarget(id: $id)';
}
final class LoginCallbackTarget extends LinkTarget {
const LoginCallbackTarget({
required this.code,
required this.state,
});
final String code;
final String state;
@override
String toString() => 'LoginCallbackTarget(code: $code, state: $state)';
}
final class HomeTarget extends LinkTarget {
const HomeTarget();
@override
String toString() => 'HomeTarget';
}
final class InvalidTarget extends LinkTarget {
const InvalidTarget(this.reason);
final String reason;
@override
String toString() => 'InvalidTarget(reason: $reason)';
}
LinkTarget parseLink(Uri uri) {
// 1. 先校验 scheme 和 host,再解析业务路径。
final isHttps =
uri.scheme == 'https' && uri.host.toLowerCase() == 'example.com';
final isCustomScheme = uri.scheme == 'myapp';
if (!isHttps && !isCustomScheme) {
return const InvalidTarget('scheme 或 host 不被允许');
}
final segments = uri.pathSegments;
// 2. 订单 Deep Link:/orders/<id>
if (segments.length == 2 && segments[0] == 'orders') {
final id = segments[1];
if (!_isSafeIdentifier(id)) {
return const InvalidTarget('订单 ID 格式非法');
}
return OrderTarget(id);
}
// 3. OAuth 登录回调:/auth/callback?code=...&state=...
if (segments.length == 2 &&
segments[0] == 'auth' &&
segments[1] == 'callback') {
final code = uri.queryParameters['code'];
final state = uri.queryParameters['state'];
if (code == null || state == null || code.isEmpty || state.isEmpty) {
return const InvalidTarget('登录回调缺少 code 或 state');
}
return LoginCallbackTarget(code: code, state: state);
}
return const HomeTarget();
}
bool _isSafeIdentifier(String value) {
// 示例策略:只允许长度有限的字母、数字、短横线和下划线。
final pattern = RegExp(r'^[A-Za-z0-9_-]{1,128}$');
return pattern.hasMatch(value);
}
void main() {
const inputs = [
'https://example.com/orders/123',
'https://example.com/orders/123?from=email',
'myapp://auth/callback?code=abc&state=s1',
'https://attacker.example/orders/123',
'https://example.com/orders/a%2Fb',
];
for (final input in inputs) {
final uri = Uri.parse(input);
print('$input => ${parseLink(uri)}');
}
}
预期结果类似:
https://example.com/orders/123 => OrderTarget(id: 123)
https://example.com/orders/123?from=email => OrderTarget(id: 123)
myapp://auth/callback?code=abc&state=s1 => LoginCallbackTarget(code: abc, state: s1)
https://attacker.example/orders/123 => InvalidTarget(reason: scheme 或 host 不被允许)
https://example.com/orders/a%2Fb => InvalidTarget(reason: 订单 ID 格式非法)
这里的处理顺序是有意设计的:
- 先限制允许的 Scheme 和 Host。
- 再限制路径结构。
- 再限制资源 ID 的字符集和长度。
- 最后才构造业务对象。
如果顺序反过来,应用可能在完成安全校验前就触发网络请求、页面跳转或状态修改。
8. Deep Link 与 Flutter 路由状态
一个 Deep Link 不应等同于一次简单的 push。原因是路由具有状态,而且可能需要恢复、替换或清空已有页面栈。
例如当前页面栈是:
Home -> ProductList -> ProductDetail(10)
此时用户点击:
https://example.com/orders/123
直接执行:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const OrderPage(orderId: '123'),
),
);
结果可能是:
Home -> ProductList -> ProductDetail(10) -> Order(123)
这未必符合产品意图。用户可能期望的是:
Home -> Order(123)
或者如果订单页是外部入口,则期望清空到:
Order(123)
因此需要先定义 Deep Link 的路由语义:
- 追加型:在当前栈上打开目标。
- 替换型:用目标页面替换当前页面。
- 重置型:清空页面栈后进入目标。
- 待登录型:先进入登录流程,成功后再恢复目标。
- 不可达型:资源不存在、无权限或链接过期时进入错误页面。
在 Router 架构中,可以把 URI 转换为不可变的应用路由状态:
sealed class AppRoute {
const AppRoute();
}
final class HomeRoute extends AppRoute {
const HomeRoute();
}
final class OrderRoute extends AppRoute {
const OrderRoute(this.id);
final String id;
}
final class LoginRoute extends AppRoute {
const LoginRoute(this.pending);
final AppRoute pending;
}
AppRoute routeFromUri(Uri uri) {
return switch (parseLink(uri)) {
OrderTarget(:final id) => OrderRoute(id),
LoginCallbackTarget() => const HomeRoute(),
HomeTarget() => const HomeRoute(),
InvalidTarget() => const HomeRoute(),
};
}
真实项目中,routeFromUri 还应与登录状态、服务端授权结果和资源加载状态协同。一个常见的状态机如下:
stateDiagram-v2
[*] --> Booting
Booting --> Ready: 初始化完成
Booting --> PendingLink: 已收到链接但应用未就绪
PendingLink --> Ready: 初始化完成
Ready --> Resolving: 收到 URI
Resolving --> ShowingPublicPage: 公开目标
Resolving --> WaitingLogin: 目标需要登录
Resolving --> ErrorPage: URI 非法
WaitingLogin --> Revalidating: 登录成功
WaitingLogin --> Ready: 用户取消登录
Revalidating --> ShowingProtectedPage: 会话有效且有权限
Revalidating --> ErrorPage: 无权限、资源不存在或目标过期
ShowingPublicPage --> Resolving: 收到新 URI
ShowingProtectedPage --> Resolving: 收到新 URI
PendingLink 很重要。冷启动时,平台链接可能早于:
- 本地 Token 加载;
- 远程配置加载;
- 用户信息恢复;
- Flutter Router 创建;
- 数据库初始化。
如果此时直接导航,常见失败表现包括:
- 首屏先显示首页,稍后又跳转目标页,出现闪烁。
- 目标页依赖用户信息,但因为会话尚未恢复而错误跳登录。
- 同一个链接被初始回调和流回调各处理一次,页面重复打开。
- 用户从登录页返回后,原始目标已丢失。
应为每个链接建立唯一处理记录,并让导航协调器串行消费。
9. 登录流程:目标暂存不等于绕过授权
Deep Link 登录通常包含两个不同问题:
- 用户是否已登录。
- 用户是否有权访问这个具体资源。
“已登录”不等于“有权访问订单 123”。所以正确流程应是:
收到 URI
-> 解析为内部目标
-> 判断是否需要登录
-> 未登录:暂存目标,进入登录
-> 登录成功:重新解析或校验目标
-> 请求服务端确认权限
-> 有权限才展示资源
暂存目标时,不要只保存未经校验的原始字符串:
// 不推荐:原始 URL 以后可能被当作任意跳转地址。
String? pendingUrl;
应保存结构化目标:
sealed class PendingDestination {
const PendingDestination();
}
final class PendingOrder extends PendingDestination {
const PendingOrder(this.orderId);
final String orderId;
}
登录成功后:
Future<void> resumeAfterLogin(PendingDestination destination) async {
switch (destination) {
case PendingOrder(:final orderId):
final allowed = await authorizationService.canReadOrder(orderId);
if (!allowed) {
showForbiddenPage();
return;
}
openOrderPage(orderId);
}
}
这里必须“登录后重新授权”,因为以下条件可能在登录期间变化:
- 原链接来自未可信来源;
- 用户登录的是另一个账号;
- 资源权限发生变化;
- 链接中的资源已删除;
- 链接已经过期;
- 应用进程被杀死后恢复了旧数据。
9.1 OAuth 回调与 Deep Link
移动端 OAuth 回调常见形式是:
myapp://auth/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE
或者使用经过验证的 HTTPS App Link/Universal Link:
https://example.com/auth/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE
推荐使用 Authorization Code + PKCE:
- 应用生成高熵
code_verifier。 - 应用计算
code_challenge并发起授权。 - 应用生成随机
state,并保存本次登录上下文。 - 授权服务器回调应用,携带
code和state。 - 应用比较回调中的
state与本地保存值。 - 应用使用
code_verifier兑换 Token。 - 服务器使授权码短期有效且只能使用一次。
形式上,回调能够被接受至少需要:
received_state == stored_state
并且:
authorization_code 未使用
authorization_code 未过期
PKCE(code_verifier) 与授权请求匹配
redirect_uri 与原授权请求一致
仅仅判断 code 不为空是不安全的。攻击者可以构造一个带有自己授权码的链接,如果应用没有校验 state,就可能发生登录 CSRF 或账号绑定错误。
不要把 Access Token 放在 Deep Link 中:
myapp://callback?access_token=...
原因包括:
- URL 可能出现在系统日志、浏览器历史、分析 SDK 或错误报告中;
- 自定义 Scheme 可能被其他应用抢先注册;
- 复制、分享和截图会扩大泄露范围;
- 前台链接监听器可能被错误记录。
授权码也应尽量短期、单次使用,并避免在普通日志中打印完整 URI。
10. 安全模型:平台验证不能替代业务验证
10.1 Universal Link 和 App Link 解决的是什么问题
域名关联文件解决的是:
“这个 HTTPS 域名允许哪个应用处理哪些链接?”
它不解决:
“用户是否登录?”
“用户是否有权查看订单?”
“订单 ID 是否存在?”
“参数是否过期?”
“这个操作是否允许执行?”
因此,即使 Universal Link 验证成功,也必须把所有链接输入当作不可信输入。
10.2 自定义 Scheme 的劫持风险
假设应用使用:
myapp://auth/callback
其他应用也可能声明相同的 myapp Scheme。系统的实际分发行为可能依赖安装顺序、默认选择和平台版本。对于普通分享链接,这种风险可能可以接受;对于登录回调和敏感操作,不应仅依赖自定义 Scheme。
可选策略是:
- 优先使用 HTTPS Universal Link/App Link。
- OAuth 使用 PKCE 和
state。 - 回调不携带长期 Token。
- 服务端再次验证授权码、客户端、重定向地址和会话。
- 对敏感操作要求用户在应用内确认。
10.3 Host、Path 和参数的白名单
不要只检查:
uri.scheme == 'https'
下面的 URL 仍然不是目标应用自己的链接:
https://attacker.example/orders/123
至少应检查:
final allowed =
uri.scheme == 'https' &&
uri.host.toLowerCase() == 'example.com' &&
uri.port == 443;
如果允许多个 host,应使用显式集合:
const allowedHosts = {
'example.com',
'm.example.com',
};
bool isAllowedHost(Uri uri) {
return uri.scheme == 'https' &&
allowedHosts.contains(uri.host.toLowerCase()) &&
(uri.port == 0 || uri.port == 443);
}
同时应限制路径前缀和参数名称。不要把 queryParameters 整体反序列化成任意命令对象,更不要允许 URI 直接决定要执行的系统操作。
10.4 URI 解析与规范化的边界
常见错误是先做宽松规范化,再做安全判断。例如把多个斜杠、点路径或大小写统一后,可能导致“验证的路径”和“实际导航的路径”不是同一个字符串。
安全策略应明确:
- 先使用平台和
Uri的标准解析。 - 明确允许的 Scheme、Host、端口和路径结构。
- 对业务 ID 使用严格字符集。
- 将规范化后的结构作为唯一后续输入。
- 不再使用原始 URL 进行第二套解析。
如果服务端也会解析同一个 URL,客户端和服务端应对编码、尾部斜杠、大小写和重复参数建立一致约定,否则可能出现解析差异。
11. 处理重复链接、并发和进程生命周期
应用可能同时收到多个链接:
A: /orders/123
B: /orders/456
也可能因为生命周期回调重复收到同一个链接。导航系统需要定义策略,而不是依赖回调执行顺序。
一个简单的串行协调器如下:
import 'dart:async';
import 'dart:collection';
class DeepLinkCoordinator {
final Queue<Uri> _queue = Queue<Uri>();
bool _processing = false;
String? _lastProcessedKey;
Future<void> add(Uri uri) async {
final key = uri.toString();
// 只做短时间或单次去重时可以使用该策略。
// 如果业务允许用户重复打开同一资源,不应永久去重。
if (key == _lastProcessedKey) {
return;
}
_queue.add(uri);
await _drain();
}
Future<void> _drain() async {
if (_processing) return;
_processing = true;
try {
while (_queue.isNotEmpty) {
final uri = _queue.removeFirst();
_lastProcessedKey = uri.toString();
final target = parseLink(uri);
await _resolveAndNavigate(target);
}
} finally {
_processing = false;
}
}
Future<void> _resolveAndNavigate(LinkTarget target) async {
switch (target) {
case OrderTarget(:final id):
// 等待会话恢复,再检查服务端权限,然后导航。
print('resolve order: $id');
case LoginCallbackTarget(:final code, :final state):
// 先校验 state,再用 code + PKCE 换取会话。
print('handle oauth callback: $code, $state');
case HomeTarget():
print('navigate home');
case InvalidTarget(:final reason):
print('show invalid link: $reason');
}
}
}
这个示例体现了三个状态约束:
_processing保证同一时刻只有一个导航消费循环。_queue保留尚未处理的链接。_lastProcessedKey避免典型的重复回调。
但去重不能简单地永久按完整 URL 丢弃。用户可能确实需要再次打开同一订单;登录回调的去重又通常应该更严格。生产实现应按链接类型定义幂等性:
- 订单查看:重复打开通常是幂等的。
- 支付确认:必须使用服务端幂等键。
- OAuth 授权码:只能兑换一次。
- 删除或确认操作:不能因为收到 URI 就自动执行。
12. 失败路径与诊断顺序
12.1 点击后只打开网页
可能原因:
- iOS 的 Associated Domains 没有启用。
- AASA 文件中的 Team ID 或 Bundle ID 错误。
- Android 的
assetlinks.json签名指纹错误。 - Manifest 没有
VIEW、BROWSABLE或匹配的 host/path。 - 域名文件返回了重定向、登录页或无效 JSON。
- 用户已经选择过在浏览器中打开。
- 测试入口本身不触发系统链接分发。
诊断应从外到内:
- 用浏览器请求关联文件,确认实际响应内容。
- 检查应用包名、Bundle ID、签名和 Team ID。
- 检查平台配置是否与线上域名完全一致。
- 使用 Android ADB 或真实 iOS 点击测试。
- 确认应用是否真的收到了 URI。
- 最后检查 Flutter 解析器和路由状态。
12.2 应用打开但总是进入首页
说明系统分发可能已成功,但 Flutter 层没有消费链接。常见原因:
- 只处理了初始链接,没有监听后续链接。
- 只监听了后续流,没有读取冷启动链接。
- 在 Router 初始化之前丢弃 URI。
- 解析失败后静默回退首页。
- 链接被插件和 Flutter 默认深链处理同时消费。
- 首屏导航完成后又覆盖了目标状态。
诊断时记录结构化信息,而不是直接打印敏感 URI:
void logLink(Uri uri, LinkTarget target) {
debugPrint(
'deep_link '
'scheme=${uri.scheme} '
'host=${uri.host} '
'path=${uri.path} '
'target=${target.runtimeType}',
);
}
不要在生产日志中打印 OAuth code、access_token、Cookie 或完整查询参数。
12.3 收到链接两次
常见来源是:
- 初始链接 API 与链接流都返回同一个链接。
- Android Activity 重新创建。
- 页面组件重复订阅流。
- 插件生命周期与 Flutter Router 同时处理 URI。
修复方法不是简单地“忽略第二次调用”,而是:
- 规定一个唯一的链接入口。
- 在应用级对象中订阅,而不是每个页面订阅。
- 对回调和导航任务做幂等处理。
- 明确冷启动、后台恢复和前台运行的测试矩阵。
- 在订阅处保存并取消
StreamSubscription。
12.4 目标页闪退或空白
链接解析成功不代表资源加载成功。目标页还可能遇到:
- 订单不存在;
- 用户没有权限;
- Token 过期;
- 网络不可用;
- 服务端返回 401、403、404 或 410;
- 页面在登录完成前就读取了用户状态。
目标页面应区分至少这些结果:
401:需要登录或重新登录
403:已登录但无权访问
404:资源不存在
410:链接或资源已过期
网络错误:提供重试,不要误显示“无权限”
把所有异常都跳首页会让用户无法判断链接是否有效,也会掩盖配置和权限问题。
13. Android、iOS、桌面和 Web 的差异
Android
Android 接收外部链接通常以 Intent 为入口。应用可能被冷启动,也可能复用已有 Activity。Manifest 的 launchMode、Activity 重建和系统版本会影响链接传递方式。App Links 依赖域名与签名关联,但实际验证状态仍应在目标设备和目标构建版本上测试。
iOS
iOS Universal Link 依赖 Associated Domains、AASA 文件和系统的链接选择策略。它不是普通 URL Scheme 的别名,不能用 Android 的 assetlinks.json 配置替代。用户行为、系统缓存以及点击来源可能导致 Universal Link 最终进入 Safari。
桌面端
Flutter Desktop 的 Deep Link 支持通常不是移动端那种统一的系统行为:
- Windows、macOS、Linux 的 Scheme 注册方式不同。
- 应用可能收到命令行参数,而不是移动端生命周期回调。
- 已运行实例如何把新链接转发给已有实例,需要平台代码或插件支持。
- Universal Link 是 iOS 概念,不能直接套用到桌面端。
桌面端的应用层仍可复用 Uri 解析器和安全白名单,但平台接收层必须分别实现。
Web
Flutter Web 中,https://example.com/orders/123 首先是浏览器 URL。服务器必须正确处理深层路径:
- 用户直接访问
/orders/123时,服务器应返回 Flutter Web 的入口 HTML。 - 浏览器 History API、服务器回退规则和静态资源路径必须协调。
- 如果服务器只知道
/,直接刷新深层路径可能返回 404。 - Web 端不能依赖移动端的 App Links 或 Universal Links。
Web 的路由地址本身暴露在地址栏、历史记录和日志中,因此同样不应在查询参数中放长期 Token。
14. 测试策略:不要只测试“能否打开首页”
一个完整测试矩阵至少包括:
| 场景 | 应验证的行为 |
|---|---|
| 应用未安装 | HTTPS 链接打开网站 |
| 应用已安装且终止 | 应用冷启动并进入目标 |
| 应用在后台 | 恢复应用并处理目标 |
| 应用在前台 | 不重启应用,处理新目标 |
| 未登录 | 暂存目标并进入登录 |
| 登录成功 | 恢复目标并重新授权 |
| 登录取消 | 不误打开受保护页面 |
| 无权限 | 显示 403 类页面 |
| 资源不存在 | 显示 404 类页面 |
| 非法 Scheme/Host | 拒绝处理 |
| 重复回调 | 不重复兑换授权码或重复导航 |
| 网络失败 | 显示可恢复错误和重试入口 |
示例链接也应覆盖边界:
https://example.com/orders/123
https://example.com/orders/
https://example.com/orders/a%2Fb
https://example.com/orders/123?from=email
https://example.com/orders/123#summary
https://attacker.example/orders/123
myapp://auth/callback?code=x&state=y
每个测试应记录三类结果:
- 系统把链接交给了哪个应用。
- Flutter 实际收到的 URI 是什么。
- 应用最终进入了哪个业务状态。
只有第三项正确,才算端到端成功。前两项正确而页面错误,属于 Flutter 路由或业务授权问题;第一项就错误,属于平台关联配置问题。
15. 生产取舍
Custom Scheme 配置简单,适合内部跳转、开发测试和不要求域名归属证明的场景,但存在 Scheme 冲突和劫持风险。
Android App Links 与 iOS Universal Links 使用 HTTPS,能够保留网站降级路径,并通过域名关联减少错误应用接管链接的风险;代价是需要维护签名、应用标识、关联文件、HTTPS 和多环境域名。
将 Deep Link 直接映射成页面路径最容易实现,但对登录、权限和并发不够安全。将链接先解析成结构化目标,再经过状态机、会话恢复和服务端授权,代码更多,却能清楚地区分:
链接有效性
登录状态
资源授权
导航状态
操作幂等性
最终,一个可靠的 Flutter Deep Link 系统应满足以下因果链:
平台正确识别链接
→ Flutter 在冷启动和运行中都收到链接
→ Uri 被严格解析为有限的内部目标
→ 目标经过登录与服务端授权
→ 路由状态以可恢复、可去重的方式更新
→ 失败被分类显示,而不是静默回首页
Universal Link 或 App Link 只负责这条链的第一步;安全的解析、登录恢复和最终页面行为,仍然必须由应用自己完整实现。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter go_router:声明式路由、重定向、Shell、Deep Link 和恢复
- 下一篇:Flutter 表单校验:Form、Controller、异步规则、错误和提交
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论