1. 为什么需要 Universal Links:核心思路拆解
1.1 URL Scheme 的历史局限
在Universal Links出现之前,iOS上要让App响应外部链接,主流做法是在Info.plist里注册URL Scheme,比如myapp://。这个方案最直观,但问题也很扎眼。第一,用户点击myapp://product/123时,如果手机压根没装这个App,系统会弹出一个类似"Safari打不开该网页,因为网址无效"的提示,一个让人莫名其妙的错误页,对运营和用户来说都是劝退级别。第二,就算装了App,系统也会弹一次"打开App吗?"的确认框,多了一次交互,在转化链路里流失率肉眼可见。第三,URL Scheme在不少大流量的第三方App内置浏览器里会被直接拦截,分享出去的内容经常变成一串无法点击的文本。这些问题在移动端网页与App深度打通的场景里非常致命,于是Apple在iOS 9推出了Universal Links。
1.2 Universal Links 的核心工作原理
Universal Links的思路是:放弃自定义协议,直接使用HTTPS URL作为"入口",然后通过服务器上一个特殊JSON文件,声明"我这个域名下某些路径,归某个App处理"。当用户点击链接时,iOS系统会先去获取这个JSON文件,判断当前安装的App是否声明了该域名,如果声明了就直接拉起App,并把完整链接交给你;如果没有声明或者App没安装,链接就在Safari里当作普通网页打开。这个机制把"链接能否唤起App"的判断权从客户端挪到了系统侧,对用户而言整个过程是无感的,不需要确认弹窗,也不会出现打开失败的错误页。
1.3 适合什么场景,不适合什么场景
如果你正在做这些需求,可以考虑Universal Links:邮件、短信、社交分享卡片里的落地页需要直接进App对应页面;网页端和App端需要共享同一套链接体系;需要在App未安装时让用户看到H5页面而不是报错。反过来,如果只是想App内部跳转模块,或者只想让App接受某个自定义协议的数据,URL Scheme依然有它的位置。实际项目里两者经常共存,Universal Links作为主链路,URL Scheme作为保底兼容。明确这一点很重要,接着往下配置时才不会在"为什么两种链接都要处理"这个问题上纠结。
2. 动手前的准备工作:别急着改代码
2.1 硬性前置条件清单
配置Universal Links不是纯粹的客户端活儿,它牵扯到开发者账号、服务器、域名证书三块。硬性条件整理如下:
| 条件 | 说明 | 缺了会怎样 |
|---|---|---|
| Apple开发者账号 | 需要付费账号,且App ID开启Associated Domains能力 | 无法添加关联域名 |
| HTTPS域名+有效TLS证书 | 必须是可以公开访问的HTTPS地址,用户访问不能出现证书警告 | AASA文件拉取失败 |
| 可写文件的服务器 | 需要在域名根目录或.well-known目录放静态JSON文件 | 系统找不到声明 |
| Xcode与真机 | 建议使用真机调试,模拟器对Universal Links支持不完整 | 测试结果不可靠 |
这里的证书不是随便一个自签名证书或测试证书就行,必须是系统信任的CA签署的证书,否则iOS根本不会去拉取AASA文件。有些开发者第一次配置,拿公司内网域名或者本地环境试,折腾半天没用,基本就是这个原因。
2.2 域名和服务器选型
先说域名。Universal Links的域名不要求必须是App主域名,你可以用一个子域名专门给App用,比如links.example.com,然后把这个子域名指向一个静态文件服务器。这样做的好处是,AASA文件、路径匹配规则都收敛在这一个域下,不会和其它网页CDN配置打架,出问题也好排查。更关键的是,AASA文件是全局性的,同一域名下所有App的匹配都在这一个文件里声明,如果混着公司多个App、多个活动页面,文件会越来越复杂。
服务器这边,最简单的方案是静态页面服务,支持自定义Content-Type。常见的Nginx、对象存储、各类静态网站托管服务都可以,只要能控制响应头。AASA文件本质上就是一个静态资源,不涉及任何后端逻辑。
2.3 AASA 文件是什么,为什么它决定成败
AASA(apple-app-site-association)是一份JSON文件,文件名不带任何扩展名,放在服务器上。文件内容的核心是一个applinks对象,里面写清App的Team ID加Bundle ID,以及允许触发跳转的路径列表。它相当于一份"域名到App"的授权声明。系统会在App安装时、用户点击链接时按需获取这个文件。正因为是系统主动拉取,所以文件位置、可访问性、响应头稍有不对,iOS就会认为这个域名跟App没有关联,跳转直接失效。你可以把AASA文件想象成服务器端的一份"白名单",Universal Links机制就是依据这份白名单决定要不要把用户送进App。
3. 完整配置实操:从开发者后台到App内回调
3.1 在开发者后台启用 Associated Domains
第一步是到开发者后台把App的Associated Domains能力打开。进入Certificates, Identifiers & Profiles,找到对应的App ID,在Capabilities列表里勾选Associated Domains,保存。如果你是手动管理描述文件,这个操作之后还要重新生成Provisioning Profile,否则旧Profile不包含这个能力,App装上后系统也不会认。
这里有个容易忽略的点:Team ID(团队ID)不是团队页面上显示的"公司简称",而是十位大写字母数字组合,在Membership详情页或者Xcode的Build Settings里都能查到。后面的AASA文件里要用到它,提前把它记下来,避免配置到一半又回去翻账号。
3.2 在 Xcode 中配置关联域名
Xcode里的操作很简单:选择Target,进入Signing & Capabilities,点左上角的加号,在列表里选Associated Domains,然后把域名加上。格式是固定的,必须以applinks:开头,比如applinks:links.example.com,注意不要带https://,也不要写路径。
可以一次性配多个域名,测试域名、生产域名、备用域名都加进去。多个域名同时配的时候,服务器上每个域名都要有一份对应的AASA文件,不是只在主域名放一份就够。另外提醒一句,Associated Domains这个Capability一旦加上,Xcode在自动签名模式下会去同步App ID,如果发现签名报错,优先检查开发者后台的App ID状态和当前使用的描述文件。
3.3 生成并部署 AASA 文件
AASA文件应该长这个样子:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.example.MyApp", "paths": ["*"] } ] } }apps数组属于历史遗留字段,留空数组即可。appID必须是"Team ID + 点 + Bundle ID"的完整格式,大小写、标点都不能错。paths定义匹配路径,*代表匹配所有路径。你需要把文件部署到下面两个位置中的一个,任选其一即可,建议用/.well-known/目录,更规范:
https://links.example.com/apple-app-site-associationhttps://links.example.com/.well-known/apple-app-site-association
部署完先别急着去点链接,用curl验证一下:
curl -i https://links.example.com/.well-known/apple-app-site-association重点看两个地方:响应头里Content-Type建议为application/json,响应体是不是规范的JSON。如果服务器返回的是application/octet-stream,或者文件被压缩、被重定向到了其它地址,iOS很可能直接判定不通过。还有一个细节:文件不要加.json后缀,访问路径里出现了任何多余字符,iOS都不会认。
3.4 App 侧处理 Universal Links 回调
AASA文件只是告诉iOS"这个域名归这个App管",真正把URL转成应用内页面,还需要App自己处理。Universal Links进入App后,系统会构造一个NSUserActivity,activityType为NSUserActivityTypeBrowsingWeb,链接放在webpageURL里。如果项目用的是UIApplicationDelegate生命周期,在AppDelegate里实现:
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return false } route(url: url) return true }如果项目用了iOS 13之后的Scene生命周期,还需要在SceneDelegate里实现同名方法:
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return } route(url: url) }这里有个常见误区:很多开发者只改了一份,结果一会儿能跳一会儿不能跳。如果你的App启动了SceneDelegate,旧的UIApplicationDelegate方法可能会被绕过去,两边都写逻辑时又可能重复路由。比较好的做法是把路由逻辑抽成一个单独方法,不管是哪个Delegate进来,都往一个入口走。
3.5 路径匹配规则与路由分发
实际项目里,当然不希望域名下所有路径都跳App,比如https://links.example.com/help这种帮助中心页面就不适合跳。paths规则支持几种通配写法:
| 写法 | 含义 |
|---|---|
/ | 只匹配域名根路径 |
* | 匹配任意路径,包含空路径 |
/products/* | 匹配/products/123、/products/abc等 |
/products/??? | 匹配固定三位字符的路径段 |
not/so/simple | 字面路径,严格匹配 |
路径匹配是大小写敏感的,并且不包含query参数。想区分query里不同值,只能在App内拿到完整URL后自行解析。路由分发时,我习惯维护一个路径到页面模块的映射:
func route(url: URL) { guard let host = url.host, host == "links.example.com" else { return } let components = url.pathComponents if components.count >= 3, components[1] == "products" { let productId = components[2] // 跳转商品详情页 } else if components.count >= 2, components[1] == "activity" { // 跳转活动页 } else { // 默认落地页 } }这种硬编码在页面少时没问题,页面多起来建议改造成注册式的路由表。关键是拿到URL后先解析Host,防止其它域名混进来时被误处理。
4. 测试、排查与调试实录
4.1 一套能落地的验证流程
配置完成之后,建议按下面这个顺序验证,不要上来就猛点链接。
第一步,先在服务端验证AASA文件。用Safari地址栏直接访问AASA文件地址,或者用curl确认文件内容、响应头,确保返回的是JSON而不是错误页。第二步,把App装到真机上,注意如果之前装过旧版本,先卸载再装,避免缓存影响。第三步,在备忘录里输入一条链接,例如https://links.example.com/products/123,直接点击。如果配置正确,系统会直接唤起App;如果只是想确认关联关系有没有建立,可以长按链接,在弹出的预览卡片上通常能看到App的名称图标。第四步,进入App后检查是否落到商品详情页,这一步验证的是route(url:)的处理逻辑。
4.2 高频问题速查表
| 现象 | 常见原因 | 检查方向 |
|---|---|---|
| 点击链接直接开网页,不唤起App | AASA未生效 | 用Safari手输AASA文件地址看JSON是否完整 |
| 同上 | paths规则没覆盖当前路径 | 把paths临时改成["*"]验证 |
| 同上 | App ID或Team ID写错 | 对照开发者后台逐字符核对 |
| 同上 | 域名和Xcode里的applinks:值不一致 | 确认子域名没写错 |
| 同上 | 证书不是受信任CA签发 | 用浏览器打开链接看是否报证书警告 |
| App能唤起,但没进指定页面 | 路由方法没调用或逻辑写错 | 在continue方法里打日志确认webpageURL |
| 修改AASA后老配置还生效 | iOS缓存未更新 | 卸载App重装,必要时重启设备 |
| 服务器返回了文件但iOS不认 | Content-Type错误 | curl查看响应头,把类型设为application/json |
| 服务器返回了文件但iOS不认 | 域名重定向到其它页面 | 关闭跳转,确保直接访问返回JSON |
4.3 调试工具与绕坑心得
调试Universal Links,我最常用的就是curl加Safari手输网址两种,不需要额外特殊工具。curl的作用是定位是不是服务端问题,Safari验证则是模拟真实环境里iOS会怎么拉文件。
还有几个额外提醒。第一,AASA文件不要加过长的CDN缓存策略,如果一定要缓存,确认过期时间不要太长,否则你改完文件上线,用户设备上永远拉的是旧版。第二,Universal Links在微信、QQ这些第三方App的内置浏览器里点链接,大概率不会唤起App,这是对方App行为决定的,不代表你的配置有问题。第三,如果项目里同时接入了其它处理Universal Links的第三方SDK,注意它们可能会抢占NSUserActivity回调,导致你的路由收不到URL,排查时可以先排除第三方SDK的干扰。
4.4 上线前最后一遍自检
上线前再对着这个清单过一遍:开发者后台App ID已开启Associated Domains;Provisioning Profile包含该能力;Xcode里applinks:域名与服务器域名一致;AASA文件能被HTTPS公开访问,无重定向,响应头正常;AASA文件中的appID格式为Team ID加Bundle ID;paths覆盖目标页面;AppDelegate/SceneDelegate的回调已实现且无重复处理。任何一项对不上,线上就会出问题,而线上问题因为AASA缓存的原因,回滚和修复都特别慢,所以宁可上线前多花十分钟检查。
我个人在实际配置中的体会是,Universal Links的坑大多不在客户端代码,而在服务器配置和缓存。曾经有一次排查了半天,最后发现是服务器给JSON文件做了gzip压缩,个别网络环境下iOS拿到的是乱码。那次之后我养成了一个习惯:先把AASA当作普通接口去curl,确认响应头和返回体都正常,再回设备上点链接验证。这个顺序能把后端和客户端的问题彻底分开,省下很多时间。