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 keycom.apple.developer.associated-domains/key array stringapplinks: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?itemId10086期望 App 内打开“商品详情”页那么应该解析出goods、itemId10086这类结构化数据再做分发。在实践中我宁愿在链接里加一个“渠道标记”参数方便归因。比如https://yourdomain.com/goods?itemId10086channelshare然后在解析函数里把这个参数传递给页面曝光统计。这是很常见的做法但容易被忽略。长度参数和中文参数记得做 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 节的排查表里按顺序过一遍大多数问题都能快速定位。