ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

iOS Universal Links 完整配置指南:从 AASA 到 Xcode 回调的避坑实践

iOS Universal Links 完整配置指南:从 AASA 到 Xcode 回调的避坑实践

Universal Links,一套表面看起来简单、实际配置起来处处是坑的东西。我刚入行时以为它就是“放个JSON文件到服务器,Xcode里加个能力”,结果被线上环境狠狠教育了一轮。这篇文章我会完整梳理从服务器端 AASA 文件到客户端回调的配置流程,把那些文档里没说透的细节一次性讲清楚,适合正在做 iOS 端深度链接接入、想要彻底告别 Scheme 弹窗和链接失效问题的同学参考。

1. Universal Links 到底在解决什么问题

1.1 从 Scheme 到 Universal Links 的演进

在 Universal Links 出现之前,iOS 上要做 App 外部唤起,主流方案是注册自定义 URL Scheme,比如myapp://。这个方案有一个天生的问题:你的 Scheme 是全局唯一的字符串,但是在同一台设备上,如果多个 App 都注册了同一个 Scheme,系统只会唤起其中一个,而且这个“谁被唤起”的行为你无法预知。更头疼的是,用户在 Safari 里点击myapp://链接时,如果设备上没有安装你的 App,系统会直接报“无法打开网页”,没有任何优雅的降级能力。

Universal Links 换了一个思路:不依赖自定义协议,而是使用标准的 HTTPS 链接。当用户点击https://yourdomain.com/path时,系统先检查你的 App 是否已安装。如果已安装,并且域名与 App 之间的关联关系验证通过,就直接唤起 App;如果未安装,则在浏览器中打开这个 URL,展示你的网页。没有弹窗确认,没有“在XXX中打开”的提示,体验上顺畅得多。

1.2 适用场景:哪些链路必须用它

我总结了几类必须用 Universal Links 的业务场景:

  • 邮件、短信、社交媒体分享的链接,点击后希望直接进入 App 对应页面。
  • 活动落地页需要引导老用户回流,同时新用户落地到 Web 页完成转化。
  • App 内分享出去的页面,别人打开后需要带上分享者信息跳回 App。
  • 搜索引擎中你的网站结果被点击时,已安装用户直接进 App。
  • 扫码场景:扫出的内容是一个普通 HTTPS URL,却能唤起 App。

注意,Universal Links 不适用于“你的 App 尚未发布、也没建立任何网站”的纯内部测试场景,这种情况建议用本地调试方案,后面第 5 节会讲。

2. 动手之前的物料清单:三件事必须齐活

2.1 应用侧需要准备什么

首先你需要一个真实的 App ID,这个 ID 必须是你在开发者后台创建的,不能是 Xcode 自动生成的通配符 ID。Universal Links 的关联关系是基于“App ID + Team ID”的,如果你的 App ID 是带通配符的,AASA 文件中的 teamID 和 bundleID 能配上,但部分 App 能力会被禁用。大家实际项目里基本都用明确的 App ID,这点注意一下就好。

其次,Xcode 工程需要开启 Associated Domains 能力。这个能力会在 entitlements 文件中生成一段配置:com.apple.developer.associated-domains。开启后你才能添加applinks:前缀的域名。这一步依赖开发者账号的权限,如果你的账号是个人类型的,一般直接就能开启;如果是公司账号且权限较多,需要确认所在团队已为该 App ID 启用 Associated Domains 服务。

还需要一个支持 HTTPS 的域名。注意,Universal Links 强制要求 HTTPS,并且证书必须是受信任的 CA 签发的有效证书。自签名证书、过期证书、证书链不完整的,统统不行。

2.2 服务端需要准备什么

服务端要准备的是那套“苹果关联文件”,路径是/.well-known/apple-app-site-association或者根目录/apple-app-site-association。文件名没有扩展名,内容是一段 JSON。

这个文件必须放在 HTTPS 服务的根路径下。如果你的服务是通过 CDN 加速的,要确保 CDN 节点能正确回源到你配置的这个文件,并且不会对 JSON 内容做缓存之外的修改。部分 CDN 会默认把application/json类型的响应压缩或改写,这会影响文件的校验。

另外,你最好有权限修改服务的响应头。因为 AASA 文件的 Content-Type 需要设置成application/json或application/pkcs7-mime。虽然现在普遍认为 iOS 对 Content-Type 的校验没以前严格,但我实测中还遇到过因为返回text/html而导致下载失败的情况,所以还是按规范来比较稳妥。

3. 服务端配置实操:AASA 文件的生成与校验

3.1 AASA 文件内容和路径规则

先看一个完整的apple-app-site-association样例。

{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.example.app", "paths": ["*"] } ] } }

appID的格式是“TeamID + App Bundle ID”拼接,中间用英文点号分隔。TeamID 是你在开发者账号中能查到的十位字符串,Bundle ID 是你工程里的那个,比如com.example.app。这里最容易写错的点是:不要用应用的 Display Name,不要用prefix里的那一长串 App ID Prefix 去替换 TeamID。App ID Prefix 在大多数情况下等于 TeamID,但某些老账号下两者是不同的,必须去开发者后台确认。

paths数组用来声明你希望 App 处理的路径。支持的通配符规则如下。

规则含义
*匹配任意路径,最粗暴的写法
/path/*只匹配/path/下的一层路径
/path/*注意*会匹配/path/xxx但不会匹配/path本身
?匹配单个字符,比如/202?/article可以匹配/2024/article
/path/精确匹配该路径
/path/末尾不带/匹配/path精确值,不匹配/path/xxx

路径匹配使用的是“最长匹配”原则,也就是多个规则命中时,选择最具体的那条。举例:

"paths": [ "/user/*", "/user/profile" ]

访问/user/profile时,会命中/user/profile,而不是/user/*。利用这个特性,可以做到“大部分路径允许,个别路径禁止”。不过 AASA 本身没有“排除”语法,想排除某个子路径,需要把精确路径和通配路径组合使用。

我建议首次配置时先用*测通,再逐步收窄到真实业务路径。一来是验证整条链路是否顺畅,二来是避免一开始就陷入路径匹配的细节里,排错时很难定位问题。

3.2 HTTPS、Content-Type 与哈希校验

将上述 JSON 内容以 UTF-8 编码保存成文件名apple-app-site-association,不添加.json后缀。如果你的服务是 Nginx,可以这样配置:

location ^~ /.well-known/apple-app-site-association { alias /path/to/apple-app-site-association; default_type application/json; }

如果放在根目录,这样处理:

location = /apple-app-site-association { default_type application/json; alias /path/to/apple-app-site-association; }

这里要单独说一个“哈希校验”的坑。iOS 系统在下载 AASA 文件后,默认会校验文件内容是否经过苹果官方的加密签名。也就是说,如果你在开发者后台配置了更高级的“App 站点关联”设置,并且系统下载到了带签名的 AASA,它可能不再接受明文 JSON 文件。但实际上绝大多数团队都不会走这套签名流程,直接上明文 JSON 就够了。如果你在日志里看到系统反复请求文件、且状态一直 404,先不要怀疑签名,先检查路径和 Content-Type。

另外,不要用任何 BOM 头去保存这个 JSON。某些 Windows 编辑器保存 UTF-8 文件时会自动写入 BOM,这会导致 iOS 解析 JSON 失败。保存时务必选“无 BOM”。

3.3 用脚本快速自测 AASA 配置

配置完服务器后,不需要立刻拿手机试,先用命令验证文件可访问性。

curl -i https://yourdomain.com/.well-known/apple-app-site-association

重点看三件事:

  • 返回的 HTTP 状态码是否为 200,而非 301、302、404。
  • Content-Type 是否为application/json。
  • 响应体是否是 JSON 且 appID 与你的 TeamID 和 BundleID 完全一致。

如果你看到 301/302,说明服务把请求重定向了。iOS 的 AASA 下载器对重定向的兼容性并不好,尤其是不支持重定向到另一个域名。务必让最终响应直接落在目标地址。

我曾经遇到过一个 CDN 配置,域名根路径返回重定向到“带www的域名”,导致抓取失败。排查方式很简单,就是拿curl -L看最终跳到了哪里。一旦确认跳转异常,就去 CDN 或者 Nginx 上调整路径规则。

4. Xcode 工程与客户端代码配置

4.1 Associated Domains 与 Entitlements

在 Xcode 中,选中 Target -> Signing & Capabilities -> 点“+” -> 选择 Associated Domains。添加一条域名,格式必须是:

applinks:yourdomain.com

注意前缀必须是applinks:。不要写成https://yourdomain.com,也不要写成yourdomain.com/path/subpath。你只能在这里添加域名,不能添加带路径的内容。哪个具体路径交给 App 处理,是在 AASA 文件的paths里控制的。

打开工程的 entitlements 文件,你能看到这样的内容:

<dict> <key>com.apple.developer.associated-domains</key> <array> <string>applinks:yourdomain.com</string> </array> </dict>

如果你同时支持多个域名,就维护多条字符串。实际项目中,建议区分正式和测试环境:applinks:yourdomain.com和applinks:test.yourdomain.com同时配置,方便切换环境调试。

配置完成后,用开发者账号签名并安装到真机。注意,这个能力在模拟器上也能工作,但需要注意 iOS 模拟器访问网络的时候对某些本机回环地址的处理和真机不同,所以核心功能建议直接用真机验证。

4.2 处理回调的两种写法

Universal Links 唤起 App 后,系统会先走continueUserActivity逻辑。使用 UIKit 生命周期时,在 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 }

如果工程使用了 SwiftUI 生命周期,则在 SceneDelegate 中处理(或通过 UIKIT 的 scene 回调):

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return } handleUniversalLink(url) }

需要特别说明的是,现在新建的 SwiftUI 工程默认没有 SceneDelegate 文件。此时你可以在 App 结构中通过.onOpenURL拿到 URL,但 Universal Links 进入 App 时不一定总走onOpenURL,还是建议保留continueUserActivity的入口。我自己的做法是两种入口都接,统一收敛到同一个路由处理函数,避免出现“在微信里点链接能跳,在备忘录里点链接不能跳”这种偏差。

4.3 URL 参数该怎么安全地传给后续页面

拿到webpageURL以后,不要直接把整个 URL 拼接进路由字符串。例如,你的页面地址是https://yourdomain.com/goods?itemId=10086,期望 App 内打开“商品详情”页,那么应该解析出goods、itemId=10086这类结构化数据,再做分发。

在实践中,我宁愿在链接里加一个“渠道标记”参数,方便归因。比如:

https://yourdomain.com/goods?itemId=10086&channel=share

然后在解析函数里把这个参数传递给页面曝光统计。这是很常见的做法,但容易被忽略。长度参数和中文参数记得做 URL 解码:

let components = URLComponents(url: url, resolvingAgainstBaseURL: false) let itemID = components?.queryItems?.first(where: { $0.name == "itemId" })?.value

这里还有一些边界情况值得小心。如果 URL 里的 query 带有未编码的{、}、空格,可能导致 URL 解析失败。所以服务端生成链接时,所有参数值都需要经过URLComponents的percentEncodedQueryItems编码,而不是手工拼字符串。

5. 联调测试与线上问题排查

5.1 验证 Universal Links 是否生效的几种方法

配置完所有环境变量后,第一种验证方式是最直接的:在备忘录里输入https://yourdomain.com/test,点击链接。此时 Safari 会先询问你是否允许打开“某 App”。如果弹出“在某某 App 中打开”的底部弹窗,说明 Universal Links 已经生效;如果没有,则会直接打开网页,说明链路有问题。

第二种方式是使用系统日志。连接手机到 Xcode,打开 Console,过滤关键字apple-app-site-association。每次系统尝试下载 AASA 文件时都会输出日志,你可以在日志中看到文件路径、状态码、校验结果。这个方法在排查疑难问题时几乎是必备的。

第三种方式是通过 App 内部逻辑来主动验证:在 AppDelegate 中打日志,观察continueUserActivity是否进入。如果进入了,说明系统已经成功把链接匹配给你的 App;如果没进入,说明下载、校验或路径匹配某一环出了问题。

模拟器上也可以测,但要注意:

  • 模拟器对applinks:localhost一类的本地调试域名支持比较弱。
  • 如果你用本地 HTTPS 服务生成 AASA,模拟器有时会因为网络代理配置不同而拉取失败。
  • 模拟器上点击链接唤起 App 的行为通常不如真机稳定,压测验证尽量放在真机。

5.2 常见失败原因与排查顺序

整理一份排错顺序,基本覆盖了 90% 的 Universal Links 问题。

现象可能原因排查动作
点击链接直接打开网页,未唤起 AppAASA 未下载成功Console 过滤 AASA 日志,看 HTTP 状态
AASA 返回 404路径没放对,或 CDN 没同步curl 确认路径,看跳转
AASA 返回 200 但解析失败JSON 不合法、有 BOM、appID 拼接错误用 JSON 格式化工具校验,比对 TeamID/BundleID
链接打开后提示“在 XXX 中打开”Universal Links 本身未激活,系统走了 Scheme 逻辑检查 .entitlements 文件,检查是否添加了 applinks 前缀
部分路径能唤起、部分路径不能paths 通配规则覆盖不全检查 paths 中是否写了“排除”逻辑
刚部署 AASA 测试失败系统缓存旧文件清除 App 缓存或等待 24-48 小时
线上版本始终不生效,测试版正常Bundle ID 不一致检查 AASA 的 appID 是否写了线上 App 的 Bundle ID

先按这个表排查,问题大多能定位。比对着代码猜半天快得多。

5.3 线上版本迭代时的注意点

如果 AASA 文件里的 TeamID、BundleID 不变,单纯新增几个 path 规则,系统更新文件的时间会存在缓存延迟。这个时间理论上最长是 24 小时,但实际短则几分钟,长则数天,取决于用户手机的网络环境。

如果你想主动刷新某台测试设备上的 AASA 缓存,可以尝试在设置中“关闭再打开 Wi-Fi”,或者切换一次飞行模式,但我也遇到过这种操作无效的情况。比较可靠的办法是把 App 卸载重装,系统会重新拉取一次关联文件。需要注意,重装以后部分本地数据会丢失,如果是线上用户环境,不建议把这个当解决方案给到用户。

还有一个常见的线上坑:App 更新后,Apple 开发者后台里的“Associated Domains”配置如果被动过,可能导致证书失效。这种失效不会出现在 Xcode 本地编译阶段,只在真机安装时出现 “未找到相关域” 之类的签名问题。遇到这种情况,重新生成 provisioning profile,并在开发者后台确认 Associated Domains 开关是开启的。

6. 项目实战中的经验沉淀

6.1 一次线上“部分用户无法回归”的排查记录

某次版本上线后,运营反馈“有 30% 的用户从短信链接点击进不了 App 指定页”。我在本地复现时一切正常,因为本地测试链路走的是测试域名和最新开发的 App,而线上用户手机里可能还保留着旧版本 App。旧版本 App 的 entitlements 文件里记录的 Associated Domains 只有applinks:old.domain.com,新版本的配置虽然已经改成了applinks:new.domain.com,但 AASA 文件里同时挂了新旧两个域名的关联。问题在于,旧的域名所对应的服务已经关停,系统能下载到 AASA,但下载的内容部分失效。

这个案例告诉我们两条原则:

  • 域名变更时,新旧域名至少并行保留一个完整发布周期,不要立即下掉旧关联。
  • AASA 文件不要同时挂太多无意义的历史路径,会让排查难度陡增。

6.2 我和后端协作时约定的“链接规范”

为了让前后端协作时减少歧义,我们定义了一套简单规范:

  • 所有可被 App 处理的路径,统一由后端提供一个“链接白名单”,前端不做硬编码。
  • 链接中统一携带cid渠道参数,值是分享来源,例如chat、sms、email。
  • 所有跳转目标路径必须存在对应的 Web 页面,防止用户未安装 App 时看到 404。
  • 每个版本上线前,后端必须提供一份最新的 AASA 校验结果,CI 中跑一个脚本自动 curl 检查 JSON 格式和 appID 是否正确。

这样分工以后,Universal Links 出问题的概率明显下降。因为很多东西不是“能不能写对代码”的问题,而是“两边是否对齐了约定”的问题。

6.3 日志和监控层面可以做的小改进

线上环境里,Universal Links 失败时用户不会主动报错,更多是“点了没反应”然后流失。为了拿到可观测性数据,我们做了两件事:

第一,在continueUserActivity处理入口加一段埋点,记录webpageURL的完整 URL 和解析后的路由结果。这个埋点数据能帮助判断是系统没有唤起 App,还是唤起后解析失败。

第二,当解析出的路由在当前 App 版本中不存在时,不要直接丢弃,而是跳转到一个“页面不存在”的中间页,并在该页面自动打开对应 Web 地址。这个兜底逻辑避免用户陷入“点击后毫无反馈”的死角。

当然,埋点也要注意个人隐私合规问题,URL 中如有用户唯一标识,需要在埋点前做脱敏处理。

6.4 另外一个容易被忽略的小细节:与其他 App 的关联冲突

Universal Links 的一个特性是“同一个域名可以同时关联多个 App”。例如你的公司甲产品和乙产品可能同时都声明了对https://yourdomain.com的关联关系。此时,系统在处理链接时,会根据这个域名下声明的 appID 列表选择一个最匹配的 App。如果你产品的 AASA 中 wrote 了多个 appID,而业务上又无法区分具体路径归属,就会产生“甲 App 抢了乙 App 链接”的情况。

解决方式很简单:在路径上做区分。/app-a/开头的路径只允许甲 App 处理,/app-b/开头的路径只允许乙 App 处理。AASA 中的 appID 列表保持独立,路径规则各管各的。

7. 最后一步:把配置固化到自动化流程里

如果你已经按照前面流程手动配置完成,强烈建议把“服务端检查”这一步放进 CI。例如在发布脚本里,每次发版前执行一次:

./scripts/check_ul.sh --domain yourdomain.com --team-id YOUR_TEAM --bundle-id com.example.app

脚本内部可以使用 Python 或 Node 请求 AASA 文件,做 JSON 解析,并断言路径里包含期望的路径集合。只要 CI 里这一关不过,就不允许打包上传。这一步虽然简单,却能把很多“改绕了口令但忘了同步 AASA”的问题扼杀在打包前。

关于本地调试,也有一些团队选择用本地 HTTPS 服务加代理工具来模拟 AASA,但实际操作起来比较麻烦。我更推荐的做法是准备一个专门的预发域名,预发环境部署一套 AASA,开发时把 App 的 Associated Domains 指向预发域名,与线上域名完全隔离。调试体验会稳定很多。

配置 Universal Links 这件事,80% 的坑其实发生在“苹果后台、服务器、Xcode 三者之间的配置没有对齐”上。你自己把流程从头到尾走一遍,再维护成文档,后续同事接手时基本不会踩雷。按照我上面这套流程配置,你应该能在半小时内完成并验证通过;如果中途卡住,回到第 5 节的排查表里按顺序过一遍,大多数问题都能快速定位。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进