Universal Links 怎么配置?URL Scheme 与 Universal Links 真机验证
最后更新
Universal Links 配置要同时做三件事:App ID 开启 Associated Domains,entitlements 里写 applinks:域名,网站的 /.well-known/apple-app-site-association 返回正确的 JSON。URL Scheme(myapp://product/42)只要在 Info.plist 的 CFBundleURLTypes 里注册,适合 App 之间互相调用。Apple 推荐对外分享的链接用 Universal Links(https://example.com/product/42)。
下文先讲两者怎么选,再讲配置、代码处理和 iPhone 真机验证,最后给出不跳转的十个原因。Apple 规则截至 2026 年 10 月,都附了官方链接。
开始前准备好一个能用 KXBuild 装到 iPhone 上运行的 SwiftUI 或 UIKit 工程,做 Universal Links 还要有一个你能上传文件、配置 HTTPS 的域名。
URL Scheme 和 Universal Links 怎么选?
| 对比项 | URL Scheme | Universal Links |
|---|---|---|
| 链接样子 | myapp://product/42 |
https://example.com/product/42 |
| 配置位置 | 只改 App 的 Info.plist | App ID 能力、entitlements、网站文件三处 |
| 需要域名和 HTTPS | 不需要 | 需要 |
| 唯一性 | 任何 App 都能注册同一个 scheme,冲突时打开哪个不确定 | 网站和 App 双向验证,别人无法冒用 |
| 没装 App 时 | 点了没反应或报错 | 用浏览器打开网页,网站自己处理 |
| 能否直接在网页、聊天里分享 | 很多 App 不会把它识别成链接 | 就是普通网址,到处都能点 |
| 生效时间 | 装上就生效 | 安装时系统验证网站文件,可能有缓存延迟 |
| 适合 | App 之间互相调用、第三方登录和支付回调 | 短信、邮件、网页、二维码里对外分享的链接 |
Apple 的自定义 URL Scheme 说明写明,多个 App 注册同一个 scheme 时,系统打开哪一个是不确定的,并强烈推荐改用 Universal Links。两种可以同时配,处理代码也能共用一套。
URL Scheme 怎么配置?
在 Info.plist 里注册 CFBundleURLTypes
CFBundleURLTypes 是一个数组,每一项是一个字典。CFBundleURLSchemes 写 scheme 本身,不带 ://。CFBundleURLName 写一个反向域名的标识,用来和别的 App 区分。CFBundleTypeRole 是你自己定义的 scheme 就写 Editor。
1<key>CFBundleURLTypes</key>
2<array>
3 <dict>
4 <key>CFBundleURLName</key>
5 <string>com.example.myapp</string>
6 <key>CFBundleURLSchemes</key>
7 <array>
8 <string>myapp</string>
9 </array>
10 <key>CFBundleTypeRole</key>
11 <string>Editor</string>
12 </dict>
13</array>
scheme 只用小写字母、数字、加号、点和连字符,以字母开头。起一个不容易和别人撞的名字,例如 examplemyapp,不要用 app、test 这类通用词,也不要用 http、tel、mailto 这些系统保留的 scheme。
先确认 target 有没有 Info.plist 文件。右键 target 选「KXBuild: 编辑目标设置」,看 General 页的「Info.plist 文件」。
- 有路径。在资源管理器里双击这个 Info.plist,用属性列表编辑器打开,在根字典上右键「添加行」,键名填
CFBundleURLTypes,类型选 Array,再用「添加子项」逐层加上面的字典和数组,Ctrl+S 保存。 - 空着,Info.plist 是自动生成的。数组类型的键不能用
INFOPLIST_KEY_构建设置写,新建一个Config/Info.plist,只放上面这段,再让 target 用它。构建时会和自动生成的内容合并。
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>CFBundleURLTypes</key>
6 <array>
7 <dict>
8 <key>CFBundleURLName</key>
9 <string>com.example.myapp</string>
10 <key>CFBundleURLSchemes</key>
11 <array>
12 <string>myapp</string>
13 </array>
14 <key>CFBundleTypeRole</key>
15 <string>Editor</string>
16 </dict>
17 </array>
18</dict>
19</plist>
1kxbuild project add-setting -k INFOPLIST_FILE -v Config/Info.plist -t MyApp -P .\MyApp.xcodeproj
Info.plist 两种情况的更多写法见 Info.plist 权限用途说明怎么写的最后一节。Flutter 工程改 ios/Runner/Info.plist,React Native 工程改 ios/<工程名>/Info.plist,写法相同。
用 canOpenURL 判断别的 App 是否安装
要检查手机上有没有装某个 App,先在 Info.plist 的 LSApplicationQueriesSchemes 数组里声明它的 scheme,否则 canOpenURL 一律返回 false。
1<key>LSApplicationQueriesSchemes</key>
2<array>
3 <string>weixin</string>
4 <string>alipay</string>
5</array>
1import UIKit
2
3@MainActor
4func openWeChatIfInstalled() {
5 guard let url = URL(string: "weixin://") else { return }
6 if UIApplication.shared.canOpenURL(url) {
7 UIApplication.shared.open(url)
8 } else {
9 print("没有安装微信")
10 }
11}
open(_:) 不受这个声明限制,只是想打开、不需要先判断时可以不声明。按 Apple 的 canOpenURL 文档,用 iOS 15 及以后的 SDK 构建的 App 最多声明 50 个,用 iOS 27 及以后的 SDK 构建的最多 25 个,只写真正要判断的那几个。
收到的链接在代码里怎么处理?
两种链接最后都是一个 URL,用一个路由类统一解析,SwiftUI 和 UIKit 都调它。新建 DeepLinkRouter.swift,把 example.com 和 myapp 换成你的域名和 scheme。
1import Foundation
2
3@MainActor
4final class DeepLinkRouter: ObservableObject {
5 enum Destination: Equatable {
6 case product(id: Int)
7 case invite(code: String)
8 }
9
10 static let scheme = "myapp"
11 static let hosts: Set<String> = ["example.com", "www.example.com"]
12
13 @Published var destination: Destination?
14 @Published private(set) var lastURL = "还没有收到链接"
15
16 /// 能识别就返回 true
17 @discardableResult
18 func handle(_ url: URL) -> Bool {
19 lastURL = url.absoluteString
20 print("收到链接: \(url.absoluteString)")
21
22 guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
23 let scheme = components.scheme?.lowercased() else { return false }
24
25 var parts = components.path.split(separator: "/").map(String.init)
26 switch scheme {
27 case Self.scheme:
28 // myapp://product/42 的 host 是 product,path 是 /42
29 if let host = components.host { parts.insert(host.lowercased(), at: 0) }
30 case "https":
31 // https://example.com/product/42 的 path 是 /product/42
32 guard let host = components.host?.lowercased(), Self.hosts.contains(host) else { return false }
33 default:
34 return false
35 }
36
37 // 外部传进来的参数都要校验,不合法就丢弃
38 switch parts.first {
39 case "product":
40 guard parts.count >= 2, let id = Int(parts[1]), id > 0 else { return false }
41 destination = .product(id: id)
42 return true
43 case "invite":
44 guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value,
45 (1...32).contains(code.count),
46 code.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber) }) else { return false }
47 destination = .invite(code: code)
48 return true
49 default:
50 return false
51 }
52 }
53}
Apple 在 Universal Links 处理说明里提醒,链接是进入 App 的一个攻击入口。所有参数都要校验,不要让链接直接执行删除数据、付款这类操作,最多跳到对应页面,让用户自己确认。
SwiftUI:onOpenURL
SwiftUI 的 onOpenURL 同时接收 URL Scheme 和 Universal Links,Universal Links 直接以 URL 形式送来,不用处理 NSUserActivity,见 onOpenURL 文档。把 App 入口文件改成下面这样。
1import SwiftUI
2
3@main
4struct MyAppApp: App {
5 @StateObject private var router = DeepLinkRouter()
6
7 var body: some Scene {
8 WindowGroup {
9 LinkTestView()
10 .environmentObject(router)
11 .onOpenURL { url in
12 router.handle(url)
13 }
14 }
15 }
16}
17
18struct LinkTestView: View {
19 @EnvironmentObject private var router: DeepLinkRouter
20
21 var body: some View {
22 VStack(spacing: 16) {
23 Text("最近收到的链接").font(.headline)
24 Text(router.lastURL).font(.footnote).multilineTextAlignment(.center)
25 switch router.destination {
26 case .product(let id):
27 Text("打开商品 \(id)").font(.title2)
28 case .invite(let code):
29 Text("邀请码 \(code)").font(.title2)
30 case nil:
31 Text("等待跳转").foregroundStyle(.secondary)
32 }
33 }
34 .padding()
35 }
36}
真实 App 里把 LinkTestView 换成你的根视图,根据 router.destination 切换页面或弹出详情即可。
UIKit:场景代理
用了 SceneDelegate 的 UIKit 工程,链接分两种时机送来。App 没在运行时,从 scene(_:willConnectTo:options:) 的 connectionOptions 里取。App 已经在运行或在后台时,URL Scheme 走 scene(_:openURLContexts:),Universal Links 走 scene(_:continue:)。
1import UIKit
2
3class SceneDelegate: UIResponder, UIWindowSceneDelegate {
4 var window: UIWindow?
5 let router = DeepLinkRouter()
6
7 func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
8 options connectionOptions: UIScene.ConnectionOptions) {
9 // 这里保留你原来创建 window 的代码
10
11 // 冷启动:URL Scheme
12 if let url = connectionOptions.urlContexts.first?.url {
13 router.handle(url)
14 }
15 // 冷启动:Universal Links
16 if let activity = connectionOptions.userActivities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb }),
17 let url = activity.webpageURL {
18 router.handle(url)
19 }
20 }
21
22 // 运行中:URL Scheme
23 func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
24 if let url = URLContexts.first?.url {
25 router.handle(url)
26 }
27 }
28
29 // 运行中:Universal Links
30 func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
31 guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
32 let url = userActivity.webpageURL else { return }
33 router.handle(url)
34 }
35}
没有用场景的老工程,在 AppDelegate 里实现下面两个方法,返回值表示是否处理了这个链接。
1import UIKit
2
3@main
4class AppDelegate: UIResponder, UIApplicationDelegate {
5 var window: UIWindow?
6 let router = DeepLinkRouter()
7
8 // URL Scheme
9 func application(_ app: UIApplication, open url: URL,
10 options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
11 router.handle(url)
12 }
13
14 // Universal Links
15 func application(_ application: UIApplication, continue userActivity: NSUserActivity,
16 restorationHandler: @escaping ([any UIUserActivityRestoring]?) -> Void) -> Bool {
17 guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
18 let url = userActivity.webpageURL else { return false }
19 return router.handle(url)
20 }
21}
Universal Links 怎么配置?
Universal Links 是 App 和网站双向确认。App 的签名里声明「我能打开 example.com 的链接」,网站上放一个文件声明「这个 App 可以打开我的链接」,两边对上,系统才会把链接交给 App。
第一步:App ID 开启 Associated Domains
- 登录 developer.apple.com,进入 Certificates, Identifiers & Profiles,在 Identifiers 页点开 App 的 App ID。
- 在 Capabilities 列表勾选 Associated Domains,点 Save。App ID 必须是精确的 Bundle ID,通配 App ID 不能开这项。
- 在 Profiles 页逐个点开这个 App ID 的描述文件(Development、Ad Hoc、App Store),点 Edit,直接 Save,再 Download。
- 用新文件替换工程
keychain目录里的旧文件,旧的删掉。
在 VS Code 里双击新的 .mobileprovision,权限里出现 com.apple.developer.associated-domains 就说明描述文件已经包含这项能力,它的值通常是 *,具体域名由 App 的 entitlements 决定。App ID 和描述文件的关系见 Bundle ID、App ID、证书和描述文件的关系。
第二步:entitlements 写上 applinks 域名
在 App 源码文件夹里新建 MyApp.entitlements,内容如下。工程原本已有 entitlements 文件的(例如开了推送或 App Groups),把这个键加进原文件,不用新建。
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>com.apple.developer.associated-domains</key>
6 <array>
7 <string>applinks:example.com</string>
8 <string>applinks:www.example.com</string>
9 </array>
10</dict>
11</plist>
格式是 applinks: 加完整域名,只写域名,不带 https://、路径、查询参数和结尾的斜杠。example.com 和 www.example.com 是两个域名,要分别写,每个域名都要放自己的网站文件。想匹配所有子域名可以写 applinks:*.example.com。规则见 Apple 的支持关联域。
然后让 target 用这个文件。以下两种做法任选其一。
- VS Code 插件。右键 target 选「KXBuild: 编辑目标设置」,在 General 页 Signing 分组的「Entitlements 文件」里填
MyApp/MyApp.entitlements,Ctrl+S 保存。 - 命令行。在工程目录执行下面这条,路径相对于 .xcodeproj 所在目录。
1kxbuild project add-setting -k CODE_SIGN_ENTITLEMENTS -v MyApp/MyApp.entitlements -t MyApp -P .\MyApp.xcodeproj
以后要改域名,双击 .entitlements 用属性列表编辑器编辑即可。设置项的位置见工程设置编辑器。
第三步:在网站上放 apple-app-site-association
新建一个名为 apple-app-site-association 的文件,没有扩展名,内容是 JSON。appIDs 里每一项是「Team ID.Bundle ID」,Team ID 在开发者网站右上角账号下的 Membership details 里,是 10 位字符。
1{
2 "applinks": {
3 "details": [
4 {
5 "appIDs": ["ABCDE12345.com.example.myapp"],
6 "components": [
7 {
8 "/": "/admin/*",
9 "exclude": true,
10 "comment": "后台页面始终用浏览器打开"
11 },
12 {
13 "/": "/product/*",
14 "comment": "商品页交给 App"
15 },
16 {
17 "/": "/invite",
18 "?": { "code": "?*" },
19 "comment": "带 code 参数的邀请链接交给 App"
20 }
21 ]
22 }
23 ]
24 }
25}
components 是新格式,iOS 13 起支持,按数组顺序逐条匹配,第一条匹配上的生效,所以 exclude 的规则要写在前面。/ 匹配路径,? 匹配查询参数,# 匹配锚点。* 匹配任意多个字符,? 匹配一个字符,?* 表示至少一个字符。字段定义见 Apple 的 applinks 参考。
把文件放到下面这个地址,域名和 entitlements 里写的一致。
1https://example.com/.well-known/apple-app-site-association
服务器要满足这几条。
| 要求 | 说明 |
|---|---|
| 必须 HTTPS | 证书有效,不能是自签名证书(开发模式除外,见下文) |
| 不能重定向 | 直接返回 200。example.com 跳到 www.example.com、http 跳 https 的规则都不能作用在这个地址上 |
| 返回 JSON | Content-Type 设为 application/json。没有扩展名的文件,很多服务器默认按 application/octet-stream 或 text/html 返回 |
| 公网可访问 | Apple 的 CDN 要能直接下载,不能要求登录、不能被防火墙或防爬虫规则拦截 |
| 每个域名一份 | www 和不带 www 的域名各自要能访问到这个文件 |
前两条来自 Apple 的支持关联域原文。Content-Type 是 Apple 早期文档的要求,按 application/json 配置最稳妥。nginx 可以这样写。
1location = /.well-known/apple-app-site-association {
2 default_type application/json;
3}
上传后在电脑上检查,第一行是 HTTP/1.1 200 或 HTTP/2 200、没有 location: 这一行、content-type 是 application/json 才算对。
1curl -sI https://example.com/.well-known/apple-app-site-association
Windows 的 PowerShell 里把 curl 写成 curl.exe。
Apple CDN 缓存与开发模式
从 iOS 14 起,手机不直接访问你的服务器,而是从 Apple 管理的 CDN 下载这个文件。按 Apple 的说明,CDN 会在 24 小时内向你的服务器请求文件,App 安装后手机大约每周检查一次更新。所以改了网站文件后,已安装的手机不一定立刻生效。
浏览器打开下面这个地址,可以看到 CDN 当前缓存的内容,和你服务器上的不一致就说明还没刷新。
1https://app-site-association.cdn-apple.com/a/v1/example.com
开发阶段用的是内网服务器、自签名证书,或者想绕开 CDN 缓存,可以用开发模式(alternate mode),让手机直接访问你的服务器。按 Apple 的配置关联域说明,要同时满足三个条件。
- entitlements 里的域名后面加
?mode=developer,例如applinks:dev.example.com?mode=developer。 - App 用 Development 描述文件签名,也就是 Debug 包。
- 手机打开开发者模式,并在「设置 → 开发者」里打开 Associated Domains Development。
开发模式下服务器可以用任何 SSL 证书,包括系统不信任的证书。上架前必须把 ?mode=developer 删掉。Debug 和 Release 想用不同域名的,给 Release 单独建一个 entitlements 文件,在工程设置编辑器的 Build Settings 页把 CODE_SIGN_ENTITLEMENTS 按配置展开,Debug 和 Release 各填一个。
怎么在 iPhone 上验证跳转?
装 Debug 包并看日志
在 KXBuild 视图的「构建配置」节点选 Debug,运行 KXBuild: 构建并运行,装到手机上。系统在安装时下载网站文件做验证,改了 entitlements 或网站文件后,先删掉手机上的 App 再装,验证才会重做。App 收到链接时,「输出 → KXBuild Console」里会打出「收到链接:」这一行。断点调试方法见 Windows 上真机调试 iOS App。
验证 URL Scheme
- 在 iPhone 的 Safari 地址栏输入
myapp://product/42,点「前往」。 - 系统弹出「在 MyApp 中打开?」,点「打开」。
- App 启动并显示「打开商品 42」。分别在 App 已关闭和已在后台两种状态下各试一次,确认两条代码路径都走通。
验证 Universal Links
Universal Links 要「点」才触发,测试方法和普通链接不一样。
- 在 iPhone 的备忘录里新建一条,写上
https://example.com/product/42,或者用信息 App 发给自己。 - 点这个链接,应该直接打开 App 并显示「打开商品 42」,不经过 Safari。
- 长按这个链接,弹出的菜单里应该有「在 MyApp 中打开」。这是判断系统是否认可关联最直接的办法。
- 再试一条被排除的地址,例如
https://example.com/admin/1,它应该用 Safari 打开。
下面这些情况不会跳转 App,属于系统的正常行为,不要当成配置错误。
| 操作 | 结果 | 原因 |
|---|---|---|
| 在 Safari 地址栏直接输入或粘贴链接 | 在 Safari 里打开 | 用户主动输入网址,系统认为是想看网页 |
| 在 example.com 的网页里点同一域名的链接 | 在 Safari 里打开 | Apple 说明同域名内的点击留在浏览器里,点其他域名指向你的链接才打开 App |
自己的 App 里用 UIApplication.shared.open 打开自己的 Universal Link |
不会回到自己 | Apple 说明 App 用这些方法打开自己网站的链接不会进入自己 |
| 之前在长按菜单里选过「在 Safari 中打开」 | 以后都在 Safari 里打开 | 系统记住了用户的选择。长按链接改选「在 MyApp 中打开」即可恢复 |
后两条见 Apple 的让 App 和网站链接到你的内容。微信等部分 App 的内置浏览器有自己的打开规则,不一定直接跳转,测试时以备忘录和信息为准。
Universal Links 不跳转怎么办?十个原因逐个排查
| # | 原因 | 怎么确认和处理 |
|---|---|---|
| 1 | App ID 没开 Associated Domains,或开了之后没重新生成描述文件 | 双击 .mobileprovision 看权限里有没有 com.apple.developer.associated-domains,没有就重新生成并替换 |
| 2 | target 没用上 entitlements 文件 | 在工程设置编辑器的 Build Settings 页搜索 CODE_SIGN_ENTITLEMENTS,确认路径正确 |
| 3 | entitlements 里的域名写错 | 不能带 https:// 和路径,www 和不带 www 要分别写 |
| 4 | appIDs 写错 |
格式是 Team ID 加点加 Bundle ID,Team ID 和 Bundle ID 都要和签名用的一致 |
| 5 | 文件地址不对或有扩展名 | 必须是 /.well-known/apple-app-site-association,文件名不能带 .json |
| 6 | 地址被重定向 | curl -sI 看返回是不是 200,有 location: 就是重定向,给这个地址单独关掉跳转规则 |
| 7 | JSON 格式错误或 Content-Type 不对 | 用 JSON 校验工具检查,确认返回 application/json |
| 8 | Apple CDN 还是旧文件或拿不到文件 | 打开 CDN 地址对比内容。开发阶段改用 ?mode=developer,上线后等 CDN 刷新 |
| 9 | components 规则没匹配上 |
检查路径大小写、exclude 规则是否写在了前面、查询参数规则是否过严 |
| 10 | 测试方式不对 | 不要在 Safari 地址栏输入,用备忘录或信息里的链接点击,并删除 App 重装后再测 |
想看系统验证 Universal Links 的过程,可以在手机连着电脑时运行 kxdevice syslog,配合 findstr swcd(macOS、Linux 上用 grep swcd)过滤出负责关联域的系统进程日志,命令说明见 kxdevice 命令参考。签名和描述文件相关的报错见 iOS 签名报错与 ipa 安装失败的解决办法。
常见问题
Universal Links 和 URL Scheme 应该用哪个?
对外分享的链接用 Universal Links,它是普通网址,没装 App 时能打开网页,别的 App 也无法冒用。App 之间互相调用、第三方登录或支付回调用 URL Scheme。两者可以同时配,共用一套解析代码。
apple-app-site-association 文件放在哪里?
放在 https://你的域名/.well-known/apple-app-site-association,文件没有扩展名,必须 HTTPS、直接返回 200、不能重定向,Content-Type 设为 application/json。每个子域名都要能访问到自己的这份文件。
改了 apple-app-site-association 为什么不生效?
iOS 14 起手机从 Apple 的 CDN 获取这个文件,CDN 会在 24 小时内更新,已安装的手机大约每周检查一次。开发时在 entitlements 的域名后加 ?mode=developer,打开手机「设置 → 开发者」里的 Associated Domains Development,再删除 App 重装即可直接读取服务器上的文件。
在 Safari 里输入网址为什么不跳转 App?
这是系统的正常行为。在地址栏直接输入或粘贴的网址、以及同一域名网页内的点击都会留在 Safari 里。测试时把链接写进备忘录或用信息发给自己,再点击。
SwiftUI 处理 Universal Links 要用 NSUserActivity 吗?
不用。SwiftUI 的 onOpenURL 会把 Universal Links 直接作为 URL 传给你,和 URL Scheme 用同一个回调。UIKit 工程才需要在场景代理的 scene(_:continue:) 里从 NSUserActivity 取 webpageURL。