1. 从一次分享链接失效说起:Universal Link 的共用困境
去年帮一个做内容社区的朋友排查过一个挺典型的问题:他们的 App 里有个"邀请好友"功能,运营在后台生成一条分享链接,用户点开后如果装了 App 就直接唤起,没装就跳 App Store。一开始只有主 App 用这条链接,跑得挺顺。后来他们又上线了一个极速版 App,想让两个客户端共用同一条分享链接——结果就出事了:极速版用户点链接,十次有七八次被主 App 抢走,偶尔两个都不唤起,直接落在浏览器里。
这个问题的核心,就是多个客户端共用同一条 Universal Link时,苹果的匹配机制到底怎么判定"该唤起谁"。很多人以为只要两个 App 的 Associated Domains 里都写上同一个域名就完事了,实际上远没这么简单。苹果在 iOS 9 引入 Universal Link 之后,又在后续版本里不断调整匹配优先级,尤其是 iOS 13 之后引入了components和exclude这些精细匹配字段,玩法完全变了。
这篇内容我打算把这件事彻底讲透:从 Universal Link 的底层匹配逻辑,到两个甚至多个 App 共用一条链接时的配置方案,再到实测中那些文档里不会写的坑。适合正在做多端 App 矩阵、马甲包、主端+极速版这类架构的开发和运维同学。如果你只有一个 App,看完也能把 Universal Link 的匹配规则吃得更透,少踩几个坑。
2. Universal Link 的匹配机制:苹果到底怎么决定唤起哪个 App
2.1 从 apple-app-site-association 文件说起
Universal Link 的信任基础是域名根目录下的apple-app-site-association文件(下面简称 AASA 文件)。这个文件放在https://你的域名/.well-known/apple-app-site-association,不需要签名,但必须是application/json类型,且不能有重定向。iOS 在安装 App 时会去拉取这个文件,把里面声明的路径和当前 App 的 Team ID + Bundle ID 建立关联。
一个最基础的 AASA 长这样:
{ "applinks": { "apps": [], "details": [ { "appID": "ABCDE12345.com.example.main", "paths": ["/share/*", "/invite/*"] } ] } }这里的appID是TeamID.BundleID的组合。关键点在于:details是一个数组,可以放多个 App 的条目。这就是多客户端共用的物理基础——同一个域名、同一个 AASA 文件里,声明多个 App 的 appID,理论上它们都能响应这个域名下的链接。
但"都能响应"不等于"都能被正确唤起"。苹果的匹配是按顺序、按优先级来的,谁先匹配上谁赢,后面的就没机会了。这个顺序不是你在 AASA 里写的顺序,而是苹果内部的一套规则。
2.2 匹配优先级:为什么主 App 总是抢走链接
苹果的匹配逻辑大致是这样的:当用户点击一个 Universal Link,系统会遍历所有已安装的、在 AASA 里声明了该域名的 App,然后按以下维度排序:
- 路径匹配的精确度:
components里写的条件越具体,优先级越高。比如/share/invite/*比/share/*更具体。 exclude字段的排除:如果某个 App 的配置里明确exclude了某条路径,那它就不会响应这条路径。- App 的安装顺序和系统缓存:这一条最玄学,苹果没有公开文档,但实测下来,先安装的 App 在同等条件下有微弱优势。
问题就出在第一条。如果两个 App 的 AASA 配置完全一样,都写/share/*,那系统就懵了——它没有明确的优先级依据,只能按内部缓存顺序来,表现就是"随机唤起",或者干脆都不唤起落到 Safari。这就是我朋友遇到的情况。
注意:很多人以为在 AASA 里把某个 App 的条目写在前面就能提高优先级,这是错的。苹果不看数组顺序,只看匹配条件的精确度。
2.3 iOS 13 之后的 components 字段:精细匹配的钥匙
iOS 13 开始,苹果推荐用components替代老的paths。components允许你对 URL 的各个部分做精确匹配和排除,语法是这样的:
{ "applinks": { "details": [ { "appID": "ABCDE12345.com.example.main", "components": [ { "/": "/share/*", "?": { "channel": "main" }, "exclude": false, "comment": "主 App 只响应带 channel=main 的分享链接" } ] }, { "appID": "ABCDE12345.com.example.lite", "components": [ { "/": "/share/*", "?": { "channel": "lite" }, "exclude": false, "comment": "极速版只响应带 channel=lite 的分享链接" } ] } ] } }这个配置的思路是:用 query 参数区分不同客户端。运营生成分享链接时,根据目标客户端拼上不同的channel参数,这样两个 App 的匹配条件就互斥了,不会互相抢。
但这里有个大坑:components里的 query 匹配是精确匹配,?channel=main只能匹配?channel=main,如果链接里还有别的参数,比如?channel=main&from=wx,那这条规则就匹配不上了。正确写法是用通配:
"?": { "channel": "main*" }或者干脆不写 query 条件,改用路径区分。这个细节后面会展开讲。
3. 多客户端共用的三种配置方案与选型逻辑
3.1 方案一:路径前缀区分(最稳,推荐)
这是我最推荐的方案,逻辑最简单:不同客户端用不同的路径前缀。
- 主 App:
/main/share/*、/main/invite/* - 极速版:
/lite/share/*、/lite/invite/*
AASA 配置:
{ "applinks": { "details": [ { "appID": "ABCDE12345.com.example.main", "components": [{ "/": "/main/*", "exclude": false }] }, { "appID": "ABCDE12345.com.example.lite", "components": [{ "/": "/lite/*", "exclude": false }] } ] } }服务端生成分享链接时,根据业务决定用哪个前缀。这个方案的好处是匹配条件完全互斥,不存在抢链接的可能,而且路径语义清晰,排查问题一眼就能看出该唤起谁。
代价是运营侧要改链接生成逻辑,如果历史链接已经发出去了,得做 301 重定向兼容。我的做法是在服务端加一层路由:老链接/share/*根据 User-Agent 或者默认规则重定向到/main/share/*,保证老链接不失效。
3.2 方案二:Query 参数区分(灵活,但坑多)
如果不想改路径结构,可以用 query 参数区分。前面提过,components的 query 匹配是精确匹配,必须用通配符。
{ "applinks": { "details": [ { "appID": "ABCDE12345.com.example.main", "components": [ { "/": "/share/*", "?": { "client": "main*" }, "exclude": false } ] }, { "appID": "ABCDE12345.com.example.lite", "components": [ { "/": "/share/*", "?": { "client": "lite*" }, "exclude": false } ] } ] } }这个方案的坑在于:
- query 参数的顺序敏感。
?client=main&from=wx和?from=wx&client=main在苹果的匹配里可能表现不一致,实测下来顺序不同会导致匹配失败。所以生成链接时一定要固定参数顺序。 - 通配符
*只能放在值的末尾,不能放中间。?client=*main是无效的。 - 如果链接里没有
client参数,两个 App 都匹配不上,直接落 Safari。所以服务端必须保证每条分享链接都带上这个参数。
3.3 方案三:exclude 排除法(兜底用,不推荐单独用)
exclude字段可以让某个 App 明确不响应某些路径。比如主 App 排除/lite/*,极速版排除/main/*:
{ "appID": "ABCDE12345.com.example.main", "components": [ { "/": "/lite/*", "exclude": true }, { "/": "/*", "exclude": false } ] }这个方案的逻辑是"先排除,再全匹配"。但实测下来,exclude的优先级判定在 iOS 不同版本间有差异,iOS 14 和 iOS 16 的表现就不完全一样。我一般只把它当兜底,不作为主要区分手段。
3.4 三种方案的选型对照
| 维度 | 路径前缀区分 | Query 参数区分 | exclude 排除法 |
|---|---|---|---|
| 匹配稳定性 | 高 | 中 | 低 |
| 服务端改动量 | 中(需改路径) | 小(加参数) | 小 |
| 历史链接兼容 | 需重定向 | 需补参数 | 需重定向 |
| 排查难度 | 低 | 中 | 高 |
| 推荐场景 | 新项目、可改路径 | 路径固定、可加参数 | 仅兜底 |
我的建议很明确:新项目一律用路径前缀区分,这是最不容易出问题的方案。如果路径已经固定死了改不动,再考虑 query 参数,但一定要做好参数顺序和通配符的处理。
4. 服务端与客户端联调的完整实操链路
4.1 AASA 文件的部署与验证
AASA 文件必须满足几个硬性条件,缺一个都会导致整个 Universal Link 失效:
- 路径必须是
/.well-known/apple-app-site-association,不能带.json后缀。 - Content-Type 必须是
application/json。 - 不能有 301/302 重定向,必须是 200 直接返回。
- 不能要求登录或鉴权。
- 文件大小不超过 128KB(未签名情况下)。
部署完之后,用苹果官方的验证工具或者直接 curl 检查:
curl -I https://yourdomain.com/.well-known/apple-app-site-association重点看返回头里的Content-Type和状态码。我踩过的坑是 CDN 把.well-known目录给缓存了旧版本,导致新配置一直不生效,排查了半天才发现是缓存问题。所以每次改完 AASA,记得刷新 CDN 缓存。
4.2 Xcode 侧的 Associated Domains 配置
两个 App 的 Xcode 工程里都要配置 Associated Domains。在 Signing & Capabilities 里添加:
applinks:yourdomain.com注意这里不要带https://前缀,也不要带路径,只写域名。如果要支持子域名,可以写applinks:sub.yourdomain.com。
有个容易忽略的点:两个 App 必须用同一个 Team ID。如果主 App 和极速版分属不同开发者账号,那 AASA 里的 appID 前缀就不一样,虽然理论上也能配,但实测下来跨 Team 的匹配优先级判定更不稳定,能避免就避免。
4.3 客户端处理链接的代码逻辑
App 被唤起后,需要在SceneDelegate或AppDelegate里处理传入的 URL。iOS 13 之后用scene(_:continue:):
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return } // 解析路径,判断是不是本 App 该处理的 let path = url.path if path.hasPrefix("/main/") { handleMainShare(url: url) } else { // 不是本 App 的路径,理论上不该走到这里,做个兜底 openInSafari(url: url) } }这里有个实战经验:一定要做路径校验兜底。虽然 AASA 配置正确的话,不该本 App 处理的链接不会唤起本 App,但线上环境复杂,用户可能通过其他方式(比如复制粘贴到 App 内)触发,做个校验能避免误处理。
4.4 联调阶段的验证清单
联调时按这个顺序验证,能快速定位问题出在哪一环:
- AASA 文件可访问性:浏览器直接打开
https://yourdomain.com/.well-known/apple-app-site-association,能看到 JSON 内容。 - App 安装后是否拉取成功:删除 App 重装,观察控制台有没有 AASA 拉取失败的日志。
- 链接点击测试:在备忘录里粘贴链接,长按看是否出现"在 XXX 中打开"的选项。如果出现,说明系统识别到了关联。
- 多 App 场景测试:两个 App 都装上,分别点击不同前缀的链接,看唤起是否正确。
提示:iOS 对 AASA 有缓存,改完配置后不是立即生效。开发阶段可以在设备上删除 App 重装来强制刷新,或者用开发者模式下的"Associated Domains Development"开关。
5. 那些文档里不会写的坑与排查实录
5.1 坑一:两个 App 都装时,链接随机唤起
这是最典型的问题。根因就是两个 App 的匹配条件有重叠,系统无法判定优先级。排查方法是逐条对比两个 App 的 AASA 配置,看有没有路径或参数的交集。
我遇到过一个隐蔽的情况:主 App 配了/share/*,极速版配了/share/*但加了exclude某个子路径。表面看极速版排除了部分路径,但剩下的路径还是和主 App 重叠,结果就是重叠部分随机唤起。解决办法就是让两个 App 的匹配条件完全互斥,不留任何交集。
5.2 坑二:query 参数顺序导致匹配失败
前面提过,components的 query 匹配对参数顺序敏感。我实测过一组数据:
| 链接 | 配置 | 结果 |
|---|---|---|
?client=main&from=wx | ?client=main* | 匹配成功 |
?from=wx&client=main | ?client=main* | 匹配失败 |
?client=main | ?client=main* | 匹配成功 |
所以服务端生成链接时,必须固定参数顺序,把用于匹配的参数放在最前面。这个规则苹果文档里没明说,是实测出来的。
5.3 坑三:AASA 更新后老用户不生效
AASA 的更新不是实时的。iOS 会在特定时机拉取,比如 App 安装、系统更新、或者苹果认为需要刷新的时候。老用户可能几天甚至几周都用的是旧配置。
应对办法有两个:一是服务端做兼容,新老配置的路径都支持,通过重定向过渡;二是引导用户更新 App,新版本 App 安装时会重新拉取 AASA。我一般两个一起用,服务端兼容保底,App 更新加速。
5.4 坑四:CDN 和反向代理的干扰
AASA 文件经过 CDN 或 Nginx 反向代理时,容易出现 Content-Type 被改写、路径被重写、或者返回了缓存的情况。排查时直接用 curl 打源站和打 CDN 各一次,对比返回头。
Nginx 的配置参考:
location /.well-known/apple-app-site-association { default_type application/json; add_header Cache-Control "no-cache"; alias /path/to/apple-app-site-association; }关键是default_type application/json和禁用缓存。CDN 侧要把.well-known目录加入不缓存名单。
5.5 坑五:iOS 版本差异导致的匹配行为不一致
iOS 13、14、15、16 在 Universal Link 的匹配细节上有差异。比如components的 query 通配在 iOS 13 上支持不完整,iOS 14 之后才稳定。如果 App 要支持 iOS 13,建议用路径前缀方案,别用 query 参数。
我的做法是维护一个版本兼容矩阵,记录每个方案在各 iOS 版本上的实测表现,新方案上线前先在各个版本的真机上跑一遍。这个矩阵是团队内部积累的,比任何文档都靠谱。
6. 多 App 矩阵下的长期维护建议
6.1 把 AASA 配置纳入版本管理
AASA 文件不要手动改,纳入 Git 管理,每次改动走 Code Review。因为一个字符的错误就可能导致整个 Universal Link 失效,人工改太容易出错。我见过有人把appID里的 Team ID 写错一位,排查了一整天才发现。
6.2 建立自动化验证脚本
写个脚本,每次 AASA 部署后自动验证:
#!/bin/bash URL="https://yourdomain.com/.well-known/apple-app-site-association" CONTENT_TYPE=$(curl -sI "$URL" | grep -i content-type) STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL") if [[ "$STATUS" != "200" ]]; then echo "FAIL: status is $STATUS" exit 1 fi if [[ "$CONTENT_TYPE" != *"application/json"* ]]; then echo "FAIL: content-type is $CONTENT_TYPE" exit 1 fi echo "PASS"这个脚本挂到 CI 里,每次部署自动跑,能挡掉大部分低级错误。
6.3 新增客户端时的检查清单
每次新增一个客户端 App,按这个清单过一遍:
- 新 App 的 Bundle ID 是否已加入 AASA 的
details数组。 - 新 App 的匹配路径是否与现有 App 完全互斥。
- 新 App 的 Xcode Associated Domains 是否配置正确。
- 服务端生成链接的逻辑是否已支持新客户端的前缀或参数。
- 历史链接是否需要重定向兼容。
- 各 iOS 版本真机测试是否通过。
这套流程跑顺了,多 App 矩阵的 Universal Link 维护就不再是玄学问题,而是可预期、可验证的工程问题。
6.4 一个容易被忽略的细节:App 卸载重装后的行为
用户卸载 App 再重装,AASA 会重新拉取,这本来是好事。但如果用户在卸载期间点击了分享链接,链接会落到 Safari,Safari 里可能引导去 App Store。用户装完 App 后,之前那条链接不会自动唤起 App,需要用户重新点击。这个体验断点没法完全消除,但可以在 Safari 落地页做个提示:"已安装?点击这里重新打开",用 JS 尝试唤起,提升转化。
这个细节在分享裂变场景里影响挺大,尤其是邀请奖励类的功能,用户装完 App 发现没拿到奖励,容易流失。我的做法是在落地页存一个 cookie,记录用户是从哪条链接来的,App 首次启动时通过剪贴板或者服务端接口把归因补上。虽然苹果对剪贴板的限制越来越严,但服务端归因这条路一直有效。
7. 关于匹配优先级的一个反直觉结论
最后分享一个我实测下来觉得挺反直觉的点:在匹配条件完全相同时,先安装的 App 并不总是赢。我做过一组对照实验,在同一台设备上,先装主 App 再装极速版,和先装极速版再装主 App,点击同一条无区分参数的链接,唤起结果并不稳定遵循"先装先赢"。有时候系统会唤起后装的,有时候唤起先装的,甚至同一台设备不同时间点结果都不一样。
这说明苹果的匹配在条件相同时,底层可能还依赖一些我们无法控制的缓存或索引状态。所以不要试图依赖安装顺序来区分客户端,这是不可靠的。唯一可靠的做法就是让匹配条件互斥,让系统没有歧义可判。这也是我为什么一直强调路径前缀方案的原因——它把"该唤起谁"这个问题在配置层面就解决掉了,不留给系统去猜。
如果你现在正被多客户端共用链接的问题困扰,先别急着调代码,回去把两个 App 的 AASA 配置逐字对比一遍,看看匹配条件有没有交集。十有八九,问题就出在那里。