iOS Universal Links 全链路排查:从 AASA 到 Scene 回调的实战指南

发布时间:2026/9/24 2:00:07
iOS Universal Links 全链路排查:从 AASA 到 Scene 回调的实战指南 1. 从一次真实的排查现场说起Universal Links 配好了Associated Domains 也开了AASA 文件在浏览器里能正常访问Apple 官方文档里能勾的选项全勾了结果 App 装上去点链接还是老老实实跳 Safari——这个场景我猜不少 iOS 开发者都遇到过。更让人抓狂的是它有时候又能正常唤起 App有时候又不行删掉重装 App 之后好了过两天又坏了。你去搜搜到的答案清一色是检查 AASA 文件格式确认 Team ID 对不对看看有没有重定向这些当然要查但查完发现都没问题问题依旧。这篇内容就是写给卡在这个阶段的同行的。我会把 Universal Links 从系统底层到应用层的完整链路拆开讲清楚重点放在那些官方文档一笔带过、但实际排查中真正决定成败的环节上。涉及的关键词包括 Universal Links、AASA、Associated Domains、swcutil、Scene这几个词基本覆盖了整条链路上最容易出问题的节点。不管你是刚接触 Universal Links 的新手还是已经配过好几轮但总在某个环节翻车的老手下面这些内容应该都能帮你省下不少抓头发的时间。需要先说明一点Universal Links 的调试体验之所以差根本原因在于它的状态管理是系统级的、有缓存的、而且缓存策略不透明。你改了一个配置系统不一定立刻感知你删了 App 重装系统可能还留着旧的关联记录。理解这一点后面的很多玄学现象就都能解释了。2. Universal Links 的完整链路到底经过哪些环节2.1 从点击链接到 App 被唤起中间发生了什么很多人对 Universal Links 的理解停留在配好 AASA 文件系统就会把链接交给 App这个层面。这个理解不算错但太粗了粗到排查问题时根本定位不到具体环节。实际链路要细得多我把它拆成下面这几步App 安装或更新时系统会去读取 App 的 entitlements找到com.apple.developer.associated-domains这个 key里面列出的每个域名都会被系统记录为待验证。系统向每个域名请求 AASA 文件路径固定是https://domain/.well-known/apple-app-site-association。注意这个请求是系统发起的不走你的 App 代码也不走你平时用的网络库。系统校验 AASA 文件内容包括 JSON 格式是否合法、appID是否匹配当前 App 的 Team ID 和 Bundle ID、paths或components是否覆盖了目标链接。校验通过后系统把域名和 App 的关联关系写入本地数据库这个数据库由swcutil这个系统工具管理。用户点击链接时系统先判断这个链接的域名是否在已关联列表里如果在再判断路径是否匹配匹配则唤起 App不匹配则交给 Safari。App 被唤起后通过NSUserActivity或Scene的continue回调拿到链接由 App 自己决定怎么处理。这六步里任何一步出问题最终表现都是打开了网页。但排查手段完全不同。下面几节我会逐个环节讲怎么验证、怎么定位。2.2 为什么系统要设计这么复杂的验证流程这里插一句设计动机理解了动机很多行为就顺了。Universal Links 的核心目标是让合法域名和合法 App 建立可信关联所以系统必须确保域名确实属于这个 App 的开发者通过 AASA 里的 Team ID 校验域名确实愿意把链接交给这个 App通过 AASA 文件的存在和内容表达这个关联关系不能被恶意 App 伪造所以 AASA 必须通过 HTTPS 获取且系统会缓存验证结果正因为有可信这个诉求系统才会对 AASA 文件做严格校验也才会把验证结果缓存起来——缓存是为了避免每次点击链接都去请求一次 AASA那样既慢又费流量。但缓存带来的副作用就是你改了 AASA系统不一定马上知道。2.3 swcutil 是什么为什么它是排查的核心工具swcutil是 macOS 和 iOS 系统里管理共享 Web 凭据Shared Web Credentials的命令行工具Universal Links 的关联数据就存在它管理的数据库里。在 macOS 上你可以直接跑swcutil show -d example.com这条命令会列出系统当前记录的、与example.com关联的所有 App 信息包括 App ID、路径匹配规则、以及这条记录是什么时候写入的。如果你在 macOS 上跑出来是空的或者显示的 App ID 跟你预期的不一样那问题基本就锁定在系统没有正确建立关联这个环节了。iOS 上没有直接可用的swcutil命令行但你可以通过 Xcode 的 Devices and Simulators 窗口连接真机后查看设备日志过滤swcd这个进程的输出。swcd是后台负责处理关联关系的守护进程它的日志里会明确写出 AASA 请求成功还是失败、校验通过还是拒绝。提示在真机上抓swcd日志时建议先删掉 App 再重装这样能触发一次完整的 AASA 拉取流程日志里能看到从请求到写入的全过程。3. AASA 文件本身最容易踩的几个坑3.1 Content-Type 和重定向两个最隐蔽的杀手AASA 文件必须满足两个硬性条件缺一不可响应头Content-Type必须是application/json不能是text/plain不能是application/octet-stream更不能是text/html。请求过程中不能有任何重定向包括 301、302、307一次都不行。这两条在 Apple 文档里都写了但实际部署时特别容易翻车。我见过最常见的情况是CDN 或者对象存储默认给.well-known路径下的文件返回text/plain或者你的服务器配置了 HTTP 到 HTTPS 的跳转系统请求 HTTPS 时又被跳了一次。这两种情况在浏览器里访问都看起来正常因为浏览器对 Content-Type 和重定向宽容得多但系统校验是严格的。验证方法很简单用 curl 看响应头curl -I https://example.com/.well-known/apple-app-site-association重点看两行HTTP/2 200不能是 301/302和content-type: application/json。如果这两行不对后面所有排查都是白费。3.2 JSON 结构details 数组和 appID 的匹配逻辑AASA 文件的 JSON 结构这几年改过几版目前主流的是applinks下面挂details数组{ applinks: { details: [ { appIDs: [ABCDE12345.com.example.app], components: [ { /: /product/*, comment: 商品详情页 } ] } ] } }这里有几个细节值得单独拎出来说appIDs里的格式是TeamID.BundleIDTeam ID 是 10 位字符Bundle ID 必须和 App 的完全一致大小写敏感。老版本用的是appID单数加paths数组新版本推荐appIDs复数加components。两者系统都还认但components的匹配能力更强支持排除规则和查询参数匹配。components里的/字段是路径匹配模式*是通配符?匹配单个字符。注意*会匹配包括/在内的所有字符所以/product/*能匹配/product/123/detail。如果你用的是paths老格式要注意paths里的*和?行为跟components不完全一样而且paths不支持排除。我个人的建议是统一用components少踩一类坑。3.3 缓存为什么改了 AASA 要等以及怎么强制刷新AASA 文件在系统侧是有缓存的而且缓存时间不固定。Apple 的说法是系统会定期重新拉取但具体间隔没有公开。实际观察下来快的时候几分钟慢的时候可能要几个小时甚至更久。如果你改完 AASA 想立刻验证有几个办法可以强制刷新删掉 App 重装这是最彻底的方式重装会触发一次完整的关联验证流程。在 macOS 上跑swcutil reset清空本地的关联数据库下次点击链接时会重新拉取。重启设备重启会清掉一部分内存缓存但不一定清掉持久化的关联记录。注意swcutil reset会清掉所有域名的关联记录不只是你正在调试的那个。如果你机器上有其他依赖 Universal Links 的 App重置后它们也需要重新验证。4. Associated Domains 配置里那些不起眼的细节4.1 entitlements 文件里的域名格式Associated Domains 的配置写在 entitlements 文件里格式是applinks:domain比如keycom.apple.developer.associated-domains/key array stringapplinks:example.com/string stringapplinks:www.example.com/string /array这里有几个容易忽略的点不要带协议头写applinks:example.com而不是applinks:https://example.com。不要带路径applinks:example.com/product是无效的。子域名要单独列example.com和www.example.com是两个不同的域名系统不会自动关联。通配符子域名可以用applinks:*.example.com但要注意这个通配符只匹配一级子域名a.b.example.com匹配不上。4.2 开发证书和发布证书的差异这个坑我踩过不止一次。在开发阶段你用的可能是 Development 证书这时候 Associated Domains 的验证走的是开发环境AASA 文件里appIDs的 Team ID 必须和开发证书的 Team ID 一致。但如果你在 AASA 里写的是发布环境的 Team ID开发阶段就验证不过。更隐蔽的是有些团队会用不同的 Bundle ID 做开发版和发布版比如com.example.app.dev和com.example.app这时候 AASA 里的appIDs必须把两个都列上否则开发版永远验证不过。{ applinks: { details: [ { appIDs: [ ABCDE12345.com.example.app, ABCDE12345.com.example.app.dev ], components: [...] } ] } }4.3 用 Xcode 的 Capabilities 面板还是手动改文件Xcode 的 Signing Capabilities 面板里可以勾选 Associated Domains 并添加域名这个操作本质上就是帮你改 entitlements 文件。但如果你用的是手动管理的 entitlements比如多 target 项目就要注意别改错了文件。我个人的习惯是始终以 entitlements 文件为准Xcode 面板只用来做可视化确认。因为面板有时候会有缓存你改了文件但面板没刷新容易误判。5. Scene 生命周期下 Universal Links 回调的变化5.1 从 AppDelegate 到 SceneDelegate 的迁移iOS 13 引入 Scene 之后Universal Links 的回调入口变了。以前在AppDelegate里实现func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: escaping ([UIUserActivityRestoring]?) - Void) - Bool { // 处理 Universal Links }现在如果 App 用了 Scene回调要写在SceneDelegate里func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType NSUserActivityTypeBrowsingWeb, let url userActivity.webpageURL else { return } // 处理 Universal Links }问题在于如果你的 App 同时实现了两套回调系统只会调用其中一套。具体调哪套取决于 App 的 Info.plist 里有没有UIApplicationSceneManifest。有 Manifest 就走 Scene 回调没有就走 AppDelegate 回调。我见过最典型的翻车场景是项目从老版本迁移到 SceneAppDelegate里的回调没删SceneDelegate里的回调也写了结果系统走了 Scene 回调但开发者一直在AppDelegate里打断点怎么都进不去误以为 Universal Links 没生效。5.2 冷启动和热启动的回调差异Universal Links 在冷启动和热启动下的回调路径不一样热启动App 在后台直接走scene(_:continue:)或application(_:continue:restorationHandler:)。冷启动App 没运行先走scene(_:willConnectTo:options:)链接信息在connectionOptions.userActivities里然后才走continue回调。如果你只在continue回调里处理链接冷启动时可能会漏掉。正确的做法是在willConnectTo里也检查一遍func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { if let userActivity connectionOptions.userActivities.first, userActivity.activityType NSUserActivityTypeBrowsingWeb, let url userActivity.webpageURL { // 处理冷启动链接 } }5.3 多 Scene 场景下的链接分发iPadOS 支持多窗口每个窗口是一个独立的 Scene。用户在一个 Scene 里点击 Universal Link系统会把链接交给当前活跃的 Scene。如果你的 App 有多个 Scene要确保每个 Scene 的SceneDelegate都正确处理了continue回调否则可能出现链接唤起了 App但当前窗口没反应的情况。6. 一套可复现的排查流程6.1 第一步确认 AASA 文件在系统侧能通过校验不要用浏览器验证用 curlcurl -I https://example.com/.well-known/apple-app-site-association curl https://example.com/.well-known/apple-app-site-association | python -m json.tool第一条看响应头和状态码第二条看 JSON 是否合法。两条都过了再往下走。6.2 第二步在 macOS 上用 swcutil 确认关联记录swcutil show -d example.com如果输出为空说明系统没有建立关联。这时候可以尝试swcutil reset然后重新安装 App再跑一次show看记录有没有写入。6.3 第三步抓 swcd 日志看验证过程在真机上通过 Xcode 的 Devices and Simulators 窗口选择设备后点 Open Console过滤swcd。然后删掉 App 重装观察日志里有没有类似这样的输出swcd: Fetching AASA for example.com swcd: AASA validation succeeded for example.com swcd: Associated app ABCDE12345.com.example.app with example.com如果看到validation failed或者fetch failed后面的错误信息会告诉你具体原因。6.4 第四步确认 App 侧的回调入口在SceneDelegate和AppDelegate的回调里都打上断点然后分别测试冷启动和热启动。如果断点进不去检查 Info.plist 里有没有UIApplicationSceneManifest确认系统走的是哪套回调。6.5 第五步检查链接路径是否匹配如果前面都通过了但点击链接还是打开网页那大概率是路径匹配的问题。把你点击的完整 URL 拿出来对照 AASA 里的components规则逐段比对。特别注意查询参数是否被规则覆盖大小写是否一致末尾斜杠是否匹配7. 几个真实案例的排查记录7.1 案例一CDN 缓存了旧的 AASA 文件某次线上问题AASA 文件更新后部分用户点击链接还是打开网页。排查发现 CDN 缓存了旧版本的 AASA系统拉取到的是旧文件。解决办法是在 CDN 上对.well-known/apple-app-site-association这个路径设置不缓存或者缓存时间设得很短。7.2 案例二Team ID 写错了一位AASA 里的 Team ID 是 10 位有人从开发者后台复制的时候少复制了一位系统校验直接失败。这种错误在日志里会明确写appID mismatch但如果你不看日志光看文件内容很难发现。7.3 案例三Scene 迁移后回调丢失一个老项目迁移到 Scene 后Universal Links 全部失效。排查发现AppDelegate里的回调还在但SceneDelegate里的回调没实现系统走了 Scene 路径链接信息被丢弃了。补上SceneDelegate的回调后恢复正常。7.4 案例四路径规则里的通配符用错AASA 里写的是/product/*但实际链接是/products/123多了一个s匹配不上。这种错误在日志里不会报因为 AASA 本身是合法的只是路径不匹配。只能靠人工比对。8. 一些不那么常见但值得知道的边界情况8.1 用户手动在 Safari 地址栏输入链接Universal Links 只在用户点击链接时生效如果用户手动在 Safari 地址栏输入 URL 并回车系统不会唤起 App而是直接打开网页。这是设计行为不是 bug。8.2 从其他 App 的 WebView 里点击链接如果链接是在其他 App 的 WebView 里被点击的Universal Links 的行为取决于 WebView 的实现。有些 WebView 会拦截链接自己处理有些会交给系统。这种情况下 Universal Links 可能不生效属于预期行为。8.3 同一域名被多个 App 关联一个域名可以被多个 App 关联系统会根据 AASA 里的appIDs列表和当前安装的 App 来决定唤起哪个。如果多个 App 都匹配系统会弹出选择菜单让用户选。8.4 企业签名和 TestFlight 的差异企业签名分发的 AppUniversal Links 的验证流程和 App Store 版本基本一致但 TestFlight 版本有时候会有延迟因为 TestFlight 的 App 安装路径和正式版不同系统可能需要额外时间建立关联。9. 我个人的几条实操建议第一AASA 文件用版本控制管理每次改动都记录 commit出问题时能快速回滚对比。我见过太多团队 AASA 文件是手改的改完没记录出问题根本不知道改了什么。第二在 CI 里加一步 AASA 校验用脚本检查 JSON 合法性、Content-Type、是否有重定向。这一步能挡掉大部分低级错误。第三调试时优先用真机不要用模拟器。模拟器的 Universal Links 行为和真机有差异尤其是涉及网络请求和缓存的部分模拟器上正常不代表真机正常。第四养成看 swcd 日志的习惯。大部分 Universal Links 的问题日志里都有明确答案只是很多人不知道去看。第五Scene 迁移要彻底。要么全用 AppDelegate要么全用 SceneDelegate不要两套混着来。混用的结果就是回调路径不确定调试时极其痛苦。最后再分享一个小技巧如果你怀疑是缓存问题可以在 macOS 上跑swcutil reset后立刻用swcutil show -d example.com看记录是否被清空然后重新安装 App 再show一次对比两次输出就能确认系统有没有重新建立关联。这个对比法在排查改了配置但不生效类问题时特别管用。