跳到主要内容
教程目录

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 是 App 和网站双向确认。App 的签名里声明「我能打开 example.com 的链接」,网站上放一个文件声明「这个 App 可以打开我的链接」,两边对上,系统才会把链接交给 App。

第一步:App ID 开启 Associated Domains

  1. 登录 developer.apple.com,进入 Certificates, Identifiers & Profiles,在 Identifiers 页点开 App 的 App ID。
  2. 在 Capabilities 列表勾选 Associated Domains,点 Save。App ID 必须是精确的 Bundle ID,通配 App ID 不能开这项。
  3. 在 Profiles 页逐个点开这个 App ID 的描述文件(Development、Ad Hoc、App Store),点 Edit,直接 Save,再 Download。
  4. 用新文件替换工程 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 的配置关联域说明,要同时满足三个条件。

  1. entitlements 里的域名后面加 ?mode=developer,例如 applinks:dev.example.com?mode=developer。
  2. App 用 Development 描述文件签名,也就是 Debug 包。
  3. 手机打开开发者模式,并在「设置 → 开发者」里打开 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

  1. 在 iPhone 的 Safari 地址栏输入 myapp://product/42,点「前往」。
  2. 系统弹出「在 MyApp 中打开?」,点「打开」。
  3. App 启动并显示「打开商品 42」。分别在 App 已关闭和已在后台两种状态下各试一次,确认两条代码路径都走通。

验证 Universal Links

Universal Links 要「点」才触发,测试方法和普通链接不一样。

  1. 在 iPhone 的备忘录里新建一条,写上 https://example.com/product/42,或者用信息 App 发给自己。
  2. 点这个链接,应该直接打开 App 并显示「打开商品 42」,不经过 Safari。
  3. 长按这个链接,弹出的菜单里应该有「在 MyApp 中打开」。这是判断系统是否认可关联最直接的办法。
  4. 再试一条被排除的地址,例如 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。