做iOS开发这几年,Universal Links 是个绕不开的话题。我最早接触它还是因为一个挺尴尬的场景:用户从浏览器点了一个链接,明明装了App,结果跳到了一个中间确认页,还得再点一次"打开App",体验非常割裂。后来把 Scheme 换成了 Universal Links,跳转变成无缝的,整个流程才顺了。这也是我写这篇东西的初衷——把配置应用的 Universal Links 这条路走通,拆开揉碎了讲明白,顺便把我踩过的坑都摆出来。
Universal Links,简单说就是苹果提供的一套"网页与App之间建立可信关联"的机制。它能做到:用户在 Safari 或自带浏览器里点某个特定域名的链接时,如果手机上装了对应的 App,就直接在 App 内打开对应页面;如果没装,就退回到网页。整个过程不需要用户确认,也不会跳去浏览器再弹框,体验上一体感很强。这篇内容不止适合 iOS 开发,如果你负责前端 H5 页面联动、或者要处理 App 与外链跳转的产品逻辑,也建议完整看完,因为很多跳转链路上的坑,往往是前后端各自视角下都看不见的。
1. 为什么必须扔掉 Scheme 改用 Universal Links?
很多刚接触 Universal Links 的人会先问一句:以前用 scheme(比如 myapp://)不也能唤起 App 吗?确实能,但 Scheme 的痛点,做过的都知道很疼。
第一个痛点是系统弹窗。用自定义 Scheme 唤起 App 时,iOS 会在浏览器里弹一个确认框,问用户"是否要在'我的App'中打开此页面?",很多用户看到这个弹窗就犹豫了,转化率肉眼可见地下降。第二个痛点是 Scheme 在 iOS 9 之后,就没法直接在网页里实现无感唤起了,苹果在这个版本里引入 Universal Links 正是为了补掉这个缺口。第三个痛点是 Scheme 的标识比较"脆"——它没法校验域名归属,任何 App 都可以声明同一个 scheme,一旦冲突,系统会弹出选择框甚至直接唤起失败,开发者在排查的时候又费劲又揪心。
Universal Links 把上面这些问题基本都解决了。核心机制是:你的域名和 App 之间通过一个 HTTPS 上的配置文件(apple-app-site-association)建立信任关系,用户在 Safari 里点这个域名下的链接,系统在后台悄悄比对证书和配置,比对通过就直接无缝打开 App,全程无弹窗、无跳转浏览器。而且因为校验基于 HTTPS 证书和域名归属,Scheme 被别的 App 抢注的问题也不存在了。
还有一点特别值得提:Universal Links 是唯一的"从 Safari 无感唤起 App"的标准方案。微信、QQ、支付宝这类超级 App 的内置浏览器里,Universal Links 是不生效的,那些场景依然得靠 Scheme 或者应用宝等多方平台的规则接一把。所以它不是"替换掉所有 Scheme",而是"把 Safari 和系统级浏览器这条主链路上的唤起体验做到最好"。你在做技术选型时,建议把 Universal Links 作为外部链接唤起的第一方案,Scheme 作为内部分发和第三方 WebView 兜底的补充方案,两者配合而不是互相替代。
2. 配置的前置条件:证书、域名和 Associated Domains 的完整链路
Universal Links 的配置不是只在工程里改一行代码,它是一条横跨苹果开发者后台、域名服务器、Xcode 工程三端的完整链路。哪一端出问题,最终表现都是"链接点了不唤起 App"。为了让你心里有数,我先把链路里涉及的每一个前提条件列出来,再逐个讲为什么必须有它。
| 前置条件 | 具体内容 | 缺了会怎样 |
|---|---|---|
| 开发者后台权限 | 拥有 Apple Developer 账号且 App ID 已开启 Associated Domains 能力 | 开发者后台里找不到关联域名的选项 |
| 域名控制权 | 你需要能在这个域名的服务器上放置静态文件 | 无法发布 apple-app-site-association 校验文件 |
| HTTPS 证书 | 域名必须支持 HTTPS,且证书链有效 | 系统抓取配置文件失败,Universal Links 直接不生效 |
| Xcode 能力开关 | Target 里启用 Associated Domains 并填写 applinks:你的域名 | 工程打包后没有相关 entitlement 声明 |
| Team ID | 开发者账号对应的 Team Identifier | apple-app-site-association 文件里无法正确声明 App 归属 |
逐个展开说。
先在苹果开发者后台找到你的 App ID,勾选 Associated Domains 能力。这里有个细节容易被忽略:如果你用的是通配符 Bundle ID(比如 com.example.*),开启 Associated Domains 后它作用于所有匹配的 App。但 Universal Links 的配置文件是按具体 App ID 区分的,通配符会造成多个 App 共用一套匹配规则,除非你有意为之,否则建议先确认目标 App 的 Bundle ID 是明确的,避免后续路径匹配时出现"几个 App 抢同一个链接"的情况。
然后是域名。需要注意,Universal Links 用的是 applinks: 前缀,不是别的。它不代表你要把整个域名权限都给 App,只代表"这个域名的某些路径允许被这个 App 处理"。域名本身要求有一个有效的 HTTPS 证书。原因是苹果需要一个可信、防篡改的通道来拉取配置文件——如果允许 HTTP 明文,中间人就能伪造配置文件,那跳转就完全不安全了。这里顺带提醒一句:自签名证书、证书链不完整、证书过期这类情况,在浏览器里用户努努力还能继续访问,但系统在验证 Universal Links 时是零容忍的,任何一个证书错误都会导致配置抓取失败。我建议在配置之前先确认你的域名 Https 访问在 Safari 里打开地址栏上带不带锁、证书链是不是完整。
在 Xcode 里的操作也不复杂:打开 Target 的 Signing & Capabilities,点加号选 Associated Domains,然后在列表里添加一项 applinks:example.com。注意这里不要写 https://,也不要写 www 前缀,除非你确实需要。Xcode 会自动生成一个叫做 xxx.entitlements 的文件,里面有一个 key 叫 associated-domains,数组里存着你填的这些域名字符串。工程里确认这个 entitlements 文件被包含进了最终构建产物就行。
到这里,左侧三样(账号权限、域名证书、Xcode 能力)都备齐了,剩下的重头戏是那个配置文件。
3. apple-app-site-association 文件:最容易翻车的一环
如果说整个 Universal Links 的配置过程有什么环节最折磨人,那一定是 apple-app-site-association 文件。它的名字长、放置路径有讲究、内容格式也严格,稍微写错一个字段,系统就完全不认。我把这个文件的关键细节拆成几块讲。
3.1 文件格式与字段含义
这个文件本质上是一个 JSON,但文件名不能带 .json 后缀,就叫 apple-app-site-association。内容结构如下:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.example.app", "paths": ["*"] } ] } }字段含义分别说一下。
"apps" 数组在老版本系统里是给 Safari 建议的关联 App 列表用的,现在基本留空即可。真正起作用的是 "details" 数组,里面每一项对应一个 App 的匹配规则。appID 的格式是TeamID.你的BundleID,中间那个点不要写错,TeamID 在开发者后台的 Membership 页面里可以找到,是一串 10 位的大写字母和数字。刚才提到的通配符 Bundle ID 在这里要额外注意:因为 appID 里出现的是整串 Bundle ID,通配符会导致两张皮——后台的 App ID 规则匹配多个 App,但配置文件里只声明了一个 appID,未声明的那几个自然不生效。
paths 数组用来声明哪些路径可以唤起 App。它支持三种模式:
- 精确匹配:比如
/profile,只匹配这个路径。 - 通配符
*:匹配任意字符,包括空字符,/gift/*能匹配/gift/、/gift/123等所有子路径。 - 问号
?:匹配单个任意字符,/gift/?能匹配/gift/1但不会匹配/gift/12。
这里有个很多人容易忽视的细节:系统匹配 path 时会忽略域名后面是否带正斜杠的差异,但如果你想让 "example.com" 这个裸域名也能唤起 App,最好显式把/放进 paths 里,或者用*覆盖,否则用户在地址栏直接输入域名回车时,可能匹配不上。
3.2 文件放置与 MIME 类型
文件必须放在 HTTPS 服务器的两个位置之一:域名根目录,或者/.well-known/目录下。也就是说,以下两个地址都能被苹果接受:
https://example.com/apple-app-site-association https://example.com/.well-known/apple-app-site-association我建议用.well-known目录,因为这是行业通用的约定目录,未来如果要配置其他标准校验文件(比如邮箱校验、支付域名校验),也都能保持一致。根目录那条路,一些云厂商的默认对象存储策略会把无后缀文件当作文本或二进制下载,虽然苹果实际上对 MIME 的容忍度比较高,application/json和application/octet-stream都认,但更稳妥的做法是在托管平台里显式把 MIME 类型设置为application/json,避免某些代理服务器或 WAF 在中间拦一道。
放置完成后,先用浏览器或者 curl 验证一下文件本身能不能访问、内容对不对。这一步不要省,一个常见坑是托管平台在部署时给 JSON 内容自动加了 BOM 头,或者改了换行符,本地看没问题,线上抓就是一串乱码。
curl -i https://example.com/.well-known/apple-app-site-association输出里应该能看到 HTTP 200,以及完整的 JSON 内容。这里我再补一个经验:用 curl 验证时记得加-i查看响应头,重点确认Content-Type是不是 JSON,以及有没有被某些 CDN 重定向走。苹果的系统抓取过程对重定向的容忍度有限,如果服务器 301 到另一个地址,可能会导致匹配失败,排查起来又隐蔽又烦人。
3.3 多 App、多域名与动态生成
一个项目里如果同时有好几个 App,details数组里可以写多个条目,各自配自己的 appID 和 paths。同一个 App 也可以处理多个域名,那就在 Xcode 的 Associated Domains 里多填几行,配置文件里对应的 details 里各自写一遍即可。这也是动态生成配置文件比较推荐的做法:后台接口根据请求的域名动态返回对应的 JSON,省去重复维护多个文件的成本。但注意,动态生成时必须保证接口不会对苹果的抓取请求做 UA 判断或登录拦截,否则又变成了一个隐性大坑。
验证整个配置是否生效,还有一个土办法:把手机升到 iOS 系统版本支持 Universal Links 的版本,然后在 Safari 地址栏直接输入你的域名加对应路径,再拉到底部,看看有没有"在 App 中打开"的提示。有,说明配置OK;没有,基本就可以断定是前面某一环出了问题,接着往下一章排查。
4. 代码侧处理:链接进来之后的路由与导航逻辑
配置文件弄好了,App 也关联上了,但链接点开之后,代码侧如果接不住,用户一样会停在系统跳转的空白页。Universal Links 的代码侧处理,核心就是 AppDelegate 或者 SceneDelegate 里的一个回调:continueUserActivity。
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 }如果你的工程用了 SceneDelegate,就实现对应的代理方法。为什么系统要把这个入口叫"Continue User Activity"而不是"Open URL"?从机制上讲,Universal Links 被系统视为一次浏览行为的延续,它把网页的浏览状态(URL)作为 UserActivity 交给 App。理解这一点,你就不会去想当然地去改openURL的回调——那是 Scheme 的入口,不是给 Universal Links 用的。有人在实际项目里把两个都实现了,结果发现点击 Universal Link 时openURL压根不触发,白忙一场。
拿到webpageURL之后,接下来就是路由逻辑。我个人的做法是把处理统一收口到一个函数里,不要让 AppDelegate 膨胀成一堆 if-else:
func handleUniversalLink(_ url: URL) { guard let host = url.host else { return } let path = url.path let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems switch (host, path) { case ("example.com", "/profile"): navigateToProfile(userID: queryItems?.first(where: { $0.name == "id" })?.value ?? "") case ("example.com", "/gift"): navigateToGiftPage(giftID: queryItems?.first(where: { $0.name == "giftId" })?.value ?? "") default: navigateToHome() } }有几个经验想分享。
第一,路径匹配时不要用url.lastPathComponent这种弱匹配方式,因为参数顺序、末尾斜杠、大小写都可能造成误判,最稳妥的是把url.host和url.path都显式取出来精确匹配。第二,Query 参数建议通过URLComponents解析,不要自己用字符串拆分,因为链接里的转义字符、中文参数会让手工解析直接爆炸。第三,如果网页端和 App 端的路由规则不是同一人维护,一定要有一个"兜底路由"。最典型的问题是 H5 侧新加了活动页路径,但 App 里没同步更新匹配规则,用户点链接进来自动跳到了首页,体验上像是出 bug 了。兜底逻辑通常就是 navigateToHome 这一个分支,至少保证用户能进 App,不白点。
还有冷启动和热启动的区别。这一点 Universal Links 做得比 Scheme 舒服:不管 App 是从后台恢复还是直接冷启动,系统都会把同一个continueUserActivity回调给你,你不用像处理推送那样区分冷热。但有一点要小心:App 第一次启动时,如果你在didFinishLaunchingWithOptions里做了很多初始化逻辑,而这个回调可能在你初始化完成之前进来,就容易出现"页面还没准备好就跳转"的问题。建议在跳转方法内部对根视图控制器的状态做一个判断,如果还没 Ready,可以先缓存 URL,等界面就绪后再路由。
5. 排查链路:当 Universal Links 不生效时我从哪里下手
Universal Links 这个东西,配置正确的路径其实不长,但一旦不生效,排查起来非常容易乱。因为链路长,你不知道是域名没配好、文件没对不对、还是代码没写在正确的入口。我经过几次踩坑之后,总结了一套固定排查顺序,按这个顺序来,基本 10 分钟内能定位到问题。
5.1 症状一:点击链接毫无反应,落在浏览器
这是最常见的故障。我会按下面顺序排查:
第一步,验证域名配置文件。用 curl 请求https://你的域名/apple-app-site-association,确认返回内容里 appID 和 paths 都是对的。这里我养成一个习惯:把配置文件内容输出到一个临时 JSON 文件,再用jq做一次语法校验。因为很多编辑器在保存 JSON 时不会报错,但在线解析工具一验就是语法错误。最典型的是少了个逗号、多余的尾逗号、单引号代替双引号。
第二步,验证 appID 是否拼对。Team ID 和 Bundle ID 中间那个点,容易手滑打成空格或者斜杠。检查的时候直接去开发者后台把 Team ID 复制过来粘贴到 JSON 里,不要手动敲。
第三步,验证 HTTPS 证书。在 Safari 里打开那个文件地址,如果地址栏能正常显示锁型标识,说明证书链是通的,否则系统必然不会抓取成功。这一步常常被忽略——测试环境用的自签名证书,在开发调试时还能正常访问,但系统在处理 Universal Links 时证书信任策略和 Safari 不一致,自签名证书一律失败。
第四步,验证 Associated Domains 是否写进了 entitlements。打开工程里的.entitlements文件,确认里面有associated-domains数组,而且字符串是applinks:你的域名格式。如果用的是自动签名,有时候 Capabilities 列表里加了,但 entitlements 文件没被包含进 target 的 Build Phases,也会导致失败。
5.2 症状二:App 被拉起来了,但页面不对
这类问题通常在路径匹配环节。我见过好几次的情况是:网页里点的链接是https://example.com/h5/events/123,但配置文件里 paths 写的是/events/*,那自然匹配不上。还有一种是大小写问题,路径里有大小写混写,而 paths 匹配是大小写敏感的,也会白搭。
另一个容易出问题的点,是网页端设置了重定向。比如https://example.com/click这个链接打开后会 302 到https://example.com/gift/123,如果配置里只写了/click而没写/gift,那么用户点下去虽然 URL 已经变了,但 Universal Links 匹配的是最终 URL 而不是初始 URL,于是跳转失败。这个问题一般不是 iOS 开发的错,也不是后端的错,纯粹是两边没对齐"到底哪个 URL 承担唤起职责"。沟通时建议让后端约定清楚:负责唤起的链接,请直接返回 200 而不是 302,要跳转也放在 App 内去做。
5.3 症状三:本地调试时怎么模拟都失败
如果你在本地开发时频繁遇到"改完不生效",先别急着怀疑代码。系统对配置文件是有缓存的,你改了服务器上的文件后,iOS 设备不会立刻重新拉取,有时会隔几个小时甚至更久才生效。这时候最有效的办法有两种:一是把 App 删掉重装,触发系统重新校验;二是换一个从来没装过这个 App 的测试机,在干净环境下验证配置本身是否正确。我通常会把"验证配置文件"和"验证测试机"分开做——先用 curl 把服务器文件搞对,再拿一部没装过 App 的测试机点链接,这样能快速确认是缓存问题还是配置问题。
5.4 症状四:链接在微信里点了没反应
这个严格说不算"配置不生效",因为微信内置浏览器不走系统级 Universal Links 流程。用户从微信点开你的链接,实际是一个 WKWebView 在渲染页面。要让用户顺利跳进 App,常规做法是在这个 H5 页面上做一层引导:识别到是微信等内置浏览器环境时,提示用户点击右上角用 Safari 打开,或者直接用 Scheme 兜底拉起(微信对 Scheme 的限制也很多)。这里强调一下:不要在开发群里看到"Universal Links 在微信里失效"就慌,这不是你配置错了,而是第三方 App 的 WebView 天然不吃这套系统机制。
完整的排查顺序,我顺手整理成一个清单,也方便你之后直接对着走:
| 排查项 | 验证方法 | 常见结论 |
|---|---|---|
| 服务器文件 | curl 抓取,jq 校验 JSON | 语法错误、TeamID 拼错 |
| 文件放置位置 | 根目录与 .well-known 都试 | 位置不对直接 404 |
| HTTPS 证书 | Safari 直连,看地址栏锁型 | 自签名、证书链不完整 |
| Associated Domains | 查看 .entitlements 文件 | applinks: 前缀漏写 |
| 路径匹配规则 | 与线上链接路径逐一比对 | 大小写、通配符、重定向 |
| 设备缓存 | 卸载 App 重装 / 干净测试机 | 配置其实没问题 |
6. 进阶玩法:多环境、多域名与跨场景集成
基础配置打通之后,再聊几个真实项目里绕不开的进阶话题。很多人配置完测试环境就上线了,结果线上出了一堆幺蛾子,问题往往集中在下面几个点。
6.1 多环境怎么隔离
开发、测试、生产往往用不同的域名。我的建议是不要共用同一个域名去区分路径,而是每个环境一个独立域名,比如 dev.example.com、test.example.com、app.example.com。这样做的原因是苹果校验时只看域名和配置文件的匹配,如果你的开发环境和生产环境用了同一个域名,那开发调试时一旦把 App 重装,系统抓到的配置文件可能指向生产环境的 appID,测试和生产就完全混在一起了。
每个环境各自放一份 apple-app-site-association,保持文件相互独立。在 Xcode 的 Associated Domains 里,Debug 配置只填 dev 域名,Release 配置只填生产域名。让配置跟着 Build Configuration 走,不要手痒全塞进去。这个细节做好,后面开发效率会高很多。
6.2 多个 App 共享一个域名
某个项目里如果有一主 App 和几个子 App,可以使用同一个域名。配置文件里 details 数组按不同子路径区分归属:
{ "applinks": { "details": [ { "appID": "TEAMID.com.example.main", "paths": ["/main/*"] }, { "appID": "TEAMID.com.example.sub", "paths": ["/sub/*"] } ] } }这里有两个坑需要提醒。第一个是 paths 里尽量不要出现重叠匹配,否则系统会随机选中其中一个 App 唤起,用户时而来主 App 时而来子 App,体验很糟。第二个是 App 之间不要互相写对方的 appID 和路径,配置文件里只声明自己的部分即可,谁家的链接对应谁家的 App,在文件层面就理清。
6.3 与 CDN、对象存储结合时的注意点
有些项目把 apple-app-site-association 放在对象存储或者 CDN 上,省去自己写后端接口的麻烦。这样做完全可行,但要注意两点:一是记得配置 CDN 的缓存规则,让这个文件在发布新版时能及时失效,否则线上配置改了,线上设备却一直用旧缓存,排错排到崩溃都想不到是 CDN 缓存在捣鬼;二是如果 CDN 做了 URL 重写或者防盗链策略,苹果服务器抓取时可能会被识别成"匿名用户"而被拒绝。验证方法依然是 curl 带上正常的 User-Agent 看能不能拿全 JSON。
6.4 统计与打点:别只做跳转不做闭环
Universal Links 做完了之后,产品一定会问:到底有多少用户从外部链接走进了 App?所以建议在handleUniversalLink的第一行就把 URL 上报到统计平台。注意,上报的数据要区分"系统成功唤起"和"用户已安装App",以及"点进来但因为路由兜底去了首页"这几类。不然你会看到转化率数据很好看,但不知道其中有多少人其实没落到目标页。这部分的埋点建议放在业务路由之前,因为一旦进入业务跳转流程,很多现场信息就被覆盖掉了。
6.5 与扫码、分享等场景的联动
扫码跳转是另一个典型的场景。如果你生成的是普通 HTTPS 链接,扫出来后在系统浏览器里打开,Universal Links 是可以无缝拉起 App 的。不过我遇到过一个问题:部分第三方扫码 SDK 内部用的是 WebView 渲染,这种情况下 Universal Links 不生效。解决路径通常是让 SDK 在识别到链接时,调用UIApplication.shared.open把链接交给系统浏览器去处理,相当于把这个链接"还给系统",Universal Links 才能接管。分享到系统生态内的链接同理,尽量让链接以原始 HTTPS 地址形式存在,避免中间套一层"中转页 + 按钮"。
写在最后的几点体会
文章快收尾了,我聊几句自己在实际项目里养成的工作习惯。
第一,每次发布日期的当天,我都会用 curl 把生产环境的 apple-app-site-association 拉下来跑一遍 jq 校验,顺便用一部干净的测试机点一遍核心链接。这个动作 5 分钟,但能保证线上配置不会在某个版本迭代时被误伤。第二,我强烈建议把 Universal Links 的配置过程写成一份团队内部的 CheckList,加入新 App、新域名时直接照着走,比临时翻文档效率高很多,也能避免前面提到的各种认知盲区。第三,如果你们团队的 H5 和 App 协作比较频繁,最好把"路径配置表"放在同一个文档里,两侧共同维护,这样每次新增页面,App 和 H5 的匹配规则能同步更新,不至于出现一方上线另一方没更的脱节。
最后一个我非常想强调的小技巧:调试时的日志一定要打全。在continueUserActivity里把接收到的完整 URL 用代码打印出来,再在handleUniversalLink里把解析后的 host、path、queryItems 逐个打出来。很多排查问题之所以耗时,就是因为日志不全,只能靠猜。日志全了,链路哪里断了,一眼就能看出来。
Universal Links 本身不难,难的是链路长、步骤多,每一处都暗藏细节。希望这篇内容能帮你把这条链路一次走通,少踩几个我踩过的坑。