配置、实现和验证 Android App Links

1. 准备工作

用户访问深层链接的主要目标是获取他们想要看到的内容。深层链接具有帮助用户实现这一目标的所有功能。Android 会处理以下类型的链接:

  • 深层链接:能够让用户进入应用中的特定部分的 URI,可采用任何架构。
  • 网页链接:采用 HTTP 和 HTTPS 架构的深层链接。
  • Android App Links:采用 HTTP 和 HTTPS 架构且包含 android:autoVerify 属性的网页链接。

如需详细了解深层链接、网页链接和 Android App Links,请参阅 Android 文档以及 YouTubeMedium 上的速成课程。

如果您了解所有技术细节,请参阅随附的博文中的快速实现方法,只需几步即可完成设置。

Codelab 目标

此 Codelab 会逐步引导您完成内含 Android App Links 的应用,包括配置、实现和验证流程的最佳实践。

Android App Links 的优势之一是安全,也就是说任何未经授权的应用都无法处理您的链接。Android OS 必须验证您所拥有网站的链接,确认是否可将其视为 Android App Links。此过程称为网站关联

此 Codelab 侧重于拥有网站和 Android 应用的开发者。Android App Links 可实现应用与网站的无缝集成,从而提供更好的用户体验。

前提条件

学习内容

  • 了解为 Android App Links 设计网址的最佳实践。
  • 在 Android 应用中配置所有类型的深层链接。
  • 了解路径通配符(pathpathPrefixpathPatternpathAdvancePattern)。
  • 了解 Android App Links 验证流程,包括上传 Google Digital Asset Links (DAL) 文件、Android App Links 手动验证流程,以及 Play 管理中心内的“深层链接”信息中心。
  • 构建 Android 应用,其中包含不同地点多家餐馆的相关信息。

网页版餐馆应用的最终外观。 Android 版餐馆应用的最终外观。

所需条件

  • Android Studio Dolphin (2021.3.1) 或更高版本。
  • 用于托管 Google Digital Asset Links (DAL) 文件的网域。(可选:阅读这篇博文,以便您快速做好准备。)
  • 可选:Google Play 管理中心开发者账号,可让您使用另一种方法来调试 Android App Links 配置。

2. 设置代码

创建空白的 Compose 应用

如需启动 Compose 项目,请按以下步骤操作:

  1. 在 Android Studio 中,依次选择 File > New > New Project

在“File”菜单中依次选择以下路径:“New”和“New Project”。

  1. 从可用模板中选择 Empty Compose Activity

Android Studio 中的“New Project”模态,已选择“Empty Compose Activity”。

  1. 点击 Next,然后配置您的项目,并将其命名为“Deep Links Basics”。请确保您选择的 Minimum SDK 版本至少为 API 级别 21,这是 Compose 支持的最低 API。

Android Studio 中的新项目设置模态,其中包含以下菜单值和选项。“Name”的值为“Deep Links Basics”。“Package Name”的值为“com.devrel.deeplinksbasics”。“Save location”采用默认值。“Language”的值为“Kotlin”。“Minimum SDK”的值为“API 21”。

  1. 点击 Finish 并等待项目生成。
  2. 启动应用,确保应用处于运行状态。您应该会看到一个显示“Hello Android!”消息的空白屏幕。

空白的 Android 版 Compose 应用屏幕,其中显示“Hello Android”文本。

Codelab 的解决方案

您可以从 GitHub 获取本 Codelab 的解决方案代码:

git clone https://github.com/android/deep-links

或者,您可以下载代码库 Zip 文件:

首先,进入 deep-links-introduction 目录。您会在 solution 目录中找到该应用。建议您按照自己的节奏逐步完成 Codelab,必要时再查看解决方案。在此 Codelab 的学习过程中,我们会为您提供需要添加到项目的代码段。

3. 查看面向深层链接的网址设计

RESTful API 设计

链接是网页开发的重要部分,链接设计则是经过无数次迭代产生的各种标准。建议您查看并采用网页开发链接设计标准,这样能让链接更易于使用和维护。

REST(表征状态转移)就是其中一项标准,一种通常用于构建网络服务 API 的架构。Open API 是一项对 REST API 进行标准化的计划。此外,您还可以使用 REST 为深层链接设计网址。

请注意,您不是在构建网络服务。本部分只会着重介绍网址设计。

设计网址

首先,查看网站中生成的各个网址,了解这些网址在 Android 应用中代表的意义:

  • /restaurants:列出您管理的所有餐馆。
  • /restaurants/:restaurantName:显示某一家餐馆的详细信息。
  • /restaurants/:restaurantName/orders:显示餐馆的订单。
  • /restaurants/:restaurantName/orders/:orderNumber:显示餐馆中的特定订单。
  • /restaurants/:restaurantName/orders/latest:显示餐馆的最新订单。

网址设计的重要性

Android 的 intent 过滤器会处理其他应用组件中的操作,还会用于捕获网址。在定义 intent 过滤器以捕获网址时,您必须采用依赖于路径前缀和简单通配符的结构。以下示例展示了餐馆网站中现有网址的组成结构:

https://example.com/pawtato-3140-Skinner-Hollow-Road

尽管此网址指定了餐馆及其位置,但在为 Android 定义 intent 过滤器来捕获网址时,该路径可能仍会带来问题,因为应用是以不同的餐馆网址为基础,如下所示:

https://example.com/rawrbucha-2064-carriage-lane

https://example.com/pizzabus-1447-davis-avenue

使用路径和通配符定义 intent 过滤器来捕获这些网址时,您可以使用类似 https://example.com/* 的路径,这基本上是可行的。尽管如此,您并没有真正解决这个问题,因为网站的不同版块还有其他现有路径,例如:

交付日期:https://example.com/deliveries

管理员:https://example.com/admin

您可能不希望 Android 捕获这些网址,因为其中某些网址可能是内部网址,但定义的 intent 过滤器 https://example.com/* 会捕获它们,包括不存在的网址。当用户点击其中某个网址后,系统会在浏览器上打开该网址(Android 12 以上版本),或者可能会显示消除歧义对话框(Android 12 以下版本)。在此设计中,这并非预期行为。

现在,Android 提供的路径前缀可以解决这个问题,但必须重新设计网址,从:

https://example.com/*

更改为:

https://example.com/restaurants/*

添加分层嵌套结构可让 intent 过滤器明确定义,而 Android 会捕获您指定的网址。

网址设计最佳实践

以下是从 Open API 收集的一些最佳实践,适用于深层链接:

  • 将网址设计的重点放在链接显示的业务实体上。例如,对于电子商务,可以是“customers”和“orders”;对于旅行,可以是“tickets”和“flights”。在餐馆应用和网站中,您将使用“restaurants”和“orders”
  • 大多数 HTTP 方法(GET、POST、DELETE、PUT)都是动词,用于描述所发出的请求,但在网址端点中使用动词会让人感到困惑。
  • 如需描述集合,请使用实体的复数形式,例如 /restaurants/:restaurantName。这能让网址更易于阅读和维护。以下是每个 HTTP 方法的示例:

GET /restaurants/pawtato

POST /restaurants

DELETE /restaurants

PUT /restaurants/pawtato

每个网址都更易于阅读和理解其作用。请注意,此 Codelab 不会说明网络服务 API 的设计,以及每个方法的用途。

  • 使用逻辑嵌套将包含相关信息的网址进行分组。例如,其中一家餐馆的网址可以包含正在处理的订单:

/restaurants/1/orders

4. 查看数据元素

AndroidManifest.xml 文件是 Android 的重要组成部分,会将应用信息提供给 Android 构建工具、Android OS 和 Google Play。

对于深层链接,您必须使用 3 个主要标记来定义 intent 过滤器:<action><category><data>。本部分的主要重点是 <data> 标记。

<data> 元素会在用户点击链接后告知 Android OS 该链接的网址结构。您可以在 intent 过滤器中使用的网址格式和结构如下:

<scheme>://<host>:<port>[<path>|<pathPrefix>|<pathPattern>|<pathAdvancedPattern>|<pathSuffix>]

Android 会读取、解析和合并 intent 过滤器中的所有 <data> 元素,以反映属性的所有变体。例如:

AndroidManifest.xml

<intent-filter>
  ...
  <data android:scheme="http" />
  <data android:scheme="https" />
  <data android:host="example.com" />
  <data android:path="/restaurants" />
  <data android:pathPrefix="/restaurants/orders" />
</intent-filter>

Android 会捕获以下网址:

  • http://example.com/restaurants
  • https://example.com/restaurants
  • http://example.com/restaurants/orders/*
  • https://example.com/restaurants/orders/*

路径属性

path(适用于 API 1)

此属性会指定以 / 开头且与 intent 中“完整路径”匹配的完整路径。例如,android:path="/restaurants/pawtato" 只会匹配 /restaurants/pawtato 网站路径;如果路径为 /restaurant/pawtato,则因为缺少 s,系统会将该路径视为不匹配。

pathPrefix(适用于 API 1)

此属性会指定只与 intent 中路径的“初始部分”匹配的部分路径。例如,

android:pathPrefix="/restaurants" 将匹配餐馆路径 /restaurants/pawtato/restaurants/pizzabus 等。

pathSuffix(适用于 API 31)

此属性会指定与 intent 中路径的“末尾部分”完全匹配的路径。例如,

android:pathSuffix="tato" 将匹配以“tato”结尾的所有餐馆路径,例如 /restaurants/pawtato/restaurants/corgtato

pathPattern(适用于 API 1)

此属性会指定与 intent 中“包含通配符的完整路径”匹配的完整路径:

  • 星号 (*) 会匹配前一个字符出现 0 次到多次的序列。
  • 英文句点后跟星号 (.*) 匹配由零到多个字符构成的任意序列。

示例:

  • /restaurants/piz*abus:此模式会匹配“pizzabus”餐馆,但也会匹配名称中含有 0 个或多个 z 字符的餐馆,例如 /restaurants/pizzabus/restaurants/pizzzabus/restaurants/pizabus
  • /restaurants/.*:此模式会匹配任何包含 /restaurants 路径的餐馆名称(例如 /restaurants/pizzabus/restaurants/pawtato),以及应用不知道的餐馆名称(例如 /restaurants/wateriehall)。

pathAdvancePattern(适用于 API 31)

此属性会指定与“具有类似正则表达式模式的完整路径”匹配的完整路径:

  • 句点 (.) 匹配任何字符。
  • 一组方括号 ([...]) 会匹配一系列字符。这个组合也支持非 (^) 修饰符。
  • 星号 * 会与前一个模式匹配 0 次或多次。
  • 加号 (+) 会与前一个模式匹配 1 次或多次。
  • 大括号 ({...}) 代表模式可以匹配的次数。

此属性可视为 pathPattern 的扩展,能让系统更灵活地选择要匹配的网址,例如:

  • /restaurants/[a-zA-Z]*/orders/[0-9]{3} 会匹配任何长度不超过 3 位数的餐馆订单。
  • /restaurants/[a-zA-Z]*/orders/latest 会匹配应用中任何餐馆的最新订单

5. 创建深层链接和网页链接

使用自定义架构的深层链接是最常见的深层链接类型,也是最容易实现的,但存在缺点。网站无法打开这类链接。不过,任何在清单中声明支持该架构的应用都可以打开该链接。

您可以对 <data> 元素使用任何架构。例如,此 Codelab 使用 food://restaurants/keybabs 网址。

  1. 在 Android Studio 中,向清单文件添加以下 intent 过滤器:

AndroidManifest.xml

<activity ... >
  <intent-filter>
    <action android:name="android.intent.action.VIEW"/>
    <category android:name="android.intent.category.BROWSABLE"/>
    <category android:name="android.intent.category.DEFAULT"/>
    <data android:scheme="food"/>
    <data android:path="/restaurants/keybabs"/>
  </intent-filter>
</activity>
  1. 若要验证应用能否打开设有自定义架构的链接,请向主 activity 添加以下内容,在主屏幕上显示输出内容:

MainActivity.kt

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Receive the intent action and data
        val action: String? = intent?.action;
        val data: Uri? = intent?.data;

        setContent {
            DeepLinksBasicsTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    // Add a Column to print a message per line
                    Column {
                        // Print it on the home screen
                        Greeting("Android")
                        Text(text = "Action: $action")
                        Text(text = "Data: $data")
                    }
                }
            }
        }
    }
}
  1. 若要测试是否收到了 intent,请搭配以下命令使用 Android 调试桥 (adb):
adb shell am start -W -a android.intent.action.VIEW -d "food://restaurants/keybabs"

此命令会启动包含 VIEW 操作的 intent,并将提供的网址用作数据。当您运行此命令后,应用会启动并接收 intent。请注意主屏幕中文本部分的变化。第一行显示“Hello Android!”消息,第二行显示 intent 调用的操作,第三行显示 intent 调用的网址。

在下图中,请注意 Android Studio 的底部,提到的 adb 命令已运行。在屏幕右侧,应用主屏幕显示 intent 信息,表示已收到该 intent。Android Studio 全屏显示以下打开的标签页:“code view”“emulator”和“terminal”。“code view”显示基本的 MainActivity.kt 文件。“emulator”显示深层链接文本字段,确认其已成功接收。“terminal”显示刚刚在此 Codelab 中讨论过的 adb 命令。

网页链接是使用 httphttps(而非自定义架构)的深层链接。

对于网页链接实现,请使用 /restaurants/keybabs/order/latest.html 路径(表示餐馆收到的最新订单)。

  1. 使用现有的 intent 过滤器调整清单文件。

AndroidManifest.xml

<intent-filter>
  <action android:name="android.intent.action.VIEW"/>
  <category android:name="android.intent.category.BROWSABLE"/>
  <category android:name="android.intent.category.DEFAULT"/>
  <data android:scheme="food"/>
  <data android:path="/restaurants/keybabs"/>

  <!-- Web link configuration -->
  <data android:scheme="http"/>
  <data android:scheme="https"/>
  <data android:host="sabs-deeplinks-test.web.app"/>
  <data android:path="/restaurants/keybabs/order/latest.html"/>
</intent-filter>

由于这两个路径都已共享 (/restaurants/keybabs),因此最好将它们放在同一个 intent 过滤器下,这样实现起来更加简单,清单文件也更易于阅读。

  1. 在测试网页链接之前,请重启应用以应用新的更改。
  2. 使用相同的 adb 命令启动 intent,但在本例中,我们会更新网址。
adb shell am start -W -a android.intent.action.VIEW -d "https://sabs-deeplinks-test.web.app/restaurants/keybabs/orders/latest.html"

请注意,屏幕截图显示,系统已收到 intent,且网络浏览器已打开并显示网站,这是 Android 12 之后版本中的功能。包含以下标签页的 Android Studio 完整视图:“Code view”显示 AndroidManifest.xml 文件,其中包含之前讨论的 intent 过滤器;“Emulator view”显示通过网页链接打开的网页,该网页会指向网页版餐馆应用;“Terminal view”显示用于网页链接的 adb 命令。

6. 配置 Android App Links

这类链接能提供最顺畅的用户体验,因为链接在获得用户点击后,一定会将其直接引导至相关应用,而不会显示消除歧义对话框。Android App Links 是在 Android 6.0 中实现的,同时也是最具体的深层链接类型。它们都是使用 http/https 架构和 android:autoVerify 属性的网页链接,使应用成为所有匹配链接的默认处理程序。实现 Android App Links 有两个主要步骤:

  1. 使用适当的 intent 过滤器更新清单文件。
  2. 添加网站关联以进行验证。

更新清单文件

  1. 若要支持 Android App Links,请在清单文件中使用以下代码替换旧配置:

AndroidManifest.xml

<!-- Replace deep link and web link configuration with this -->
<!-- Please update the host with your own domain -->
<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW"/>
  <category android:name="android.intent.category.BROWSABLE"/>
  <category android:name="android.intent.category.DEFAULT"/>
  <data android:scheme="https"/>
  <data android:host="example.com"/>
  <data android:pathPrefix="/restaurants"/>
</intent-filter>

此 intent 过滤器会添加 android:autoVerify 属性,并将其设置为 true。这样一来,Android OS 便可在安装应用和每次更新时验证网域。

网站关联

若要验证 Android App Link,请在应用和网站之间建立关联。您必须在网站上发布 Google Digital Asset Links (DAL) JSON 文件,才能进行验证。

Google DAL 是一种协议和 API,定义了有关其他应用和网站的可验证语句。在此 Codelab 中,您将在 assetlinks.json 文件中创建有关 Android 应用的语句。示例如下:

assetlinks.json

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.devrel.deeplinksbasics",
    "sha256_cert_fingerprints":
   ["B0:4E:29:05:4E:AB:44:C6:9A:CB:D5:89:A3:A8:1C:FF:09:6B:45:00:C5:FD:D1:3E:3E:12:C5:F3:FB:BD:BA:D3"]
  }
}]

此文件可存储语句列表,但该示例仅显示了一项内容。每个语句都必须包含以下字段:

  • 关系。描述声明的且与目标相关的一个或多个关系。
  • 目标。此语句适用的资产。可能是以下两个可用目标之一:webandroid_app

Android 语句的 target 属性包含以下字段:

  • namespace:适用于所有 Android 应用的 android_app
  • package_name:完全限定软件包名称 (com.devrel.deeplinksbasics)。
  • sha256_cert_fingerprints:应用证书的指纹。您将在下一部分中了解如何生成此证书。

证书指纹

有多种方法可以获取证书指纹。此 Codelab 会使用两种方法,一种用于应用调试 build,另一种用于帮助将应用发布到 Google Play 商店。

调试配置

Android Studio 首次运行您的项目时,会自动使用调试证书为应用签名。此证书的位置为 $HOME/.android/debug.keystore。您可以使用 Gradle 命令获取此 SHA-256 证书指纹;具体步骤如下:

  1. Control 两次,系统应该会显示 Run anything 菜单。如果没有显示,您可以在右侧边栏的 Gradle 菜单中找到该菜单,然后点击 Gradle 图标。

Android Studio 中的 Gradle 菜单标签页,其中 Gradle 图标处于选中状态。

  1. 输入 gradle signingReport,然后按 Enter。该命令会在控制台中执行,并显示调试应用变体的指纹信息。

“Terminal”窗口会显示 Gradle 签名报告结果。

  1. 要完成网站关联,请复制 SHA-256 证书指纹,更新 JSON 文件,然后将其上传到您网站的 https://<domain>/.well-know/assetlinks.json 位置。请参阅此 Android App Links 博文,了解如何进行设置。
  2. 如果您的应用仍在运行,请按 Stop 以停止该应用。
  3. 如需重新启动验证流程,请从模拟器中移除该应用。在模拟器上,点击并按住 DeepLinksBasics 应用图标,然后选择 App Info。在模态窗口中,依次点击 UninstallConfirm。然后,运行该应用,以便 Android Studio 可以验证关联。

f112e0d252c5eb48.gif

  1. 务必选择 app 运行配置。否则,Gradle 签名报告将再次运行。Android Studio 运行配置菜单,其中已选择“app”配置。
  2. 重启应用,然后启动含有 Android 应用链接网址的 intent:
adb shell am start -W -a android.intent.action.VIEW -d "https://sabs-deeplinks-test.web.app/restaurants/"
  1. 请注意,应用会启动,且 intent 会显示在主屏幕上。

Android 模拟器主屏幕,其中的文本字段显示 Android 应用链接已成功实现。

恭喜,您刚刚创建了您的首个 Android 应用链接!

版本配置

现在,为了能够将包含 Android App Links 的应用上传到 Play 商店,您必须使用具有正确证书指纹的发布 build。要生成并上传该 build,请按以下步骤操作:

  1. 在 Android Studio 主菜单中,依次点击 Build > Generate Signed Bundle/APK
  2. 在接下来出现的对话框中,选择 Android App Bundle(针对 Play 应用签名)或 APK(如果您要直接部署到设备)。
  3. 在接下来出现的对话框中,点击 Key store path 下的 Create new。系统会显示一个新窗口。
  4. 为您的密钥库选择路径,并将其命名为 basics-keystore.jks
  5. 密钥库创建并确认密码。
  6. 让“Key”部分的 Alias 字段保留默认值。
  7. 确保密码和确认密码与密钥库中的密码一样。二者必须一致。
  8. 填写 Certificate 信息,然后点击 OK

Android Studio 中的“New Key Store”模态窗口,其中包含以下菜单项和值:“Key store path”的值为所选目录,“Password”和“Confirm”的值为所选密码,“Alias”的值为“key0”,“Password”和“Confirm”的值与首次输入的密码相同,“Validity”的值为默认值,“First and Last Name”的值为“Sabs sabs”,“Organizational Unit”的值为“Android”,“Organization”的值为“MyOrg”,“City or Locality”的值为“MyCity”,“State or Province”的值为“MyState”,以及“Country Code”的值为“US”。

  1. 确保已针对 Play 应用签名功能勾选导出加密密钥的选项,然后点击 Next

“Generate Signed Bundle or APK”菜单模态窗口,其中包含以下菜单项和值:“Module”的值为默认值,“Key store path”的值为生成的路径,“Key store password”的值为先前生成的密码,“Key alias”的值为“key0”,“Key password”的值为先前生成的密码,“Export encrypted key for enrolling published apps in Google Play App Signing”处于选中状态,以及“Encrypted key export path”的值为默认值。

  1. 在该对话框中,选择发布 build 变体,然后点击 Finish。现在,您可以将应用上传到 Google Play 商店并使用 Play 应用签名功能。

Play 应用签名

借助 Play 应用签名功能,Google 可帮助您管理和保护应用的签名密钥。您只需上传在上一步中完成的已签名 app bundle 即可。

如需检索 assetlinks.json 文件的证书指纹,并在发布变体 build 中提供 Android App Links,请按以下步骤操作:

  1. 在 Google Play 管理中心内,点击创建应用
  2. 输入 Deep Links Basics 作为应用名称。
  3. 在接下来的两个选项中,分别选择应用免费“创建应用”菜单,其中包含以下更新的值:“应用名称”的值为“Deep Links Basics”,为“应用或游戏”选择了“应用”,为“免费或付费”选择了“免费”,并接受了两项声明。
  4. 接受声明,然后点击创建应用
  5. 如需上传 bundle 并能够测试 Android App Links,请在左侧菜单中依次选择测试 > 内部测试
  6. 点击创建新的发布版本

Play 管理中心的“内部测试”部分,显示了“创建新的发布版本”按钮。

  1. 在接下来出现的屏幕中,点击上传,然后选择在上一部分中生成的 bundle。您可以在 DeepLinksBascis > app > release 下找到 app-release.aab 文件。点击打开,然后等待 bundle 上传完毕。
  2. 上传后,让其余字段先保留默认设置。点击保存

Play 管理中心的内部测试发布版本部分,其中包含已上传的“Deep Links Basics”应用。系统已填入默认值。

  1. 点击检查发布版本,然后在接下来出现的屏幕上点击开始发布到内部测试,为下一部分做好准备。请忽略显示的警告,因为发布到 Play 商店不在本 Codelab 的讨论范围内。
  2. 点击模态窗口上的发布
  3. 如需获取 Play 应用签名创建的 SHA-256 证书指纹,请点击左侧菜单中的深层链接标签页,然后查看“深层链接”信息中心。

Play 管理中心内的“深层链接”信息中心,其中显示了关于最近上传的深层链接的所有深层链接信息。

  1. 网域部分下,点击网站的网域。请注意,Google Play 管理中心会提及您尚未验证应用的网域(网站关联)。
  2. 修复域名问题部分下,点击展开箭头。
  3. 在该屏幕中,Google Play 管理中心会展示如何使用证书指纹更新 assetlinks.json 文件。复制相应代码段并更新 assetlinks.json 文件。

“深层链接”信息中心内的域名验证部分,显示了如何使用正确的证书指纹更新域名。

  1. assetlinks.json 文件更新后,点击重新检查验证状态。如果验证尚未通过,验证服务最多需要等待 5 分钟的时间,才会检测到新的更改。
  2. 如果重新加载深层链接信息中心页面,您不会再看到验证错误。

对已上传的应用进行验证

您已经了解如何验证位于模拟器上的应用。现在,您需要验证上传到 Play 商店的应用。

如需在模拟器上安装应用并确保 Android 应用链接已通过验证,请按以下步骤操作:

  1. 在左侧边栏中,点击发布版本概览,然后选择您刚刚上传的最新版本,它应该是 1 (1.0) 版
  2. 点击发布版本详情(右侧蓝色箭头)以查看版本详情。
  3. 点击同一蓝色箭头按钮可获取 app bundle 信息。
  4. 在此模态窗口中,选择下载标签页,然后点击已签名的通用 APK 资源对应的下载
  5. 在将 bundle 安装到模拟器之前,先删除 Android Studio 安装的上一个应用。
  6. 在模拟器上,点击并按住 DeepLinksBasics 应用图标,然后选择 App Info。在模态窗口中,依次点击 UninstallConfirm

f112e0d252c5eb48.gif

  1. 如需安装下载的 bundle,请将下载的 1.apk 文件拖放到模拟器屏幕上,然后等待安装。

8967dac36ae545ee.gif

  1. 如需测试验证,请在 Android Studio 中打开终端,并使用以下两个命令运行验证流程:
adb shell pm verify-app-links --re-verify com.devrel.deeplinksbasics
adb shell pm get-app-links com.devrel.deeplinksbasics
  1. 运行 get-app-links 命令之后,您应该会在控制台上看到 verified 消息。如果您看到 legacy_failure 消息,请确保证书指纹与您为网站上传的证书指纹相符。如果相符,但您仍然没有看到验证消息,请尝试重新执行第 6、7 和 8 步。

控制台输出。

7. 实现 Android App Links

现在,您已经完成所有配置,接下来可以实现该应用了。

Jetpack Compose 将用于实现。如需详细了解 Jetpack Compose,请参阅使用 Jetpack Compose 更快地打造更出色的应用

代码依赖项

如需添加和更新此项目所需的一些依赖项,请按以下步骤操作:

  • 将以下代码添加到 ModuleProject Gradle 文件中:

build.gradle(项目)

buildscript {
  ...
  dependencies {
    classpath "com.google.dagger:hilt-android-gradle-plugin:2.43"
  }
}

build.gradle(模块)

plugins {
  ...
  id 'kotlin-kapt'
  id 'dagger.hilt.android.plugin'
}
...
dependencies {
  ...
  implementation 'androidx.compose.material:material:1.2.1'
  ...
  implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
  implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1"
  implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
  implementation "com.google.dagger:hilt-android:2.43"
  kapt "com.google.dagger:hilt-compiler:2.43"
}

项目 zip 文件中含有一个图片目录,其中有 10 张免版税的图片可用于各个餐馆。您可以随意使用这些图片,也可以添加您自己的图片。

如需为 HiltAndroidApp 添加主入口点,请按以下步骤操作:

  • 新建一个名为 DeepLinksBasicsApplication.kt 的 Kotlin 类/文件,然后使用新的应用名称更新清单文件。

DeepLinksBasicsApplication.kt

package com.devrel.deeplinksbasics

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class DeepLinksBasicsApplication : Application() {}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <!-- Update name property -->
    <application
        android:name=".DeepLinksBasicsApplication"
        ...

数据

您需要为餐馆创建一个带有 Restaurant 类、库和本地数据源的数据层。所有内容都位于您需要创建的 data 软件包下。为此,请按以下步骤操作:

  1. Restaurant.kt 文件中,使用以下代码段创建一个 Restaurant 类:

Restaurant.kt

package com.devrel.deeplinksbasics.data

import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable

@Immutable
data class Restaurant(
    val id: Int = -1,
    val name: String = "",
    val address: String = "",
    val type: String = "",
    val website: String = "",
    @DrawableRes val drawable: Int = -1
)
  1. RestaurantLocalDataSource.kt 文件的数据源类中添加一些餐馆。别忘了使用自己的域名更新数据。您可以参考下面的代码段:

RestaurantLocalDataSource.kt

package com.devrel.deeplinksbasics.data

import com.devrel.deeplinksbasics.R
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class RestaurantLocalDataSource @Inject constructor() {
    val restaurantList = listOf(
        Restaurant(
            id = 1,
            name = "Pawtato",
            address = "3140 Skinner Hollow Road, Medford, Oregon 97501",
            type = "Potato and gnochi",
            // TODO: Update with your own domain
            website = "https://your.own.domain/restaurants/pawtato/",
            drawable = R.drawable.restaurant1,
        ),
        Restaurant(
            id = 2,
            name = "Rawrbucha",
            address = "2064 Carriage Lane, Mansfield, Ohio 44907",
            type = "Kombucha",
            // TODO: Update with your own domain
            website = "https://your.own.domain/restaurants/rawrbucha/",
            drawable = R.drawable.restaurant2,
        ),
        Restaurant(
            id = 3,
            name = "Pizzabus",
            address = "1447 Davis Avenue, Petaluma, California 94952",
            type = "Pizza",
            // TODO: Update with your own domain
            website = "https://your.own.domain/restaurants/pizzabus/",
            drawable = R.drawable.restaurant3,
        ),
        Restaurant(
            id = 4,
            name = "Keybabs",
            address = "3708 Pinnickinnick Street, Perth Amboy, New Jersey 08861",
            type = "Kebabs",
            // TODO: Update with your own domain
            website = "https://your.own.domain/restaurants/keybabs/",
            drawable = R.drawable.restaurant4,
        ),
        Restaurant(
            id = 5,
            name = "BBQ",
            address = "998 Newton Street, Saint Cloud, Minnesota 56301",
            type = "BBQ",
            // TODO: Update with your own domain
            website = "https://your.own.domain/restaurants/bbq/",
            drawable = R.drawable.restaurant5,
        ),
        Restaurant(
            id = 6,
            name = "Salades",
            address = "4522 Rockford Mountain Lane, Oshkosh, Wisconsin 54901",
            type = "salads",
            // TODO: Update with your own domain
            website = "https://your.own.domain/restaurants/salades/",
            drawable = R.drawable.restaurant6,
        ),
        Restaurant(
            id = 7,
            name = "Gyros and moar",
            address = "1993 Bird Spring Lane, Houston, Texas 77077",
            type = "Gyro",
            // TODO: Update with your own domain
            website = "https://your.own.domain/restaurants/gyrosAndMoar/",
            drawable = R.drawable.restaurant7,
        ),
        Restaurant(
            id = 8,
            name = "Peruvian ceviche",
            address = "2125 Deer Ridge Drive, Newark, New Jersey 07102",
            type = "seafood",
            // TODO: Update with your own domain
            website = "https://your.own.domain/restaurants/peruvianCeviche/",
            drawable = R.drawable.restaurant8,
        ),
        Restaurant(
            id = 9,
            name = "Vegan burgers",
            address = "594 Warner Street, Casper, Wyoming 82601",
            type = "vegan",
            // TODO: Update with your own domain
            website = "https://your.own.domain/restaurants/veganBurgers/",
            drawable = R.drawable.restaurant9,
        ),
        Restaurant(
            id = 10,
            name = "Taquitos",
            address = "1654 Hart Country Lane, Blue Ridge, Georgia 30513",
            type = "mexican",
            // TODO: Update with your own domain
            website = "https://your.own.domain/restaurants/taquitos/",
            drawable = R.drawable.restaurant10,
        ),
    )
}
  1. 记得将图片导入您的项目。
  2. 接下来,在 RestaurantRepository.kt 文件中添加 Restaurant 库,其中包含按名称获取餐馆的函数,如以下代码段所示:

RestaurantRepository.kt

package com.devrel.deeplinksbasics.data

import javax.inject.Inject

class RestaurantRepository @Inject constructor(
    private val restaurantLocalDataSource: RestaurantLocalDataSource
){
    val restaurants: List<Restaurant> = restaurantLocalDataSource.restaurantList

    // Method to obtain a restaurant object by its name
    fun getRestaurantByName(name: String): Restaurant ? {
        return restaurantLocalDataSource.restaurantList.find {
            val processedName = it.name.filterNot { it.isWhitespace() }.lowercase()
            val nameToTest = name.filterNot { it.isWhitespace() }.lowercase()
            nameToTest == processedName
        }
    }
}

ViewModel

为了能够通过应用和 Android 应用链接选择一家餐馆,您需要创建一个 ViewModel 来更改所选餐馆的值。请按照以下步骤操作:

  • RestaurantViewModel.kt 文件中,添加以下代码段:

RestaurantViewModel.kt

package com.devrel.deeplinksbasics.ui.restaurant

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.devrel.deeplinksbasics.data.Restaurant
import com.devrel.deeplinksbasics.data.RestaurantRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class RestaurantViewModel @Inject constructor(
    private val restaurantRepository: RestaurantRepository,
) : ViewModel() {
    // restaurants and selected restaurant could be used as one UIState stream
    // which will scale better when exposing more data.
    // Since there are only these two, it is okay to expose them as separate streams
    val restaurants: List<Restaurant> = restaurantRepository.restaurants

    private val _selectedRestaurant = MutableStateFlow<Restaurant?>(value = null)
    val selectedRestaurant: StateFlow<Restaurant?>
        get() = _selectedRestaurant

    // Method to update the current restaurant selection
    fun updateSelectedRestaurantByName(name: String) {
        viewModelScope.launch {
            val selectedRestaurant: Restaurant? = restaurantRepository.getRestaurantByName(name)
            if (selectedRestaurant != null) {
                _selectedRestaurant.value = selectedRestaurant
            }
        }
    }
}

Compose

现在您已经有了 ViewModel 和数据层的逻辑,是时候添加界面层了。得益于 Jetpack Compose 库,您只需几个步骤就能完成。就此应用而言,您希望以卡片网格的形式呈现餐馆。用户只需点击每张卡片,便可查看各个餐馆的详细信息。您需要三个主要的可组合函数,以及一个会路由到对应餐馆的导航组件。

显示餐馆应用成品的 Android 模拟器。

如需添加界面层,请按以下步骤操作:

  1. 从可呈现各个餐馆详细信息的可组合函数着手。在 RestaurantCardDetails.kt 文件中,添加以下代码段:

RestaurantCardDetails.kt

package com.devrel.deeplinksbasics.ui

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.devrel.deeplinksbasics.data.Restaurant

@Composable
fun RestaurantCardDetails (
    restaurant: Restaurant,
    onBack: () -> Unit,
) {
    BackHandler() {
       onBack()
    }
    Scaffold(
        topBar = {
            TopAppBar(
                backgroundColor = Color.Transparent,
                elevation = 0.dp,
            ) {
                Row(
                    horizontalArrangement = Arrangement.Start,
                    modifier = Modifier.padding(start = 8.dp)
                ) {
                    Icon(
                        imageVector = Icons.Default.ArrowBack,
                        contentDescription = "Arrow Back",
                       modifier = Modifier.clickable {
                            onBack()
                        }
                    )
                    Spacer(modifier = Modifier.width(8.dp))
                    Text(text = restaurant.name)
                }
            }
        }
    ) { paddingValues ->
        Card(
            modifier = Modifier
                .padding(paddingValues)
                .fillMaxWidth(),
            elevation = 2.dp,
            shape = RoundedCornerShape(corner = CornerSize(8.dp))
        ) {
            Column(
                modifier = Modifier
                    .padding(16.dp)
                    .fillMaxWidth()
            ) {
                Text(text = restaurant.name, style = MaterialTheme.typography.h6)
                Text(text = restaurant.type, style = MaterialTheme.typography.caption)
                Text(text = restaurant.address, style = MaterialTheme.typography.caption)
                SelectionContainer {
                    Text(text = restaurant.website, style = MaterialTheme.typography.caption)
                }
                Image(painter = painterResource(id = restaurant.drawable), contentDescription = "${restaurant.name}")
            }
        }
    }
}
  1. 接下来,实现网格单元和网格本身。在 RastaurantCell.kt 文件中,添加以下代码段:

RestaurantCell.kt

package com.devrel.deeplinksbasics.ui

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.devrel.deeplinksbasics.data.Restaurant

@Composable
fun RestaurantCell(
    restaurant: Restaurant
){
    Card(
        modifier = Modifier
            .padding(horizontal = 8.dp, vertical = 8.dp)
            .fillMaxWidth(),
        elevation = 2.dp,
        shape = RoundedCornerShape(corner = CornerSize(8.dp))
    ) {
        Column(
            modifier = Modifier
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text(text = restaurant.name, style = MaterialTheme.typography.h6)
            Text(text = restaurant.address, style = MaterialTheme.typography.caption)
            Image(painter = painterResource(id = restaurant.drawable), contentDescription = "${restaurant.name}")
        }
    }
}
  1. RestaurantGrid.kt 文件中,添加以下代码段:

RestaurantGrid.kt

package com.devrel.deeplinksbasics.ui

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.devrel.deeplinksbasics.data.Restaurant

@Composable
fun RestaurantGrid(
    restaurants: List<Restaurant>,
    onRestaurantSelected: (String) -> Unit,
    navigateToRestaurant: (String) -> Unit,
) {
    Scaffold(topBar = {
        TopAppBar(
            backgroundColor = Color.Transparent,
            elevation = 0.dp,
        ) {
            Text(text = "Restaurants", fontWeight = FontWeight.Bold)
        }
    }) { paddingValues ->
        LazyVerticalGrid(
            columns = GridCells.Adaptive(minSize = 200.dp),
            modifier = Modifier.padding(paddingValues)
        ) {
            items(items = restaurants) { restaurant ->
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .clickable(onClick = {
                            onRestaurantSelected(restaurant.name)
                            navigateToRestaurant(restaurant.name)
                        })
                ) {
                    RestaurantCell(restaurant)
                }
            }
        }
    }
}
  1. 接下来,您需要实现应用状态和导航逻辑,并更新 MainActivity.kt。用户只要点击餐馆卡片,便会被定向到特定餐馆。在 RestaurantAppState.kt 文件中,添加以下代码段:

RestaurantAppState.kt

package com.devrel.deeplinksbasics.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController

sealed class Screen(val route: String) {
   object Grid : Screen("restaurants")
   object Name : Screen("restaurants/{name}") {
       fun createRoute(name: String) = "restaurants/$name"
   }
}

@Composable
fun rememberRestaurantAppState(
    navController: NavHostController = rememberNavController(),
) = remember(navController) {
    RestaurantAppState(navController)
}

class RestaurantAppState(
    val navController: NavHostController,
) {
    fun navigateToRestaurant(restaurantName: String) {
        navController.navigate(Screen.Name.createRoute(restaurantName))
    }

    fun navigateBack() {
        navController.popBackStack()
    }
}
  1. 对于导航,您需要创建 NavHost,并使用可组合路由定向到各个餐馆。在 RestaurantApp.kt 文件中,添加以下代码段:

RestaurantApp.kt

package com.devrel.deeplinksbasics.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.devrel.deeplinksbasics.ui.restaurant.RestaurantViewModel

@Composable
fun RestaurantApp(
   viewModel: RestaurantViewModel = viewModel(),
   appState: RestaurantAppState = rememberRestaurantAppState(),
) {
    val selectedRestaurant by viewModel.selectedRestaurant.collectAsState()
    val onRestaurantSelected: (String) -> Unit = { viewModel.updateSelectedRestaurantByName(it) }

    NavHost(
        navController = appState.navController,
        startDestination = Screen.Grid.route,
    ) {
        // Default route that points to the restaurant grid
        composable(Screen.Grid.route) {
            RestaurantGrid(
                restaurants = viewModel.restaurants,
                onRestaurantSelected = onRestaurantSelected,
                navigateToRestaurant = { restaurantName ->
                    appState.navigateToRestaurant(restaurantName)
                },
            )
        }
        // Route for the navigation to a particular restaurant when a user clicks on it
        composable(Screen.Name.route) {
            RestaurantCardDetails(restaurant = selectedRestaurant!!, onBack = appState::navigateBack)
        }
    }
}
  1. 您现在可以使用应用实例更新 MainActivity.kt 了。请将文件替换为以下代码:

MainActivity .kt

package com.devrel.deeplinksbasics

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import com.devrel.deeplinksbasics.ui.RestaurantApp
import com.devrel.deeplinksbasics.ui.theme.DeepLinksBasicsTheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            DeepLinksBasicsTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    RestaurantApp()
                }
            }
        }
    }
}
  1. 运行应用以浏览网格,然后选择特定餐馆。当您选择一家餐馆后,应用会展示该餐馆及其详细信息。

fecffce863113fd5.gif

现在,将您的 Android App Links 添加到网格和每个餐馆。您已经为 /restaurants 下的网格设置 AndroidManifest.xml 部分。真正巧妙的是,您可以对每个餐馆使用相同的设置;只需向逻辑添加新的路由配置即可。为此,请按以下步骤操作:

  1. 使用 intent 过滤器更新清单文件,以接收 /restaurants 作为路径,同时别忘了将您的网域添加为主机。在 AndroidManifest.xml 文件中,添加以下代码段:

AndroidManifest.xml

...
<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW"/>
  <category android:name="android.intent.category.BROWSABLE"/>
  <category android:name="android.intent.category.DEFAULT"/>
  <data android:scheme="http"/>
  <data android:scheme="https"/>
  <data android:host="your.own.domain"/>
  <data android:pathPrefix="/restaurants"/>
</intent-filter>
  1. RestaurantApp.kt 文件中,添加以下代码段:

RestaurantApp.kt

...
import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink

fun RestaurantApp(...){
  NavHost(...){
    ...
    //  Route for the navigation to a particular restaurant when a user clicks on it
    //  and for an incoming deep link
    // Update with your own domain
        composable(Screen.Name.route,
            deepLinks = listOf(
                navDeepLink { uriPattern = "https://your.own.domain/restaurants/{name}" }
            ),
            arguments = listOf(
                navArgument("name") {
                    type = NavType.StringType
                }
            )
        ) { entry ->
            val restaurantName = entry.arguments?.getString("name")
            if (restaurantName != null) {
                LaunchedEffect(restaurantName) {
                    viewModel.updateSelectedRestaurantByName(restaurantName)
                }
            }
            selectedRestaurant?.let {
                RestaurantCardDetails(
                    restaurant = it,
                    onBack = appState::navigateBack
                )
            }
        }
  }
}

在后台,NavHost 会将 Android intent Uri 数据与可组合路由进行匹配。如果路由匹配,系统会呈现 composable

composable 组件可以接受 deepLinks 参数,其中包含从 intent 过滤器接收的 URI 列表。在此 Codelab 中,您将添加所创建网站的网址,并定义 ID 参数,以接收用户并将其引导至特定餐馆。

  1. 为了确保应用逻辑能在用户点击 Android 应用链接后将其引导至对应餐馆,请使用 adb
adb shell am start -W -a android.intent.action.VIEW -d "https://sabs-deeplinks-test.web.app/restaurants/gyrosAndMoar"

请注意,该应用显示的是对应餐馆。

Android 模拟器中,显示“Gyros and moar”餐馆屏幕的餐馆应用。

8. 查看 Play 管理中心内的信息中心

您已经看过深层链接的信息中心。此信息中心会提供所有必要信息,以确保深层链接正常运行。您甚至可以按应用版本查看!此处会显示您在清单文件中添加的网域、链接和自定义链接;如果 assetlinks.json 文件出现问题,此信息中心甚至会显示更新该文件的位置。

Play 管理中心内的“深层链接”信息中心,其中有一个 Android 应用链接已通过验证。

9. 总结

恭喜,您已成功构建您的第一个 Android App Links 应用!

您已了解设计、配置、创建和测试 Android App Links 的流程。这个过程有许多不同的部分,因此,此 Codelab 汇总了所有相关详情,以帮助您在 Android OS 开发中取得成功。

现在,您已了解让 Android App Links 正常运行的关键步骤。

深入阅读

参考文档