iOS 通用链接(Universal Links)

概要

  • 在 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 下面是 appsdetails

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 以上系统版本可用。它们是可以匹配的字符串的命名列表,这些变量出现在模式匹配字符串中代表你指定的所有值,它们的名字几乎可以包含任意字符(除了 "$"、"("、")"),在模式中遇到变量名时总是区分大小写。另外,你指定替代变量的值可以包含用于通配符匹配的问号和星号,但不能引用替代变量。默认情况下,如果模式匹配区分大小写,则值也会区分大小写。如果你启用了不区分大小写的模式匹配,值也一样。

下图是苹果内置的一些常见的替代变量


image.png

下面我们来看看替代变量如何使用?

首先,在 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 证书(即使它不受操作系统内置证书存储的信任)。


image.png

如何在设备上启用替代模式呢?
苹果要求用户在 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 文件。

最佳实践

  1. 优雅地失败 可能会向你提供表示过期、无效或不存在内容的 URL,如果你确定一个通用链接不能被你的 App 打开,你可以尝试在 Safari 视图控制器中打开它,这可以让用户参与你的 App。如果 Safari 视图控制器不是选项,则考虑在 Safari 中打开 URL,或者至少提示有关问题的详细信息,避免将用户发送到空白屏幕。
  2. 如果有人访问你的网站,请使用 Smart App Banner 提供到 App Store 或你的内容的链接。Smart App Banner 与 Safari 无缝集成,不需要 JavaScript 或 自定义 URL Scheme 来支持它。

参考

WWDC2019 session-717
WWDC2020 session-10098

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容