1. 从一个真实场景说起:为什么多个客户端要共用一条拉起链接
做过iOS端分享拉新或者跨端跳转的同学,大概率都遇到过这样一个需求:市场部给了一条推广链接,希望A应用、B应用、甚至H5页面都能通过这一条链接把用户拉起来,而且拉起之后还要能区分是谁拉起来的、要跳到哪个具体页面。听起来简单,但真到配置Universal Link的时候,坑就一个接一个冒出来了。
Universal Link(通用链接)是苹果在iOS 9之后推出的一种通过HTTPS链接直接唤起App的机制。它最大的好处是:不需要像URL Scheme那样弹一个"是否打开某某App"的确认框,用户点击链接就能丝滑进入App,体验上几乎和网页跳转一样自然。但它的配置门槛也比URL Scheme高得多——需要在苹果开发者后台配置Associated Domains、需要在服务器上放置apple-app-site-association文件(简称AASA文件)、需要处理路径匹配规则,任何一环出问题,链接就拉不起来。
而"多个客户端共用同一条拉起链接"这个需求,把难度又往上抬了一档。因为默认情况下,一条Universal Link只能对应一个App的Team ID和Bundle ID组合。如果你有多个App(比如主App、极速版、商家端、骑手端),它们各自有不同的Bundle ID,却想共用同一个域名下的同一条链接,就必须在AASA文件里做文章,让苹果的匹配机制能同时识别多个App。
这篇文章我会从原理讲到实操,把多客户端共用Universal Link的完整配置流程、路径匹配的坑、AASA文件的写法、以及我实际踩过的几个大坑都掰开揉碎讲清楚。不管你是刚接触Universal Link的新手,还是已经配过单App、现在要扩展到多App的老手,都能从里面找到能直接抄作业的东西。
2. Universal Link的工作机制与多客户端共用的核心矛盾
2.1 一条链接是怎么被系统"认领"的
要理解多客户端共用的难点,得先搞清楚Universal Link的匹配流程。整个链路大致是这样的:
- 用户在任意位置(短信、网页、其他App)点击一条
https://yourdomain.com/path的链接。 - iOS系统拦截这个点击,去本地缓存里查找这个域名对应的AASA文件记录。
- 系统拿当前设备上已安装的所有App,逐个比对AASA文件里声明的
appID(TeamID.BundleID)和paths规则。 - 如果某个App的
appID匹配上,且点击的路径也命中该App声明的paths,系统就直接唤起这个App,并把完整URL传给App的continueUserActivity回调。 - 如果没有任何App匹配,链接就正常用Safari打开。
关键点在于第3步:系统是拿"设备上已安装的App"去和AASA文件做匹配的。也就是说,AASA文件里必须同时声明所有希望被这条链接唤起的App的appID,并且每个App的paths规则要写清楚它负责响应哪些路径。
这里有个容易被忽略的细节:AASA文件的缓存机制。iOS系统不会每次点击都去服务器拉最新的AASA文件,而是会在App安装时拉一次,之后由系统按自己的节奏(通常是几天,或者App更新时)刷新。苹果的CDN也会缓存AASA文件。这就导致你改了AASA文件之后,可能几个小时甚至几天都不生效。这是后面排查问题时必须记住的一条。
2.2 多客户端共用的三种典型诉求
在实际项目里,"多个客户端共用一条链接"通常对应三种不同的业务诉求,配置方式也不一样:
- 诉求一:同一App的不同变体。比如主App和极速版,Bundle ID不同但功能高度重合,希望同一条链接在装了主App的设备上拉起主App,装了极速版的设备上拉起极速版。这种是最常见的。
- 诉求二:不同角色的App。比如用户端和商家端,同一条营销链接,用户点了进用户端,商家点了进商家端。这种通常需要结合路径或者参数来区分。
- 诉求三:App和H5兜底。装了App就拉起App,没装就用H5打开对应页面。这是Universal Link的天然能力,但多App场景下兜底逻辑会更复杂。
这三种诉求的核心矛盾是一样的:一条链接、一个域名、一份AASA文件,却要服务多个appID。解决思路就是在AASA文件里用数组的方式声明多个App,并给每个App分配清晰的路径规则,避免路径重叠导致的匹配歧义。
2.3 为什么不能简单粗暴地让所有App声明所有路径
有同学可能会想:那我干脆让每个App的paths都写成["*"],不就都能拉起来了吗?理论上可行,但实际会出问题。
当多个App都声明了["*"],系统在匹配时如果发现多个App都命中,行为是不确定的——苹果没有明确文档说明这种情况下会优先拉起哪个。实测下来,通常是按AASA文件里App声明的顺序,或者按App安装时间,但这个顺序并不可靠,不同iOS版本表现还不一样。所以正确做法是:用路径把不同App的职责区分开,让每条路径只对应一个明确的App。
3. AASA文件的正确写法:多App声明的完整配置
3.1 AASA文件的基本结构
AASA文件是一个纯JSON文件,必须放在域名的根目录下,路径固定为https://yourdomain.com/.well-known/apple-app-site-association。注意几个硬性要求:
- 文件必须通过HTTPS访问,不能有重定向(301/302都不行,必须是200直接返回)。
- Content-Type必须是
application/json,不能是text/plain。 - 文件不能超过128KB(未压缩)。
- 不需要
.json后缀,文件名就是apple-app-site-association。
一个支持多App的AASA文件结构大概长这样:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID1.com.example.app.main", "paths": ["/share/*", "/promo/*", "/home/*"] }, { "appID": "TEAMID2.com.example.app.lite", "paths": ["/share/*", "/promo/*"] }, { "appID": "TEAMID3.com.example.app.merchant", "paths": ["/merchant/*", "/shop/*"] } ] } }这里有几个关键点要展开说。
apps字段必须是一个空数组,这是苹果的历史遗留字段,现在填任何东西都会被忽略,但必须存在且为空。
details是一个数组,每个元素代表一个App的声明。appID的格式是TeamID.BundleID,TeamID是苹果开发者账号的团队ID(10位字符),BundleID就是App的包名。这两个必须完全匹配,错一个字符都匹配不上。
paths是路径规则数组,支持通配符*和?。*匹配任意多个字符,?匹配单个字符。注意路径是区分大小写的,/Share/*和/share/*是两条不同的规则。
3.2 多App路径分配的实战策略
路径分配是整套配置里最需要动脑子的地方。我的经验是遵循"先分域、再分路径"的原则。
所谓"分域",是指如果不同App的业务差异大,尽量用不同的路径前缀把它们隔开。比如用户端用/u/*,商家端用/m/*,这样AASA文件里各App的paths完全不重叠,匹配逻辑最清晰,也最不容易出问题。
如果业务上确实需要共用某些路径(比如/share/*这种营销链接,用户端和极速版都要响应),那就让多个App声明同一条路径。这时候系统会优先拉起哪个?实测下来,如果两个App都装了,通常是AASA文件里排在前面的那个App被拉起。但这个行为不是苹果官方保证的,所以如果你的业务强依赖"必须拉起某一个",就不要让路径重叠,而是通过URL参数在App内部再做一次判断。
我一般会这样设计路径:
| 路径前缀 | 负责App | 用途 |
|---|---|---|
/share/* | 主App、极速版 | 分享拉新,两者都响应 |
/promo/* | 主App、极速版 | 活动推广,两者都响应 |
/merchant/* | 商家端 | 商家专属,独占 |
/rider/* | 骑手端 | 骑手专属,独占 |
/web/* | 无(H5兜底) | 不声明,走网页 |
这样设计的好处是:营销类链接多端共用,业务类链接各端独占,逻辑清晰,后期扩展也方便。
3.3 关于components字段的补充说明
苹果在iOS 13之后引入了新的components字段,用来替代paths,功能更强大,支持查询参数和片段匹配。写法是这样的:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID1.com.example.app.main", "components": [ { "/": "/share/*", "?": { "channel": "?*" }, "comment": "分享链接,必须带channel参数" } ] } ] } }components里每个对象可以指定/(路径)、?(查询参数)、#(片段)的匹配规则,还能用exclude字段排除某些情况。功能确实比paths强,但兼容性上要注意:iOS 13以下的设备不认components,只认paths。所以如果你的App还要支持iOS 12及以下,建议paths和components同时写,让老系统走paths,新系统走components。
不过现在iOS 12以下的设备占比已经极低了,新项目基本可以只用components。但如果你的用户群体里有大量老设备,还是稳妥点两个都写。
4. 苹果开发者后台与Xcode的配置实操
4.1 开发者后台开启Associated Domains
AASA文件配好了,还得在苹果开发者后台给每个App开启Associated Domains能力。步骤是:
- 登录开发者后台,进入Certificates, Identifiers & Profiles。
- 找到对应的App ID(注意是App ID,不是Bundle ID,虽然两者通常一致)。
- 在Capabilities列表里勾选Associated Domains。
- 保存后,如果用的是手动签名,需要重新生成Provisioning Profile并下载。
这一步每个App都要单独做。多个App就重复多次。这里有个坑:如果你用的是自动签名(Automatically manage signing),Xcode会自动帮你处理,但前提是你在Xcode里配置了正确的Team。
4.2 Xcode里的Associated Domains配置
在Xcode里,选中Target,进入Signing & Capabilities,添加Associated Domains能力,然后在Domains列表里添加:
applinks:yourdomain.com注意格式:必须是applinks:前缀,后面跟域名,不要带https://,不要带路径,不要带末尾斜杠。正确的写法是applinks:yourdomain.com,错误的写法包括applinks:https://yourdomain.com、applinks:yourdomain.com/、applinks:yourdomain.com/path。
如果你有多个域名,就加多行。如果同一个域名下有多个App,每个App的Target里都要加这一行。
这里有个细节:如果你在开发阶段想同时支持测试域名和生产域名,可以两个都加上:
applinks:yourdomain.com applinks:test.yourdomain.com但要注意,测试域名的AASA文件也要配好,否则测试链接拉不起来。
4.3 多Target项目的配置要点
如果你的项目是一个工程多个Target(比如主App和极速版共用一个代码库),那每个Target都要单独配置Associated Domains。Xcode里切换Target,分别添加。这时候容易出现的问题是:某个Target忘了加,导致那个App的链接拉不起来,排查半天才发现是配置漏了。
我的做法是做一个配置检查清单,每次新增App或者改域名时,逐项核对:
- [ ] 开发者后台该App ID是否开启Associated Domains
- [ ] Xcode该Target是否添加
applinks:域名 - [ ] AASA文件是否声明了该App的
appID - [ ] AASA文件该App的
paths是否覆盖了目标路径 - [ ] Provisioning Profile是否包含Associated Domains权限
这五步任何一步漏了,链接都拉不起来。而且报错信息往往很模糊,不会直接告诉你哪一步错了,所以清单核对是最靠谱的办法。
5. 服务端AASA文件的部署与验证
5.1 部署位置与服务器配置
AASA文件必须放在https://yourdomain.com/.well-known/apple-app-site-association。注意.well-known是个隐藏目录,很多服务器默认不允许访问以点开头的目录,需要单独配置。
以Nginx为例,需要确保:
location /.well-known/apple-app-site-association { default_type application/json; add_header Content-Type application/json; root /var/www/html; }关键点是default_type application/json,确保返回的Content-Type正确。有些服务器配置会把无后缀文件当成application/octet-stream返回,苹果的爬虫就不认了。
另外要确保这个路径不被重定向。如果你的站点有全站HTTPS跳转或者www跳转,要确保AASA文件的请求不会被跳转。苹果的爬虫不跟随重定向。
5.2 验证AASA文件是否可访问
部署完之后,用curl验证:
curl -I https://yourdomain.com/.well-known/apple-app-site-association期望看到的是HTTP/2 200和content-type: application/json。如果看到301/302,说明有重定向,要修。如果看到content-type: text/plain或application/octet-stream,说明Content-Type不对,也要修。
再验证内容:
curl https://yourdomain.com/.well-known/apple-app-site-association确认返回的JSON格式正确,没有语法错误。可以用在线的JSON校验工具过一遍,或者本地用python -m json.tool验证。
5.3 苹果CDN缓存与刷新
AASA文件会被苹果的CDN缓存。你更新文件后,CDN不会立即刷新。苹果官方没有提供主动刷新CDN的接口,只能等。通常几小时内会刷新,但极端情况下可能要一两天。
在开发调试阶段,这个缓存很让人头疼。有几个应对办法:
- 用测试域名,每次改配置换个新域名,避开缓存。
- 在设备上删除App重装,会触发重新拉取AASA文件。
- 在设备设置里关闭再开启"开发者模式"下的相关选项(不同iOS版本路径不同),有时能触发刷新。
实测下来,删除App重装是最可靠的方式。所以调试阶段我一般会频繁重装App来验证配置。
6. App端的链接处理与多客户端分发逻辑
6.1 接收Universal Link的回调
App端接收Universal Link的回调有两个:
// iOS 13及以上,SceneDelegate func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return } handleUniversalLink(url) } // iOS 13以下,AppDelegate func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return false } handleUniversalLink(url) return true }如果你的App同时支持iOS 13以上和以下,两个都要实现。iOS 13以上走SceneDelegate,以下走AppDelegate。如果只实现了AppDelegate,在iOS 13以上的设备上可能收不到回调。
6.2 多客户端的分发逻辑设计
当多个App共用一条链接时,每个App收到的URL是一样的。App需要自己解析URL,判断这个链接是不是给自己的,以及要跳转到哪个页面。
我的做法是在URL里带足够的参数,让App能自主判断:
https://yourdomain.com/share/activity?channel=wechat&target=detail&id=12345&client=mainApp收到后,解析client参数,如果和自己的身份匹配就处理,不匹配就忽略(理论上不会发生,因为系统已经按AASA匹配过了,但作为兜底逻辑保留)。
然后根据target和id参数,路由到对应的页面。这套逻辑建议封装成一个统一的路由模块,所有App共用同一套解析代码,避免各端逻辑不一致。
6.3 冷启动和热启动的处理差异
Universal Link在App冷启动和热启动时的回调时机不一样,这是个大坑。
冷启动时,App还没起来,系统会先启动App,然后在didFinishLaunchingWithOptions之后调用continueUserActivity。这时候如果你的首页初始化逻辑比较重,可能会在回调到达之前就渲染了首页,导致用户看到首页闪一下才跳到目标页。
热启动时,App已经在后台,系统直接调用continueUserActivity,这时候处理起来相对简单。
我的处理方式是:在App启动时先判断是不是通过Universal Link启动的,如果是,就先不渲染首页,等回调到了再决定跳哪里。具体做法是在didFinishLaunchingWithOptions里检查launchOptions[.userActivityDictionary],如果有值,说明是Universal Link启动,设置一个标志位,首页初始化时根据标志位决定是否延迟渲染。
这个细节不做的话,用户体验会很差——点个链接,App打开先闪一下首页,再跳到目标页,感觉像卡了一下。
7. 常见问题排查与避坑经验实录
7.1 链接拉不起来的分层排查法
Universal Link拉不起来是最常见的问题,排查起来要有章法。我总结了一个从外到内的排查顺序:
| 排查层级 | 检查项 | 常见问题 |
|---|---|---|
| 第一层:AASA文件 | 能否HTTPS访问、Content-Type、JSON格式 | 重定向、Content-Type错误、JSON语法错 |
| 第二层:appID匹配 | TeamID和BundleID是否正确 | TeamID写错、BundleID大小写不一致 |
| 第三层:路径匹配 | paths是否覆盖目标路径 | 路径大小写、通配符位置错误 |
| 第四层:App配置 | Associated Domains是否开启 | 开发者后台没开、Xcode没配 |
| 第五层:系统缓存 | AASA是否被缓存 | 改了没生效,需重装App |
按这个顺序逐层排查,基本能定位到问题。我遇到过最坑的一次是TeamID写错了一位,排查了两天才发现,因为报错信息完全不提示这个。
7.2 路径匹配的常见陷阱
路径匹配有几个容易踩的坑:
- 大小写敏感:
/Share/*和/share/*不匹配。URL路径是区分大小写的,AASA里的paths也要严格对应。 - 通配符位置:
/share/*能匹配/share/abc和/share/abc/def,但/share*会匹配/shareabc,语义不同。要明确你的意图。 - 末尾斜杠:
/share/*不匹配/share(没有斜杠),如果需要匹配,要单独加一条/share。 - 查询参数:
paths不匹配查询参数,/share/*能匹配/share/abc?x=1,因为查询参数不参与路径匹配。但如果用components,可以用?字段匹配查询参数。
7.3 多App场景下的优先级问题
前面提到过,多个App声明同一路径时,系统拉起哪个App是不确定的。我实测过几次,结论是:
- 如果只有一个App安装了,那毫无疑问拉起它。
- 如果多个App都安装了,通常拉起AASA文件里排在前面的那个,但这个顺序在不同iOS版本上表现不一致。
- 如果AASA文件里App顺序变了,拉起结果也可能变。
所以如果你的业务强依赖"必须拉起某个特定App",就不要让路径重叠。正确做法是给不同App分配不同路径,或者用URL参数在App内部做二次判断。
7.4 调试工具与日志技巧
调试Universal Link时,有几个实用技巧:
- 用苹果自带的
swcutil工具(在设备上通过特定方式调用)可以查看系统缓存的AASA文件内容,确认系统拉到的是不是你最新的配置。 - 在App里打日志,把收到的URL完整打印出来,确认回调有没有触发、URL是什么。
- 用Safari的开发者工具连接设备,可以看网络请求,确认AASA文件有没有被拉取。
- 如果条件允许,用真机调试,模拟器对Universal Link的支持不完整,很多问题在模拟器上复现不了。
我个人最常用的是在continueUserActivity回调里打日志,配合删除App重装,基本能覆盖大部分调试场景。
8. 多客户端共用的进阶优化与扩展思路
8.1 用中间页做统一入口
如果你的App数量多、路径规则复杂,可以考虑加一个中间页做统一入口。所有链接先指向一个H5中间页,中间页根据User-Agent、URL参数等判断该跳哪个App,然后用Universal Link或者URL Scheme拉起。
这样做的好处是:AASA文件只需要声明中间页的路径,各App的路径规则可以简化;中间页可以做更灵活的兜底逻辑,比如没装App就引导下载。坏处是多了一次跳转,体验上略慢,而且中间页本身要处理各种边界情况。
8.2 动态AASA文件的实现
如果你的App列表经常变,或者需要按地区、按渠道返回不同的AASA文件,可以做一个动态的AASA接口。服务器根据请求的来源(比如User-Agent、IP地区)动态生成AASA内容返回。
但要注意,苹果的爬虫拉取AASA文件时,请求头里不一定带足够的信息让你区分。而且CDN缓存会让动态逻辑失效。所以动态AASA适合做灰度或者A/B测试,不适合做核心的匹配逻辑。
8.3 与URL Scheme的配合使用
Universal Link虽然体验好,但有个限制:只能通过用户点击触发,不能在App内部用代码直接打开另一个App。如果你的场景需要在App内跳转到另一个App,还是得用URL Scheme。
我的建议是两者配合:对外分享的链接用Universal Link,保证点击体验;App内部跳转用URL Scheme,保证可控性。两套机制共用同一套路由解析逻辑,避免维护两份代码。
9. 我踩过的几个真实大坑
第一个坑是AASA文件的Content-Type。我一开始用对象存储托管AASA文件,对象存储默认给无后缀文件返回application/octet-stream,苹果爬虫不认,链接死活拉不起来。后来改成用Nginx托管,显式设置Content-Type才解决。这个坑排查了很久,因为从浏览器访问文件内容是正常的,只有Content-Type不对。
第二个坑是TeamID和BundleID的匹配。我们有个App是从别的团队迁移过来的,TeamID变了,但AASA文件里还写着旧的TeamID,导致这个App的链接一直拉不起来。后来把AASA文件里的appID全部核对了一遍才发现。
第三个坑是路径大小写。我们的分享链接路径是/Share/xxx(首字母大写),但AASA文件里写的是/share/*(小写),结果就是拉不起来。URL路径是区分大小写的,这个细节很容易忽略。
第四个坑是冷启动闪首页。前面提到过,冷启动时App先渲染首页再跳转,用户看到闪一下。后来在启动逻辑里加了判断,如果是Universal Link启动就延迟渲染首页,才解决。
这几个坑的共同点是:报错信息都不明确,只能靠经验和系统排查。所以我把排查清单和验证步骤都固化下来,每次配置新App或者改域名时逐项核对,能省很多时间。
10. 一套可直接复用的配置模板
最后给一套我实际在用的配置模板,多App共用一条链接的场景可以直接参考。
AASA文件模板:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID1.com.example.app.main", "paths": ["/share/*", "/promo/*", "/home/*"], "components": [ { "/": "/share/*" }, { "/": "/promo/*" }, { "/": "/home/*" } ] }, { "appID": "TEAMID1.com.example.app.lite", "paths": ["/share/*", "/promo/*"], "components": [ { "/": "/share/*" }, { "/": "/promo/*" } ] }, { "appID": "TEAMID2.com.example.app.merchant", "paths": ["/merchant/*"], "components": [ { "/": "/merchant/*" } ] } ] } }Xcode Associated Domains配置:
applinks:yourdomain.comApp端路由处理的核心逻辑:
func handleUniversalLink(_ url: URL) { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } let path = components.path let queryItems = components.queryItems ?? [] let params = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name, $0.value ?? "") }) // 根据path分发到不同模块 if path.hasPrefix("/share/") { routeToShare(params) } else if path.hasPrefix("/promo/") { routeToPromo(params) } else if path.hasPrefix("/merchant/") { routeToMerchant(params) } else { routeToHome() } }这套模板覆盖了多App声明、路径分配、App端分发三个核心环节,改改域名和BundleID就能用。实际项目里我还会加一层参数校验和异常兜底,确保任何异常URL都不会导致App崩溃。
配置Universal Link这件事,说到底就是细心加耐心。原理不复杂,但细节多,任何一个小地方出错都会导致整个链路失效。把AASA文件、开发者后台、Xcode、App端这四块都配对了,再配合分层排查法,基本就没有搞不定的场景。多客户端共用一条链接的需求,本质上就是把这四块配置乘以App的数量,然后确保路径规则不冲突。想清楚这一点,剩下的就是按清单逐项落实了。