1. 简介
上次更新日期:2021 年 3 月 17 日
所需条件
- Android Studio Chipmunk
- 了解 Kotlin
- 基本了解 Compose(如
@Composable
注解) - 建议您在学习此 Codelab 之前先学习 Jetpack Compose 基础知识 Codelab
使用 Compose 进行导航
Navigation 是一个 Jetpack 库,用于在应用中沿着特定路线从一个目的地导航到另一个目的地。Navigation 库还提供了一个专用工件,用于在使用 Jetpack Compose 构建的屏幕中实现一致而惯用的导航方式。此 Codelab 会重点介绍这个工件 (navigation-compose
)。
实践内容
您将使用 Rally Material 研究作为此 Codelab 的基础。您将迁移现有导航代码,以使用 Jetpack Navigation 组件在 Jetpack Compose 内的屏幕之间导航。
学习内容
- 将 Jetpack Navigation 与 Jetpack Compose 配合使用的基础知识
- 在可组合项之间导航
- 使用必需实参和可选实参导航
- 使用深层链接进行导航
- 将标签页栏集成到导航层次结构中
- 测试导航
2. 设置
您可以在您的机器上按照此 Codelab 进行操作。
如需自行学习,请克隆此 Codelab 的起始代码。
$ git clone https://github.com/googlecodelabs/android-compose-codelabs.git
或者,您也可以下载两个 ZIP 文件:
现在,您已下载相应代码,请在 Android Studio 中打开 NavigationCodelab 项目。现已准备就绪,可以开始开发项目了。
3. 迁移到 Navigation
Rally 是一个现有的应用,最初没有使用 Navigation。迁移过程分为以下几步:
- 添加 Navigation 依赖项
- 设置 NavController 和 NavHost
- 为目的地准备路线
- 将原始目的地机制替换为导航路线
添加 Navigation 依赖项
打开应用的 build 文件(位于 app/build.gradle
)。在“dependencies”区段中,添加 navigation-compose
依赖项。
dependencies { implementation "androidx.navigation:navigation-compose:2.4.0-beta02" // other dependencies }
现在,同步项目,然后您就可以开始使用 Compose 中的 Navigation 了。
设置 NavController
使用 Compose 中的 Navigation 时,NavController
是核心组件;它可跟踪返回堆栈条目、使堆栈向前移动、支持对返回堆栈执行操作,以及在不同屏幕状态之间导航。由于 NavController
是导航的核心,因此必须先创建它,然后才能导航到目的地。
在 Compose 中,您使用的是 NavHostController
,它是 NavController
的子类。使用 rememberNavController()
函数获取 NavController
。这将创建并记住 NavController
,它可以在配置更改后继续存在(使用 rememberSavable
)。NavController
与单个 NavHost
可组合项相关联。NavHost
将 NavController
与用于指定可组合项目的地的导航图相关联。
对于此 Codelab,请获取 NavController
并将其存储在 RallyApp
中。它是整个应用的根可组合项。您可以在 RallyActivity.kt
中找到它。
import androidx.navigation.compose.rememberNavController
...
@Composable
fun RallyApp() {
RallyTheme {
val allScreens = RallyScreen.values().toList()
var currentScreen by rememberSaveable { mutableStateOf(RallyScreen.Overview) }
val navController = rememberNavController()
Scaffold(...
}
为目的地准备路线
概览
Rally 应用有三个屏幕:
- Overview - 所有财务交易和提醒的概览
- Accounts - 有关现有帐号的数据分析
- Bills - 预定支出
三个屏幕均使用可组合项构建而成。您可以查看 RallyScreen.kt
。此文件中声明了这三个屏幕。稍后,您需要将这些屏幕映射到导航目的地,并将 Overview
作为起始目的地。此外,您还需要将可组合项从 RallyScreen
移至 NavHost
。目前,RallyScreen
可以保持不变。
使用 Compose 中的 Navigation 时,路线用字符串表示。您可以将这些字符串视为类似于网址或深层链接。在此 Codelab 中,我们将使用每个 RallyScreen
项的 name
属性作为路线,例如 RallyScreen.Overview.name
。
准备
返回 RallyActivity.kt
中的 RallyApp
可组合项,并将包含相应屏幕内容的 Box
替换为新创建的 NavHost
。传入我们在上一步中创建的 navController
。NavHost
还需要一个 startDestination
。把它设置成 RallyScreen.Overview.name
。此外,创建一个 Modifier
,以将内边距传递到 NavHost
。
import androidx.compose.foundation.layout.Box
import androidx.compose.material.Scaffold
import androidx.navigation.compose.NavHost
...
Scaffold(...) { innerPadding ->
NavHost(
navController = navController,
startDestination = RallyScreen.Overview.name,
modifier = Modifier.padding(innerPadding)
) { ... }
现在,我们可以定义导航图了。NavHost
可以导航到的目的地已准备好接受目的地。为此,我们使用 NavGraphBuilder
,它会提供给 NavHost(一个用于定义导航图的 lambda)的最后一个形参。由于此参数要求使用函数,因此您可以在尾随 lambda 中声明目的地。Navigation Compose 工件提供了 NavGraphBuilder.composable
扩展函数。您可以用它在导航图中定义导航目的地。
import androidx.navigation.compose.NavHost
...
NavHost(
navController = navController,
startDestination = RallyScreen.Overview.name,
modifier = Modifier.padding(innerPadding)
) {
composable(RallyScreen.Overview.name) { ... }
}
目前,我们暂时设置 Text
,将屏幕名称用作该可组合项的内容。在下面的这一步中,我们将使用现有的可组合项。
import androidx.compose.material.Text
import androidx.navigation.compose.composable
...
NavHost(
navController = navController,
startDestination = RallyScreen.Overview.name
modifier = Modifier.padding(innerPadding)
) {
composable(RallyScreen.Overview.name) {
Text(text = RallyScreen.Overview.name)
}
// TODO: Add the other two screens
}
现在,移除 Scaffold
中的 currentScreen.content
调用并运行应用;您会看到起始目的地的名称和上面的标签页。
最终应该得到一个类似于下面所示的 NavHost:
NavHost(
navController = navController,
startDestination = RallyScreen.Overview.name,
modifier = Modifier.padding(innerPadding)
) {
composable(RallyScreen.Overview.name) {
Text(RallyScreen.Overview.name)
}
composable(RallyScreen.Accounts.name) {
Text(RallyScreen.Accounts.name)
}
composable(RallyScreen.Bills.name) {
Text(RallyScreen.Bills.name)
}
}
NavHost
现在可以替换 Scaffold
中的 Box
。将 Modifier
传递到 NavHost,使 innerPadding
保持不变。
@Composable
fun RallyApp() {
RallyTheme {
val allScreens = RallyScreen.values().toList()
// FIXME: This duplicate source of truth
// will be removed later.
var currentScreen by rememberSaveable {
mutableStateOf(RallyScreen.Overview)
}
val navController = rememberNavController()
Scaffold(
topBar = {
RallyTabRow(
allScreens = allScreens,
onTabSelected = { screen -> currentScreen = screen },
currentScreen = currentScreen
)
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = RallyScreen.Overview.name,
modifier = Modifier.padding(innerPadding)) {
}
}
}
}
此时,顶栏尚未连接,因此点击标签页不会更改显示的可组合项。在下一步中,您将处理这一问题。
全面集成导航栏状态更改
在此步骤中,您将连接 RallyTabRow
,并删除当前的手动导航代码。完成此步骤后,Navigation 组件便会全方位处理路线问题。
还是在 RallyActivity
中,您会发现当点击某个标签页时,RallyTabRow
可组合项会有名为 onTabSelected
的回调。更新选择代码以使用 navController
导航到选定的屏幕。
以下是通过 Navigation 使用 TabRow
导航到屏幕所需的全部代码:
@Composable
fun RallyApp() {
RallyTheme {
val allScreens = RallyScreen.values().toList()
// FIXME: This duplicate source of truth
// will be removed later.
var currentScreen by rememberSaveable {
mutableStateOf(RallyScreen.Overview)
}
val navController = rememberNavController()
Scaffold(
topBar = {
RallyTabRow(
allScreens = allScreens,
onTabSelected = { screen ->
navController.navigate(screen.name)
},
currentScreen = currentScreen,
)
}
进行这更改后,currentScreen
将不再更新。这意味着对所选项的展开和收起操作将不起作用。如需重新启用此行为,还需要更新 currentScreen
属性。幸运的是,Navigation 可以为您保留返回堆栈,并为您提供当前的返回堆栈条目(作为 State
)。通过此 State
,您可以对返回堆栈的更改做出响应。您甚至可以查询当前的返回堆栈条目,了解其路线。
如需完成将 TabRow
屏幕选择迁移到 Navigation 的操作,请更新 currentScreen
以使用类似如下所示的导航返回堆栈。
import androidx.navigation.compose.currentBackStackEntryAsState
...
@Composable
fun RallyApp() {
RallyTheme {
val allScreens = RallyScreen.values().toList()
val navController = rememberNavController()
val backstackEntry = navController.currentBackStackEntryAsState()
val currentScreen = RallyScreen.fromRoute(
backstackEntry.value?.destination?.route
)
...
}
}
此时,当您运行该应用时,您可以使用 Tab 键在屏幕之间切换,但系统只会显示屏幕名称。您需要先将 RallyScreen
迁移到 Navigation,然后相应屏幕才能显示。
将 RallyScreen 迁移到 Navigation
完成此步骤后,这个可组合项将完全与 RallyScreen
枚举分离,并移至 NavHost
中。RallyScreen
将仅用于为相应屏幕提供图标和标题。
打开 RallyScreen.kt。将每个屏幕的 body
实现移至 RallyApp
的 NavHost
内的相应可组合项中。
import com.example.compose.rally.data.UserData
import com.example.compose.rally.ui.accounts.AccountsBody
import com.example.compose.rally.ui.bills.BillsBody
import com.example.compose.rally.ui.overview.OverviewBody
...
NavHost(
navController = navController,
startDestination = Overview.name,
modifier = Modifier.padding(innerPadding)
) {
composable(Overview.name) {
OverviewBody()
}
composable(Accounts.name) {
AccountsBody(accounts = UserData.accounts)
}
composable(Bills.name) {
BillsBody(bills = UserData.bills)
}
}
此时,您可以从 RallyScreen
中安全移除 content
函数和 body
参数及其用法,余下的代码如下所示:
enum class RallyScreen(
val icon: ImageVector,
) {
Overview(
icon = Icons.Filled.PieChart,
),
Accounts(
icon = Icons.Filled.AttachMoney,
),
Bills(
icon = Icons.Filled.MoneyOff,
);
companion object {
...
}
}
再次运行应用。您会看到原始的三个屏幕,可以通过 TabRow 在这些屏幕之间导航。
对 OverviewScreen 启用点击
此 Codelab 最初忽略了 OverviewBody
的点击事件。也就是说,“SEE ALL”按钮可供点击,但不会转到任何位置。
让我们解决这个问题!
OverviewBody
可以接受多个函数作为对点击事件的回调。我们将实现 onClickSeeAllAccounts
和 onClickSeeAllBills
,以导航到相关的目的地。
为了在点击“SEE ALL”按钮时启用导航,请使用 navController
并导航到“Accounts”或“Bills”屏幕。打开 RallyActivity.kt
,在 NavHost
中找到 OverviewBody
,然后添加导航调用。
OverviewBody(
onClickSeeAllAccounts = { navController.navigate(Accounts.name) },
onClickSeeAllBills = { navController.navigate(Bills.name) },
)
现在,您可以轻松更改 OverviewBody
的点击事件的行为。若能将 navController
保持在导航层次结构的顶层,而不将其直接传递到 OverviewBody
中,就可以轻松地单独预览或测试 OverviewBody
,而不必在这样操作时依赖于实际存在的 navController
。
4. 使用实参进行导航
让我们为 Rally 添加一些新功能!我们将添加“Accounts”屏幕,在用户点击某个行时,该屏幕会显示具体帐号的详细信息。
导航参数可使路线变为动态形式。导航参数是一类非常强大的工具,它们会将一个或多个参数传递到路线并调整参数类型或默认值,从而使路线行为变为动态形式。
在 RallyActivity
中,通过向带有参数 Accounts/{name}
的现有 NavHost
添加新的可组合项,向导航图添加新的目的地。对于此目的地,我们还将指定 navArgument
列表。我们将定义一个名为“name”且类型为 String
的参数。
import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navArgument
...
val accountsName = RallyScreen.Accounts.name
composable(
route = "$accountsName/{name}",
arguments = listOf(
navArgument("name") {
// Make argument type safe
type = NavType.StringType
}
)
) {
// TODO
}
每个 composable
目的地的主体都会收到当前 NavBackStackEntry
的一个形参(我们到目前为止还没有用过),NavBackStackEntry 会对当前目的地的路线和实参建模。我们可以使用 arguments
检索该参数(即,所选帐号的名称);也可以在 UserData
中找到它,并将其传递到 SingleAccountBody
可组合项中。
您也可以提供一个默认值,以供在未提供该参数时使用。我们将跳过这部分内容,因为这里并不需要这样做。
现在,您的代码应如下所示:
import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navArgument
...
val accountsName = RallyScreen.Accounts.name
NavHost(...) {
...
composable(
"$accountsName/{name}",
arguments = listOf(
navArgument("name") {
// Make argument type safe
type = NavType.StringType
}
)
) { entry -> // Look up "name" in NavBackStackEntry's arguments
val accountName = entry.arguments?.getString("name")
// Find first name match in UserData
val account = UserData.getAccount(accountName)
// Pass account to SingleAccountBody
SingleAccountBody(account = account)
}
}
导航到 SingleAccountBody
现在,该可组合项已使用相应参数进行设置,您可以使用 navController
导航到该参数,如下所示:navController.navigate("${RallyScreen.Accounts.name}/$accountName")
。
将此函数添加到 NavHost
中 OverviewBody
声明的 onAccountClick
参数内,以及 AccountsBody
的 onAccountClick
内。
为方便重用内容,您可以创建如下所示的私有辅助函数。
fun RallyNavHost(
...
) {
NavHost(
...
) {
composable(Overview.name) {
OverviewBody(
...
onAccountClick = { name ->
navigateToSingleAccount(navController, name)
},
)
}
composable(Accounts.name) {
AccountsBody(accounts = UserData.accounts) { name ->
navigateToSingleAccount(
navController = navController,
accountName = name
)
}
}
...
}
}
private fun navigateToSingleAccount(
navController: NavHostController,
accountName: String
) {
navController.navigate("${Accounts.name}/$accountName")
}
此时,当您运行应用时,您可以点击每个帐号,系统会带您转到显示给定帐号数据的屏幕。
5. 启用深层链接支持
除了实参之外,您还可以使用深层链接将应用中的目的地提供给第三方应用。在此部分中,您将向在上一部分中创建的路线添加新的深层链接,以使应用外部的深度链接能够直接按名称指向具体帐号。
添加 intent 过滤器
首先,将深层链接添加到 AndroidManifest.xml
。您需要为 RallyActivity
创建一个新的 intent 过滤器,相应操作为 VIEW
,类别为 BROWSABLE
和 DEFAULT
。
然后,使用 data
标记添加 scheme
、host
和 pathPrefix
。
此 Codelab 将使用 rally://accounts/{name}
作为深层链接网址。
您无需在 AndroidManifest 中声明“name”参数。Navigation 会将它解析为参数。
<activity
android:name=".RallyActivity"
android:windowSoftInputMode="adjustResize"
android:label="@string/app_name"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="rally" android:host="accounts" />
</intent-filter>
</activity>
响应深层链接
现在,您可以在 RallyActivity
中响应传入的 intent。
您之前为了接受参数而创建的可组合项也可以接受新创建的深层链接。
使用 navDeepLink
函数添加 deepLinks
列表。传递 uriPattern
并为上述 intent-filter
提供匹配的 URI。使用 deepLinks
参数将创建的深层链接传入可组合项。
val accountsName = RallyScreen.Accounts.name
composable(
"$accountsName/{name}",
arguments = listOf(
navArgument("name") {
type = NavType.StringType
},
),
deepLinks = listOf(navDeepLink {
uriPattern = "rally://$accountsName/{name}"
})
)
使用 adb 测试深层链接
现在,您的应用已准备好处理深层链接。为了测试其能否正常运行,请在模拟器或设备上安装最新版 Rally,打开命令行并执行以下命令:
adb shell am start -d "rally://accounts/Checking" -a android.intent.action.VIEW
系统会带您直接进入正在检查的帐号,这适用于应用中的所有帐号名称。
6. 提取完成的 NavHost
您的 NavHost
现已完成。您可以将它从 RallyApp
可组合项中提取到其自己的函数,并将其命名为 RallyNavHost
。这是您应该使用 navController
处理的唯一一个可组合项。如果未在 RallyNavHost
内创建 navController
,您仍然可以用它在 RallyApp
中选择标签页,这是更高层结构的一部分。
@Composable
fun RallyNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = Overview.name,
modifier = modifier
) {
composable(Overview.name) {
OverviewBody(
onClickSeeAllAccounts = { navController.navigate(Accounts.name) },
onClickSeeAllBills = { navController.navigate(Bills.name) },
onAccountClick = { name ->
navController.navigate("${Accounts.name}/$name")
},
)
}
composable(Accounts.name) {
AccountsBody(accounts = UserData.accounts) { name ->
navController.navigate("Accounts/${name}")
}
}
composable(Bills.name) {
BillsBody(bills = UserData.bills)
}
val accountsName = Accounts.name
composable(
"$accountsName/{name}",
arguments = listOf(
navArgument("name") {
type = NavType.StringType
},
),
deepLinks = listOf(navDeepLink {
uriPattern = "example://rally/$accountsName/{name}"
}),
) { entry ->
val accountName = entry.arguments?.getString("name")
val account = UserData.getAccount(accountName)
SingleAccountBody(account = account)
}
}
}
此外,请务必将原始调用点替换为 RallyNavHost(navController)
,确保一切正常运行。
fun RallyApp() {
RallyTheme {
...
Scaffold(
...
) { innerPadding ->
RallyNavHost(
navController = navController,
modifier = Modifier.padding(innerPadding)
)
}
}
}
7. 测试 Compose 中的 Navigation
从此 Codelab 的开头开始,我们就确保不将 navController
直接传入任何可组合项,而是将回调作为参数传递。这意味着您的所有可组合项均可单独测试。不过,您也可以测试整个 NavHost
,这是此步骤的关键。如需测试各个可组合函数,请务必查看在 Jetpack Compose 中进行测试 Codelab。
准备测试类
您的 NavHost 可独立于 activity 本身进行测试。
由于此测试仍会在 Android 设备上运行,因此您需要在 /app/src/androidTest/java/com/example/compose/rally
下的 androidTest
目录中创建测试文件。
执行此操作并将其命名为 RallyNavHostTest
。
然后,为了使用 Compose 测试 API,请创建如下所示的 Compose 测试规则。
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
class RallyNavHostTest {
@get:Rule
val composeTestRule = createComposeRule()
}
现在,您可以编写实际测试了。
编写首个测试
创建一个测试函数,该函数必须设为公开并带有 @Test
注解。在该函数中,您必须设置要测试的内容。使用 composeTestRule
的 setContent
执行此操作。它接受一个可组合参数,支持您编写 Compose 代码,就像您在常规应用中一样。像在 RallyActivity
中那样设置 RallyNavHost
。
import androidx.navigation.compose.rememberNavController
import org.junit.Assert.fail
import org.junit.Test
...
class RallyNavHostTest {
@get:Rule
val composeTestRule = createComposeRule()
lateinit var navController: NavHostController
@Test
fun rallyNavHost() {
composeTestRule.setContent {
navController = rememberNavController()
RallyNavHost(navController = navController)
}
fail()
}
}
如果您复制了上述代码,fail()
调用会确保您的测试一直失败,直到出现实际断言为止。它用于提醒您完成测试的实现。
您可以使用内容说明来验证是否显示了正确的屏幕。在此 Codelab 中,我们提供了 "Accounts Screen"
和 "Overview Screen"
的内容说明,供您用于测试验证。在测试类本身中创建一个 lateinit
属性,以便在将来的测试中使用。
为了轻松开始,请检查是否已显示 OverviewScreen
。
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.navigation.NavHostController
...
class RallyNavHostTest {
@get:Rule
val composeTestRule = createComposeRule()
lateinit var navController: NavHostController
@Test
fun rallyNavHost() {
composeTestRule.setContent {
navController = rememberNavController()
RallyNavHost(navController = navController)
}
composeTestRule
.onNodeWithContentDescription("Overview Screen")
.assertIsDisplayed()
}
}
移除 fail()
调用,再次运行测试,然后测试会通过。理应如此。
在以下每个测试中,RallyNavHost
的设置方式都相同。因此,您可以将其提取到带有 @Before
注解的函数中,确保代码简洁明了。
import org.junit.Before
...
class RallyNavHostTest {
@get:Rule
val composeTestRule = createComposeRule()
lateinit var navController: NavHostController
@Before
fun setupRallyNavHost() {
composeTestRule.setContent {
navController = rememberNavController()
RallyNavHost(navController = navController)
}
}
@Test
fun rallyNavHost() {
composeTestRule
.onNodeWithContentDescription("Overview Screen")
.assertIsDisplayed()
}
}
在测试中导航
您可以通过多种方式对导航实现进行测试,方法是对要转至新目的地的界面元素执行点击,或使用相应的路线名称调用 navigate
。
通过界面和测试规则进行测试
如果您要测试应用的实现,最好在界面中执行点击操作。编写通过点击“All Accounts”按钮转至“Accounts Screen”的测试,确认是否显示了正确的屏幕。
import androidx.compose.ui.test.performClick
...
@Test
fun rallyNavHost_navigateToAllAccounts_viaUI() {
composeTestRule
.onNodeWithContentDescription("All Accounts")
.performClick()
composeTestRule
.onNodeWithContentDescription("Accounts Screen")
.assertIsDisplayed()
}
通过界面和 navController 进行测试
您还可以使用 navController
检查您的断言。为此,请在界面中执行点击操作,然后使用 backstackEntry.value?.destination?.route
将当前路线与预期路线进行比较。
import androidx.compose.ui.test.performScrollTo
import org.junit.Assert.assertEquals
...
@Test
fun rallyNavHost_navigateToBills_viaUI() {
// When click on "All Bills"
composeTestRule.onNodeWithContentDescription("All Bills").apply {
performScrollTo()
performClick()
}
// Then the route is "Bills"
val route = navController.currentBackStackEntry?.destination?.route
assertEquals(route, "Bills")
}
通过 navController 进行测试
第三种方式是直接调用 navController.navigate
,这里有一点需要注意。对 navController.navigate
的调用需要在界面线程上进行。为此,您可以将 Coroutines
与 Main
线程调度程序配合使用。此外,因为这个调用需要先执行,然后您才能生成有关新状态的断言,所以这个调用需要封装在 runBlocking
调用中。
runBlocking {
withContext(Dispatchers.Main) {
navController.navigate(RallyScreen.Accounts.name)
}
}
如此一来,您就可以在应用中导航,并断言路线会带您转到您期望的位置。
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
...
@Test
fun rallyNavHost_navigateToAllAccounts_callingNavigate() {
runBlocking {
withContext(Dispatchers.Main) {
navController.navigate(RallyScreen.Accounts.name)
}
}
composeTestRule
.onNodeWithContentDescription("Accounts Screen")
.assertIsDisplayed()
}
如需详细了解 Compose 中的测试,请参阅下一步的“后续步骤”中链接的 Codelab。
8. 恭喜
恭喜,您已成功完成此 Codelab!
您向 Rally 应用添加了 Navigation,现在,您已经了解使用 Jetpack Compose 中的 Navigation 的关键概念。您了解了如何创建可组合项目的地的导航图、向路线添加参数、添加深层链接,以及如何通过多种方式测试实现。
后续操作
查看下列 Codelab…