概要
- 在 Web 和 App 中表示我们的内容的一个 URL
- 通用链接允许用户在 App 中而不是在 Web 浏览器中打开内容,从而让你提供更丰富的体验
- 在 iOS、tvOS 和 macOS App 中可用
- 在 App 和网站之间安全关联
什么是通用链接
通用链接是 HTTP 或 HTTPS URL,无论是在网上还是在我们的 App 中,Apple 的操作系统将其识别为指向网络或 App 中的资源。这意味着,无论用户是否安装了 App,一个 URL 都可以表示该内容,它是提高用户在 App 中参与度的好方法。
iOS 9、tvOS 10 和 macOS 10.15 中引入了通用链接。通用链接在我们的 App 和网站之间是安全关联的,App 中有一个权限文件,这个权限文件指示它可以代表哪些 domain;Web 服务器上则有一个 JSON 文件,该文件包含了关于我们的 App 中可以表示其域的哪些部分的更多细节。这种双向安全握手确保没有人可以将用户重定向到他们的 App,苹果建议我们把使用自定义 URL 方案(custom URL schemes) 的地方迁移到通用链接,自定义 URL 方案本质上是不安全的,并且可能被恶意开发人员滥用,强烈建议不要使用自定义 URL 方案。
怎么创建通用链接
1. 配置你的网络服务器
安装一个有效的 HTTPS 证书
由于 HTTP 不安全,不能用于验证 App 和网站之间的关联,所以,我们的 Web 服务器必须有一个有效的 HTTPS 证书,并且用于签名 HTTPS 证书的根证书必须被操作系统识别,不支持自定义根证书。添加 apple-app-site-association 文件
生成证书并配置到服务器之后,还需要添加 apple-app-site-association JSON 文件。当你的 App 安装到 Apple 设备上时,操作系统将下载该文件,以确定服务器将允许 App 使用哪些服务,系统还会定期下载此文件的更新,通用链接就是这个文件中可能包含的许多服务之一。苹果推荐我们服务器把这个文件放置到 https://example.com/.well-known/apple-app-site-association 路径,不推荐使用其它路径,注意不要对 apple-app-site-association 进行签名。
// Your apple-app-site-association File
{
"applinks": {
"apps": [],
"details": [
{
"appID": "ABCDE12345.com.example.app",
"paths": [ "/path/*/filename" ]
}
]
}
}
这个文件顶层是一个字典,它的 key 是服务类型。对于通用链接,key 是 applinks,在顶层的 key 下面是 apps 和 details。
apps:对于通用链接,apps 的值总是一个空数组。在 iOS13、tvOS 13 和 macOS 10.15 及以上系统,我们可以删除 apps 字段,如果需要支持 iOS 12、tvOS 12或更早版本,则仍需保留 apps 字段。
details: 包含一个字典数组,每个字典代表一个特定 App 的通用链接配置。在 iOS 12、tvOS 12或更早版本,details 的值支持使用字典结构(现在是数组)。
appID:它的值是App 标识符, App 标识符是一个由 Apple 提供的10位字母数字前缀和 bundleId 组成。这个前缀可能等于也可能不等于你的团队标识符,检查开发人员门户网站 (https://developer.apple.com) 以确认你的App 标识符。如果有多个相同通用链接配置的 App,且 target 是 iOS13、tvOS 13 和 macOS 10.15 及以上系统,可以使用 ** appIDs** 来减小该文件的大小,该键的值是一个App 标识符数组。如果需要支持 iOS 12、tvOS 12或更早版本,则应该继续为每个 App 使用appID。
// Your apple-app-site-association File
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": [ "ABCDE12345.com.example.app", "ABCDE12345.com.example.app2" ]
"paths": [ "/path/*/filename" ]
}
]
}
}
paths:它的值是包含路径的数组。路径使用模式匹配,且模式匹配与在终端中执行相同,星号用于表示多个通配符,而问号只匹配一个字符。
components:从 iOS13、tvOS 13 和 macOS 10.15 开始,苹果使用 components 替换 paths,它的值是一个字典数组,其中每个字典都包含 0 个或多个 URL 组件以进行模式匹配。如果需要支持 iOS 12、tvOS 12或更早版本,可以保留paths。 iOS13、tvOS 13 和 macOS 10.15 及以上系统,如果 components 存在,将忽略 paths。
你可以匹配 URL 的 path 组件,该组件的 key 是 "/"。
// Your apple-app-site-association File
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": [ "ABCDE12345.com.example.app", "ABCDE12345.com.example.app2" ]
"components": [
{
"/": "/path/*/filename",
}
]
}
]
}
}
你可以匹配 URL 的 fragment 组件,它的 key 是 "#";你还可以匹配 URL 的 query 组件,它的 key 是"?"。
// Your apple-app-site-association File
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": [ "ABCDE12345.com.example.app", "ABCDE12345.com.example.app2" ]
"components": [
{
"/": "/path/*/filename",
"#": "*fragment",
"?": "widget=?*"
}
]
}
]
}
}
现在很多 URL 将 query 组件分成键值对,称为 query item。对于 query 组件,可以指定字典 (而不是字符串) 作为它的值,从而模式匹配单个 query item。
// Your apple-app-site-association File
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": [ "ABCDE12345.com.example.app", "ABCDE12345.com.example.app2" ]
"components": [
{
"/": "/path/*/filename",
"#": "*fragment",
"?": { "widget": "?*", "grommet": "please" }
}
]
}
]
}
}
URL 可以重复 query item 名称,并且操作系统将要求给定 query item 名称的所有实例都匹配模式,没有值的 query item 和没有 query item 字段都由操作系统自动处理,就好像它们的值等于空字符串一样。
要使用 components 字典匹配待筛选 URL,所有指定的 component 必须匹配。如果不指定 component,操作系统的默认行为就是忽略那个 component。
例如,你的 App 不关心 URL 的 fragment 组件,你就不需要指定它。此外,我们的网站可能有一些部分还不能在 App 中显示,可以使用 true 设置 exclude 来排除这些部分。component 中的 exclude 与在 paths 中使用 not 关键词具有相同的行为。
// Your apple-app-site-association File
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": [ "ABCDE12345.com.example.app", "ABCDE12345.com.example.app2" ]
"components": [
{
"/": "/path/*/filename",
"#": "*fragment",
"?": { "widget": "?*", "grommet": "please" }
"exclude": true
}
]
}
]
}
}
模式匹配的增强:从 iOS13.5、macOS 10.15.5 开始,支持不区分大小写的模式匹配,在 components 中新增 caseSensitive,将它的值设置为 false 以禁用区分大小写 (即不区分大小写)。
"components": [{ "/": "/sourdough/?*", "caseSensitive": false }]
Unicode 模式的增强:从 iOS14、macOS 11 开 始,支持 Unicode 编码,即禁用百分比编码(比如:URL 中的中文字符;俄语中的音标等),禁用后使用 32bit Unicode 码位序列,而不是 7 位 ASCII 字符。
"components": [{ "/": "/蚂蚁上树/?*", "percentEncoded": false }]
上面两种增强功能可以同时使用,并支持添加默认值 ** defaults,默认值是应用于所有模式的值的字典,除非某个模式显式地覆盖它。如果 defaults 是与 components 同一级的组件,它将应用于components** 数组中的所有模式;如果 defaults 是与 details 同一级的组件,它将应用于这个 domain 下的所有通用链接。
"defaults": { "percentEncoded": false, "caseSensitive": false },
"components": [
{ "/": "/蚂蚁上树/?*", "percentEncoded": false },
{ "/": "/sourdough/?*", "caseSensitive": false },
{ "/": "/canat onbed/?*", "caseSensitive": false, "percentEncoded": false},
]
下面是一些 URL 示例,我们需要它们进行模式匹配。
// Pattern-Matching Examples
"appIDs": [ "ABCDE12345.com.example.app" ],
"components" : [
{
"/": "/*/order/" https://example.com/taco/order/
}, https://example.com/salad/order/
{
"/": "/taco/*", https://example.com/taco/?cheese=panela
"?": { "cheese" : "?*" }
},
{
"#": "coupon-1???", https://example.com#coupon-1234
"exclude": true
},
{
"/": "", https://example.com#coupon-5678
"#": "coupon-????"
}
]
替代变量(Substitution variables):在 macOS 10.15.6 和 iOS 13.5 以上系统版本可用。它们是可以匹配的字符串的命名列表,这些变量出现在模式匹配字符串中代表你指定的所有值,它们的名字几乎可以包含任意字符(除了 "$"、"("、")"),在模式中遇到变量名时总是区分大小写。另外,你指定替代变量的值可以包含用于通配符匹配的问号和星号,但不能引用替代变量。默认情况下,如果模式匹配区分大小写,则值也会区分大小写。如果你启用了不区分大小写的模式匹配,值也一样。
下图是苹果内置的一些常见的替代变量
下面我们来看看替代变量如何使用?
首先,在 applinks 下添加一个新的键值对 substitutionVariables,它的值是字典,而字典中的 key 是变量名,value 则是包含要匹配的子字符串的数组。
{
"applinks": {
"substitutionVariables": {
"food": [ "burrito", "shawarma", "sushi", "curry-pad-thai" ]
},
"details": [{
"appIDs": [ "ABCDE12345.com.example.restaurant" ],
"components": [
{ "/": "/$(lang)_CA/$(food)", "exclude": true }, // 这一行代码为了排查不想要的匹配
{ "/": "/$(lang)_$(region)/$(food)" }
]
}]
}
}
如何处理 ** substitutionVariables** 中的特殊要求呢?比如,加拿大地区的 food 与其它地区的 food 有一些差异。
我们可以在 substitutionVariables 字典中添加新的变量 "Canadian food"。如果这些变量名中存在 Unicode 编码,我们还可以在 substitutionVariables 字典中添加 "percentEncoded" 字段并设置其值为 false 来禁用百分比编码,从而使用 Unicode 编码。
{
"applinks": {
"substitutionVariables": {
"food": [ "burrito", "shawarma", "sushi", "curry-pad-thai" ],
"Canadian food": [ "burrito", "poutine", "butter-tart", "fiddlehead" ],
"percentEncoded": false
},
"details": [{
"appIDs": [ "ABCDE12345.com.example.restaurant" ],
"components": [
{ "/": "/$(lang)_CA/$(Canadian food)" },
{ "/": "/$(lang)_CA/$(food)", "exclude": true }, // 这一行代码为了排查不想要的匹配
{ "/": "/$(lang)_$(region)/$(food)" }
]
}]
}
}
进入 App 配置之前,先聊聊国际化
URL 始终使用 ASCII 编码,所以模式匹配也是使用 ASCII 编码来完成的。当你创建 JSON 时,如果需要匹配当前的 Unicode 字符,需要对其进行编码。你可能想为支持的每个国家提供特定国家的模式,这大大增加了 JSON 的大小。如果国家之间的模式匹配是一致的,则可以通过使用通配符 ?? 简化 JSON 来减少服务器之间的流量。例如,如果你使用两个字母的国家码来分隔内容,那么只需使用"??"。如果遇到带有无效国家码或特定语言环境标识符的 URL,则将其视为用户的当前语言环境。
从 iOS13、tvOS 13 和 macOS 10.15 开始,操作系统将根据用户最可能浏览的位置对 apple-app-site-association 文件下载进行优先级排序,苹果仍然会在安装 App 时下载它们,但是优先级不同,顶级域名 .com、.net 和 .org 是高优先级域,国家代码 TLD(也称为 ccTLD),如果国际化的 TLD 与用户当前的语言环境设置匹配,那么它们也会被优先化。
2.配置你的 App
- 添加 Associated Domains Entitlement
在 Xcode 中打开项目,并导航到 project settings,添加 Associated Domains 功能,这将向选定的 target 添加一个新的权限——关联域权限。
关联域权限的值是 "<类型>:<域名>" 的字符串数组,对于通用链接服务类型是 applinks,这个数组中的值的顺序会被系统忽略。
<array>
<string>applinks:www.example.com</string>
</array>
在这里,我们声明 App 支持通用链接,例如 www.example.com。当 App 被安装时,操作系统将访问 www.example.com,寻找 apple-app-site-association 文件。如果它存在,并且包含这个 App 的 App 标识符 信息,那么关联就被确认了。还可以指示对给定 domain 的 sub-domain 的通配符支持,如下所示,在通用链接查找期间,精确域比通配符域具有更高的优先级。
<array>
<string>applinks:www.example.com</string>
<string>applinks:*.example.com</string>
</array>
最后,来看一个国际化 URL 的例子,国际化 domain 需要使用 Punycode 进行编码。
假设我们的 App 声明了对某些 domain 的支持,我们需要在 URL 进入时解析它们。通用链接是基于 Foundation 的 NSUserActivity 类的,由 AppDelegate 处理,我们将需要一个处理程序来处理传入的用户活动。我们的 AppDelegate 中可能已经有了这个方法,该方法返回一个 bool,如果你能够成功打开用户活动则返回 true,否则返回 false。如果你使用 UIScene,可以使用类似的委托方法。
// Configuring Your App
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// 将通用链接与可能支持的其它用户活动区分开来
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
// 从用户活动对象获取网络页面 URL,对于一个通用链接它永远不会是 nil
let url = userActivity.webpageURL,
// 从 URL 构建一个 URL 组件结构体,应该始终使用 URL 组件解析 URL,使用正则表达式或手动解析 URL 字符串可能会使你容易受到安全问题的影响
let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return false
}
// 比如,选择你感兴趣的组件来校验
for queryItem in components.queryItems ?? [] {
...
}
// 当你支持来自多个域的通用链接时,不要忘记检查 host 组件
return true
}
// Opening universal links in other applications
let url = /* ... */
UIApplication.shared.open(url, options: [:]) { success in /* ... */ }
apple-app-site-association 文件是如何进入用户设备的?这一流程存在什么问题?苹果又是如何改善的呢?
当我们打开 App Store,选择想要下载的 App,下载并安装 App 后,操作系统会检查其应用权限(这里是关联域权限),发现它需要一个或多个 apple-app-site-association 文件数据,此时,设备将打开到承载该文件的 Web 服务器的链接,以便下载该文件。
现在,设备的带宽有限,如果需要从多个 Web 服务器下载多个文件时,设备需要一次下载多个文件。当下载完一个文件后,apple-app-site-association 文件从 Web 服务器到进入设备,由关联域守护程序解析,App 的通用链接就会活跃起来,然后设备移动到下一个排队的服务器去下载 apple-app-site-association 文件,依次类推。
如果下载有问题怎么办?
让我们再次尝试下载那几个文件,首先设备尝试建立到服务器的连接,假设 Wi-Fi 中断或者服务器崩溃,又或者根本无法从设备访问服务器,那么下载的程度取决于故障的确切性质,但数据不会传到设备上,最终设备不得不放弃下载,然后转到下一个服务器下载另一个文件。这会使设备处于不一致的状态,即虽然安装了 App,但它的通用链接和其关联域数据不可用,这种状态可能持续数小时或数天,直到系统下次尝试更新该 App 的数据。
苹果的改进
我们再次在 App Store 上选择一个 App,然后下载到设备上,该设备会看到 App 具有关联域权限,但还没有连接到关联的 Web 服务器,而是连接到管理关联域数据的内容传递网络 (即 Apple CDN)。CDN 是一种功能强大的工具,可以缓存大量数据,因此它可能已经存储了来自该 Web 服务器的数据。我们假设它没有存储,它可以代表该设备连接到服务器。CDN 很强大,因此它可以同时连接该设备上所有 App 的所有服务器,它可以同时下载所有这些域的 apple-app-site-association 文件,然后缓存它们,并通过单个网络连接将数据发送到设备。
苹果使用 CDN 的原因
苹果已经构建了一个可专门用于关联域和 apple-app-site-association 文件的 CDN ,可以对它进行微调,然后为用户提供最好的体验。由于 CDN 缓存了来自多个 Web 服务器的数据,所以苹果使用 HTTP/2 连接来请求所需的所有数据,而不是每个 Web 服务器使用单独的连接。缓存可以减少服务器上的总负载,从每天可能的数百万次请求降低到仅有的几次请求。而且 CDN 以顺畅快速的连接著称,总体上让用户拥有更安心的 App 体验。
在 iOS14 和 macOS 11 系统之后,Web 服务器只需要接收来自 Apple CDN 对 apple-app-site-association 的文件请求。该 CDN 位于公网上,但并不是所有的服务器都能访问。
如果 Web 服务器无法从公网访问 Apple CDN 该怎么办?如何继续支持这些场景?
无法访问的原因可能有:服务器是用于部署前测试的 Web 服务器或者是仅供连接到内网的 Web 服务器。
Apple CDN 有一种替代模式(Alternate modes),允许你绕过 CDN ,直接连接到我们控制的 domain。有 2 种替代模式,它们的区别在于何时使用它们。开发者模式是在你将 App 部署到 TestFlight 或最终用户之前专为构建和测试 App 而设计的;托管模式用于使用 MDM 配置文件安装 App 期间。开发者模式可以在 Web 服务器上使用任意有效的 SSL 证书(即使它不受操作系统内置证书存储的信任)。
如何在设备上启用替代模式呢?
苹果要求用户在 iOS、watchOS 和 tvOS 上选择加入开发者模式。在 iOS 设备上打开设置 App,选择开发者设置,它们将在我们的设备第一次连接到运行 Xcode 的 Mac 之后出现,在 "开发者设置"下启用 "Associated Domains Development" 选项就能把该设备置为开发者模式。在 Mac 设备上,这个过程稍有不同,打开终端,输入 "swcutil developer-mode -e true" 命令,系统将提示你输入管理员密码或TouchID,授予权限后,将启用开发者模式,这是针对每个用户的操作。
由于开发者模式是全局切换的,我们不想为所有的 App 都启用它,所以它只对使用开发配置文件签名的 App 生效。在 App Store 或 TestFlight 上签名发布的 App 或已经签名和公证的 Mac App,不能与这个替代模式一起使用。
最后,开发者模式和托管模式要求我们在 .well-known 目录中 (不是 domain 的根目录) 托管 apple-app-site-association 文件。
最佳实践
- 优雅地失败 可能会向你提供表示过期、无效或不存在内容的 URL,如果你确定一个通用链接不能被你的 App 打开,你可以尝试在 Safari 视图控制器中打开它,这可以让用户参与你的 App。如果 Safari 视图控制器不是选项,则考虑在 Safari 中打开 URL,或者至少提示有关问题的详细信息,避免将用户发送到空白屏幕。
- 如果有人访问你的网站,请使用 Smart App Banner 提供到 App Store 或你的内容的链接。Smart App Banner 与 Safari 无缝集成,不需要 JavaScript 或 自定义 URL Scheme 来支持它。