导航和返回堆栈

1. 准备工作

在此 Codelab 中,您将接着实现在上一个 Codelab 中开始着手的 Cupcake 应用的其余部分。Cupcake 应用包含多个屏幕,并显示纸杯蛋糕的订单流程。完成的应用将允许用户在应用中导航以执行以下操作:

  • 创建纸杯蛋糕订单
  • 使用 UpBack 按钮,转到订单流程的上一步
  • 取消订单
  • 将订单发送到其他应用,例如电子邮件应用

在此过程中,您将了解 Android 如何处理应用的任务和返回堆栈。这样您就可以在取消订单等情况下操控返回堆栈,从而使用户返回应用的第一个屏幕(而不是订单流程的上一屏幕)。

前提条件

  • 能够在 activity 的 fragment 中创建和使用共享视图模型
  • 熟悉 Jetpack Navigation 组件的使用
  • 已结合 LiveData 使用数据绑定,使界面与视图模型保持同步
  • 可以构建一个 intent 来启动新 activity

学习内容

  • 导航如何影响应用的返回堆栈
  • 如何实现自定义返回堆栈行为

构建内容

  • 一个纸杯蛋糕订购应用,可让用户将订单发送到其他应用并允许取消订单

所需条件

  • 一台安装了 Android Studio 的计算机。
  • 完成上一个 Codelab 获得的 Cupcake 应用的代码

2. 起始应用概览

此 Codelab 使用上一个 Codelab 中的 Cupcake 应用。您可以使用完成前一个 Codelab 获得的代码,也可以从 GitHub 下载起始代码。

下载此 Codelab 的起始代码

如果您从 GitHub 下载起始代码,请注意该项目的文件夹名称为 android-basics-kotlin-cupcake-app-viewmodel。在 Android Studio 中打开项目时,请选择此文件夹。

如需获取此 Codelab 的代码并在 Android Studio 中打开它,请执行以下操作。

获取代码

  1. 点击提供的网址。此时,项目的 GitHub 页面会在浏览器中打开。
  2. 在项目的 GitHub 页面上,点击 Code 按钮,这时会出现一个对话框。

5b0a76c50478a73f.png

  1. 在对话框中,点击 Download ZIP 按钮,将项目保存到计算机上。等待下载完成。
  2. 在计算机上找到该文件(很可能在 Downloads 文件夹中)。
  3. 双击 ZIP 文件进行解压缩。系统将创建一个包含项目文件的新文件夹。

在 Android Studio 中打开项目

  1. 启动 Android Studio。
  2. Welcome to Android Studio 窗口中,点击 Open an existing Android Studio project

36cc44fcf0f89a1d.png

注意:如果 Android Studio 已经打开,请依次选择 File > New > Import Project 菜单选项。

21f3eec988dcfbe9.png

  1. Import Project 对话框中,前往解压缩的项目文件夹所在的位置(很可能在 Downloads 文件夹中)。
  2. 双击该项目文件夹。
  3. 等待 Android Studio 打开项目。
  4. 点击 Run 按钮 11c34fc5e516fb1c.png 以构建并运行应用。请确保该应用按预期构建。
  5. Project 工具窗口中浏览项目文件,了解应用的设置方式。

现在运行应用,它应如下所示:

45844688c0dc69a2.png

在此 Codelab 中,您将首先在应用中完成实现 Up 按钮,以便用户可以通过点按该按钮进入订单流程的上一步。

fbdc1793f9fea6da.png

然后,您将添加一个 Cancel 按钮,以便用户在订购流程中改变主意时可以取消订单。

d66fdafeac1b0dcf.gif

然后,您可以扩展该应用,以便用户可以通过点按 Send Order to Another App 将订单共享给其他应用。然后,订单就可以通过电子邮件等应用发给纸杯蛋糕店。

170d76b64ce78f56.png

让我们一起深入了解 Cupcake 应用并完成应用构建吧!

3. 实现向上按钮行为

Cupcake 应用中,应用栏会显示一个箭头,点按该箭头可返回上一屏幕。该按钮称为 Up 按钮,您在之前的 Codelab 中已对此有所了解。鉴于 Up 按钮当前没有任何作用,因此我们先来修复应用中这一导航 bug。

fbdc1793f9fea6da.png

  1. MainActivity 中,您应该已经获得相应代码,可使用导航控制器来设置应用栏(也称为操作栏)。将 navController 设为类变量,以便在另一个方法中使用。
class MainActivity : AppCompatActivity(R.layout.activity_main) {

    private lateinit var navController: NavController

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

        val navHostFragment = supportFragmentManager
                .findFragmentById(R.id.nav_host_fragment) as NavHostFragment
        navController = navHostFragment.navController

        setupActionBarWithNavController(navController)
    }
}
  1. 在同一个类中,添加代码以替换 onSupportNavigateUp() 函数。此代码将要求 navController 处理应用中的向上导航。否则,回退到处理 Up 按钮的超类实现(在 AppCompatActivity 中)。
override fun onSupportNavigateUp(): Boolean {
   return navController.navigateUp() || super.onSupportNavigateUp()
}
  1. 运行应用。现在,Up 按钮在 FlavorFragmentPickupFragmentSummaryFragment 中应能正常工作。在导航到订单流程中的前几步时,fragment 应显示视图模型提供的正确口味和提货日期。

4. 了解任务和返回堆栈

现在,您将在应用的订单流程中引入 Cancel 按钮。用户在订单流程中的任何时刻取消订单都会使用户返回 StartFragment。为处理此行为,您需了解 Android 中的任务和返回堆栈。

任务

Android 中的 activity 存在于任务中。当您从启动器图标首次打开应用时,Android 会使用主 activity 创建一个新任务。任务是用户在执行某项作业(例如,查看电子邮件、创建纸杯蛋糕订单、拍照)时与之互动的一系列 activity。

activity 排列在一个堆栈中,称为“返回堆栈”,其中,用户访问的每个新的 activity 都会推送到任务的返回堆栈中。您可以将它看作是一摞煎饼,每一张新的煎饼都会加到这摞煎饼的最上方。堆栈顶部的 activity 是用户当前正在与之互动的 activity。堆栈中位于下方的 activity 已置于后台,并且已停止。

517054e483795b46.png

当用户需要向后导航时,返回堆栈十分有用。Android 可以从堆栈顶部移除当前 activity,将其销毁,然后重新启动其下方的 activity。此过程是将一个 activity 从堆栈中弹出,并将前一个 activity 置于前台,以便用户与之互动。如果用户想要返回多次,Android 将一直从堆栈顶部弹出 activity,直到接近堆栈的底部。当返回堆栈中不再有 activity 时,用户会返回设备的启动器屏幕(或启动该应用的应用)。

我们来看看您使用以下 2 个 activity 实现的 Words 应用的版本:MainActivityDetailActivity

当您首次启动应用时,系统会打开 MainActivity 并将其添加到任务的返回堆栈中。

4bc8f5aff4d5ee7f.png

当您点击某个字母时,系统会启动 DetailActivity,并将其推送到返回堆栈。这意味着,DetailActivity 已创建、启动和恢复,因此用户可以与之互动。MainActivity 会置于后台,并且在图表中显示为灰色的背景色。

80f7c594ae844b84.png

如果您点按 Back 按钮,系统会从返回堆栈中弹出 DetailActivity,并销毁和完成 DetailActivity 实例。

80f532af817191a4.png

然后,返回堆栈顶部的下一个项目 (MainActivity) 会进入前台。

85004712d2fbcdc1.png

返回堆栈可以跟踪用户已打开的 activity,与此相同,返回堆栈还可以借助 Jetpack Navigation 组件跟踪用户访问过的 fragment 目的地。

fe417ac5cbca4ce7.png

借助 Navigation 库,您可以在用户按 Back 按钮时从返回堆栈弹出 fragment 目的地。此默认行为将自动实现,您无需实施任何操作。如果您需要自定义返回堆栈行为,您只需编写代码即可,您将为 Cupcake 应用执行此操作。

Cupcake 应用的默认行为

让我们一起看看在 Cupcake 应用中返回堆栈如何工作。该应用中只有一个 activity,但是用户导航有多个 fragment 目的地。因此,最好在每次点按 Back 按钮时,它能返回到上一个 fragment 目的地。

首次打开应用时,将显示 StartFragment 目的地。该目的地会推送到堆栈的顶部。

cf0e80b4907d80dd.png

选择要订购的纸杯蛋糕数量后,您将前往 FlavorFragment(此时会被推送到返回堆栈)。

39081dcc3e537e1e.png

选择一种口味并点按 Next 即可前往 PickupFragment,此时,PickupFragment 将被推送到返回堆栈。

37dca487200f8f73.png

最后,选择自提日期并点按 Next 后,您将前往 SummaryFragment,此时,SummaryFragment 将被添加到返回堆栈的顶部。

d67689affdfae0dd.png

如果您从 SummaryFragment 点按 BackUp 按钮,SummaryFragment 将从堆栈中弹出并被销毁。

215b93fd65754017.png

PickupFragment 现在位于返回堆栈的顶部,并向用户显示。

37dca487200f8f73.png

再次点按 BackUp 按钮。系统从堆栈中弹出 PickupFragment,然后显示 FlavorFragment

再次点按 BackUp 按钮。系统从堆栈中弹出 FlavorFragment,然后显示 StartFragment

当您导航回订单流程中之前的步骤时,一次只能弹出一个目的地。但在下一个任务中,您将向应用添加取消订单功能。这可能需要您一次在返回堆栈中弹出多个目的地,才能将用户返回 StartFragment 以开启新订单。

e3dae0f492450207.png

修改 Cupcake 应用中的返回堆栈

修改 FlavorFragmentPickupFragmentSummaryFragment 类和布局文件,以便为用户提供 Cancel 订单按钮。

添加导航操作

首先,将导航操作添加到应用的导航图中,以便用户从后续目的地导航回 StartFragment

  1. 转到 res > navigation > nav_graph.xml 文件并选择 Design 视图,以打开 Navigation Editor
  2. 目前,有一个从 startFragmentflavorFragment 的操作,从 flavorFragmentpickupFragment 的操作,以及从 pickupFragmentsummaryFragment 的操作。
  3. 点击并拖动即可创建从 summaryFragmentstartFragment 的新导航操作。如需回顾如何关联导航图中的目的地,您可以查看这些说明
  4. pickupFragment 中,点击并拖动以创建到 startFragment 的新操作。
  5. flavorFragment 中,点击并拖动以创建到 startFragment 的新操作。
  6. 完成后,导航图应如下所示。

dcbd27a08d24cfa0.png

通过这些更改,用户可以从订单流程中的某个后续 fragment 遍历订单流程的开头。现在,您需要使用能够实际使用这些操作进行导航的代码。合适位置为点按 Cancel 按钮时。

向布局添加“Cancel”按钮

首先,为除 StartFragment 以外的所有 fragment 的布局文件添加 Cancel 按钮。如果您已经在订单流程的第一个屏幕上,则无需取消订单。

  1. 打开 fragment_flavor.xml 布局文件。
  2. 使用 Split 视图直接修改 XML 并并排查看预览。
  3. 在小计文本视图和 Next 按钮之间添加 Cancel 按钮。为 Cancel 按钮分配资源 ID @+id/cancel_button 和文本,显示为 @string/cancel

该按钮应水平放置于 Next 按钮旁边,使之与 Next 按钮显示在一排。对于垂直约束条件,请将 Cancel 按钮的顶部约束为 Next 按钮的顶部。对于水平约束条件,请将 Cancel 按钮的起始位置约束到父容器,并将其结束位置约束为 Next 按钮的起始位置。

此外,请将 Cancel 按钮的高度和宽度分别指定为 wrap_content0dp,以便其可以与另外一个按钮均分屏幕宽度。请注意,在下一步之前,该按钮不会出现在 Preview 窗格中。

...

<TextView
    android:id="@+id/subtotal" ... />

<Button
    android:id="@+id/cancel_button"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:text="@string/cancel"
    app:layout_constraintEnd_toStartOf="@id/next_button"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="@id/next_button" />

<Button
    android:id="@+id/next_button" ... />

...
  1. fragment_flavor.xml 中,您还需要将 Next 按钮的起始约束条件从 app:layout_constraintStart_toStartOf="parent" 更改为 app:layout_constraintStart_toEndOf="@id/cancel_button"。此外,还要在 Cancel 按钮上添加一个末端外边距,以便在两个按钮之间留出一些空白。现在,Android Studio 的 Preview 窗格中会显示 Cancel 按钮。
...

<Button
    android:id="@+id/cancel_button"
    android:layout_marginEnd="@dimen/side_margin" ... />

<Button
    android:id="@+id/next_button"
    app:layout_constraintStart_toEndOf="@id/cancel_button"... />

...
  1. 就外观样式而言,使用 Material Outlined Button 样式(具有属性 style="?attr/materialButtonOutlinedStyle"),这样,Cancel 按钮就不会像 Next 按钮一样突出显示,后者是您希望用户关注的主要操作。
<Button
    android:id="@+id/cancel_button"
    style="?attr/materialButtonOutlinedStyle" ... />

现在,按钮及其位置看起来都很合适!

1fb41763cc255c05.png

  1. 以同样的方式,向 fragment_pickup.xml 布局文件添加一个 Cancel 按钮。
...

<TextView
    android:id="@+id/subtotal" ... />

<Button
    android:id="@+id/cancel_button"
    style="?attr/materialButtonOutlinedStyle"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginEnd="@dimen/side_margin"
    android:text="@string/cancel"
    app:layout_constraintEnd_toStartOf="@id/next_button"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="@id/next_button" />

<Button
    android:id="@+id/next_button" ... />

...
  1. 同时更新 Next 按钮的起始约束条件。然后,预览中将显示 Cancel 按钮。
<Button
    android:id="@+id/next_button"
    app:layout_constraintStart_toEndOf="@id/cancel_button" ... />
  1. fragment_summary.xml 文件应用类似的更改,但此 fragment 的布局略有不同。您将在父类别 LinearLayoutSend 按钮下添加 Cancel 按钮,并在两按钮之间添加一定的外边距。

741c0f034397795c.png

...

    <Button
        android:id="@+id/send_button" ... />

    <Button
        android:id="@+id/cancel_button"
        style="?attr/materialButtonOutlinedStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/margin_between_elements"
        android:text="@string/cancel" />

</LinearLayout>
  1. 运行并测试应用。现在,您应该会在 FlavorFragmentPickupFragmentSummaryFragment 的布局中看到 Cancel 按钮。不过,点按该按钮不会执行任何操作。在下一步中为这些按钮设置点击监听器。

添加“Cancel”按钮点击监听器

在每个 fragment 类(StartFragment 除外)中,添加一个支持在用户点击 Cancel 按钮时处理的辅助方法。

  1. 将此 cancelOrder() 方法添加到 FlavorFragment。显示口味选项时,如果用户决定取消订单,请通过调用 sharedViewModel.resetOrder(). 来清除视图模型。然后使用 ID 为 R.id.action_flavorFragment_to_startFragment. 的导航操作导航回 StartFragment
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_flavorFragment_to_startFragment)
}

如果您发现与操作资源 ID 相关的错误,则可能需要返回 nav_graph.xml 文件来验证您的导航操作是否也被命名为同一名称 (action_flavorFragment_to_startFragment)。

  1. 使用监听器绑定在 fragment_flavor.xml 布局中的 Cancel 按钮上设置点击监听器。点击此按钮将会调用您刚刚在 FragmentFlavor 类中创建的 cancelOrder() 方法。
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> flavorFragment.cancelOrder()}" ... />
  1. PickupFragment 重复上述过程。向 fragment 类添加 cancelOrder() 方法,此举会重置订单,并从 PickupFragment 导航到 StartFragment
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_pickupFragment_to_startFragment)
}
  1. fragment_pickup.xml 中,对 Cancel 按钮设置点击监听器,以便在点击该按钮后调用 cancelOrder() 方法。
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> pickupFragment.cancelOrder()}" ... />
  1. SummaryFragment 中的 Cancel 按钮添加类似的代码,以便用户返回 StartFragment。如果系统未自动为您导入 androidx.navigation.fragment.findNavController,则您需要手动导入。.
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_summaryFragment_to_startFragment)
}
  1. fragment_summary.xml 中,点击 Cancel 按钮时,调用 SummaryFragmentcancelOrder() 方法。
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> summaryFragment.cancelOrder()}" ... />
  1. 运行并测试应用以验证您刚刚添加到每个 fragment 的逻辑。创建纸杯蛋糕订单后,点按 FlavorFragmentPickupFragmentSummaryFragment 中的 Cancel 按钮即可返回 StartFragment。在创建新订单时,您会注意到之前订单中的信息已清除。

这看起来运行正常,但返回到 StartFragment 后,实际上存在一个向后导航 bug。执行下面几个步骤重现 bug。

  1. 浏览创建新纸杯蛋糕订单的订单流程,直到到达摘要屏幕。例如,您可以订购 12 个巧克力口味的纸杯蛋糕,然后选择未来的提货日期。
  2. 然后点按 Cancel。您将回到 StartFragment
  3. 这看起来没问题,但如果您点按了系统自带的 Back 按钮,您最终将返回到订单摘要屏幕,其中将显示订单摘要:订购了 0 个纸杯蛋糕,未选择任何口味。此摘要是不正确的,不应向用户显示。

1a9024cd58a0e643.png

用户可能不希望重复执行订单流程。此外,视图模型中的所有订单数据都已清除,因此此信息没有用。在这种情况下,点按 StartFragment 中的 Back 按钮将离开 Cupcake 应用。

让我们一起看一下返回堆栈目前的外观,并了解一下如何修复该 bug。当您通过订单摘要屏幕创建订单时,每个目的地均会推送到返回堆栈。

fc88100cdf1bdd1.png

您从 SummaryFragment 中取消了此订单。当您通过操作从 SummaryFragment 前往 StartFragment 时,Android 在返回堆栈中添加了另一个 StartFragment 实例作为新的目的地。

5616cb0028b63602.png

因此,如果您点按 StartFragment 中的 Back 按钮,应用最终将重新显示 SummaryFragment(包含空白订单信息)。

如需解决此导航 bug,请了解如何使用 Navigation 组件在使用操作进行导航时从返回堆栈中弹出其他目的地。

从返回堆栈中弹出其他目的地

在导航图的导航操作中添加 app:popUpTo 属性后,多个目的地可以从返回堆栈中弹出,直至达到指定的目的地为止。如果您指定 app:popUpTo="@id/startFragment",那么在到达 StartFragment 之前,返回堆栈中的目的地将被弹出,而 StartFragment 将继续保留在堆栈中。

将此更改添加到您的代码中并运行应用后,您会发现,当取消订单时,您将回到 StartFragment。但这时,当您从 StartFragment 中点按 Back 按钮时,会再次看到 StartFragment(而不是退出应用)。这也不是期望出现的行为。如前所述,由于您正在前往 StartFragment,Android 实际上会在返回堆栈中添加 StartFragment 作为新目的地,因此现在您可以在返回堆栈上有 2 个 StartFragment 实例。因此,您需要点按两次 Back 按钮才能退出应用。

dd0fedc6e231e595.png

如需修复这一新 bug,须请求将所有目的地都从返回堆栈中弹出,包含 StartFragment。您可以通过在合适的导航操作上指定 app:popUpTo="@id/startFragment"

app:popUpToInclusive="true" 来修复该错误。这样,在返回堆栈中您就只会有一个新的 StartFragment 实例。然后从 StartFragment 点按 Back 按钮一次即可退出该应用。让我们现在就执行这一更改吧。

cf0e80b4907d80dd.png

修改导航操作

  1. 打开 res > navigation > nav_graph.xml 文件,转到 Navigation Editor
  2. 选择从 summaryFragmentstartFragment 的操作,以便以蓝色突出显示。
  3. 展开右侧的 Attributes(如果尚未打开)。在您可以修改的属性列表中查找 Pop Behavior

8c87589f9cc4d176.png

  1. 在下拉列表选项中,将 popUpTo 设置为 startFragment。这意味着,返回堆栈中的所有目的地都将弹出(从堆栈顶部开始,然后向下),一直到 startFragment

a9a17493ed6bc27f.png

  1. 然后点击 popUpToInclusive 复选框,直到屏幕上显示对勾标记并标记为 true。这表示您要弹出最多的目的地,并包含返回堆栈中已存在的 startFragment 实例。这样,返回堆栈中就不会有两个 startFragment 实例。

4a403838a62ff487.png

  1. 对将 pickupFragment 连接到 startFragment 的操作重复执行这些更改。

4a403838a62ff487.png

  1. 针对将 flavorFragment 连接到 startFragment 的操作重复此操作。
  2. 完成后,查看导航图文件的 Code 视图,确认您对应用进行了正确的更改。
<navigation
    android:id="@+id/nav_graph" ...>
    <fragment
        android:id="@+id/startFragment" ...>
        ...
    </fragment>
    <fragment
        android:id="@+id/flavorFragment" ...>
        ...
        <action
            android:id="@+id/action_flavorFragment_to_startFragment"
            app:destination="@id/startFragment"
            app:popUpTo="@id/startFragment"
            app:popUpToInclusive="true" />
    </fragment>
    <fragment
        android:id="@+id/pickupFragment" ...>
        ...
        <action
            android:id="@+id/action_pickupFragment_to_startFragment"
            app:destination="@id/startFragment"
            app:popUpTo="@id/startFragment"
            app:popUpToInclusive="true" />
    </fragment>
    <fragment
        android:id="@+id/summaryFragment" ...>
        <action
            android:id="@+id/action_summaryFragment_to_startFragment"
            app:destination="@id/startFragment"
            app:popUpTo="@id/startFragment"
            app:popUpToInclusive="true" />
    </fragment>
</navigation>

请注意,这 3 项操作(action_flavorFragment_to_startFragmentaction_pickupFragment_to_startFragmentaction_summaryFragment_to_startFragment)应该都新增了属性 app:popUpTo="@id/startFragment"app:popUpToInclusive="true"

  1. 现在运行该应用。浏览订单流程,然后点按 Cancel。当您返回到 StartFragment 时,点按 Back 按钮(仅按一次!),您将退出应用。

下面将简要概述发生了什么,当您取消订单并返回到应用的第一个屏幕时,返回堆栈中的所有 fragment 目的地都将弹出堆栈,包括 StartFragment 的第一个实例。完成导航操作后,在返回堆栈中会添加 StartFragment 作为新目的地。点按 Back 会将 StartFragment 弹出堆栈,而不会在返回堆栈中留下任何 fragment。因此,Android 会完成 activity,用户也将离开应用。

该应用的外观应如下所示:2e0599d9b55401f1.png

5. 发送订单

这个应用现在看起来已经很棒了!不过还缺少一个部分。当您点按 SummaryFragment 上的“Send Order”按钮时,系统仍然会弹出 Toast 消息。

90ed727c7b812fd6.png

如果可以从应用中发送订单,这个应用会更实用。充分利用您在之前的 Codelab 中学到的知识,了解如何利用隐式 intent 将应用中的信息分享到其他应用。这样,用户就可以使用设备上的电子邮件应用分享纸杯蛋糕订单信息,从而将订单通过电子邮件发送到纸杯蛋糕店。

170d76b64ce78f56.png

如需实现此功能,请查看上方屏幕截图中电子邮件主题和正文的结构。

您将使用 strings.xml 文件中已有的字符串。

<string name="new_cupcake_order">New Cupcake Order</string>
<string name="order_details">Quantity: %1$s cupcakes \n Flavor: %2$s \nPickup date: %3$s \n Total: %4$s \n\n Thank you!</string>

order_details 是包含 4 种不同格式参数的字符串资源,这些参数是纸杯蛋糕实际数量、期望的口味、期望提货日期和总价的占位符。参数的编号为 1 到 4,语法为 %1%4。系统也指定了参数类型($s 表示此处需要字符串)。

在 Kotlin 代码中,您将能够对 R.string.order_details 调用 getString(),后接 4 个参数(顺序很重要!)。例如,调用 getString(R.string.order_details, "12", "Chocolate", "Sat Dec 12", "$24.00") 可创建以下字符串,该字符串是您需要的电子邮件正文。

Quantity: 12 cupcakes
Flavor: Chocolate
Pickup date: Sat Dec 12
Total: $24.00

Thank you!
  1. SummaryFragment.kt 中,修改 sendOrder() 方法。移除现有的 Toast 消息。
fun sendOrder() {

}
  1. sendOrder() 方法中,构建订单摘要文本。获取共享视图模型中的订单数量、口味、日期和价格,然后创建格式化的 order_details 字符串。
val orderSummary = getString(
    R.string.order_details,
    sharedViewModel.quantity.value.toString(),
    sharedViewModel.flavor.value.toString(),
    sharedViewModel.date.value.toString(),
    sharedViewModel.price.value.toString()
)
  1. sendOrder() 方法中,创建一个隐式 intent,以将订单分享给其他应用。如需了解如何创建电子邮件 intent,请参阅文档。为 intent 操作指定 Intent.ACTION_SEND,将类型设置为 "text/plain",并包含电子邮件主题 (Intent.EXTRA_SUBJECT) 和电子邮件正文 (Intent.EXTRA_TEXT) 的 intent extra。如果需要,导入 android.content.Intent
val intent = Intent(Intent.ACTION_SEND)
    .setType("text/plain")
    .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.new_cupcake_order))
    .putExtra(Intent.EXTRA_TEXT, orderSummary)

额外提示:如果您自行调整此应用以适应您自己的用例,则可以预先填充电子邮件的收件人,作为纸杯蛋糕店的电子邮件地址。在 intent 中,您需要使用 intent extra Intent.EXTRA_EMAIL 指定电子邮件收件人。

  1. 由于这是隐式 intent,因此您无需提前了解具体由哪个组件或应用处理此 intent。用户将决定要使用哪个应用来处理 intent。不过,在使用该 intent 启动 activity 之前,请检查是否有应用能够处理该 intent。如果没有可用的应用来处理 intent,执行该项检查可防止 Cupcake 应用崩溃,从而提升代码的安全性。
if (activity?.packageManager?.resolveActivity(intent, 0) != null) {
    startActivity(intent)
}

通过访问 PackageManager 来执行该项检查,PackageManager 中说明了设备上安装了哪些应用软件包。PackageManager 可通过 fragment 的 activity 访问,前提是 activitypackageManager 均不为 null。使用您创建的 intent 调用 PackageManagerresolveActivity() 方法。如果结果不为 null,就可以放心使用您的 intent 来调用 startActivity()

  1. 运行您的应用以测试代码。创建纸杯蛋糕订单,然后点按 Send Order to Another App。当共享对话框弹出时,您可以选择 Gmail 应用,但如果您愿意,也可以选择其他应用。如果您选择 Gmail 应用,则可能需要在设备上设置一个账号(如果您尚未设置账号),例如,在您使用模拟器的情况下。如果电子邮件正文中未显示您的最新纸杯蛋糕订单,则可能需要先舍弃当前的电子邮件草稿。

170d76b64ce78f56.png

在测试不同场景时,如果您只订购 1 个纸杯蛋糕,可能会出现 bug。订单摘要显示 1 cupcakes,但在英语中,这种说法存在语法错误。

ef046a100381bb07.png

正确说法应为 1 cupcake(不使用复数)。如果您想根据数量值选择是使用“cupcake”还是“cupcakes”,则可以使用 Android 中的数量字符串。通过声明 plurals 资源,您可以根据具体数量指定要使用的不同字符串资源,例如采用单数或复数形式。

  1. strings.xml 文件中添加一个 cupcakes 复数资源。
<plurals name="cupcakes">
    <item quantity="one">%d cupcake</item>
    <item quantity="other">%d cupcakes</item>
</plurals>

在单数情况 (quantity="one") 下,将使用单数形式的字符串。在所有其他情况 (quantity="other") 下,将使用复数形式的字符串。请注意,与需要字符串参数的 %s 不同,%d 需要的是整数参数,您将在格式化字符串时传入该整数参数。

在您的 Kotlin 代码中,调用

getQuantityString(R.plurals.cupcakes, 1, 1) 将返回字符串 1 cupcake

getQuantityString(R.plurals.cupcakes, 6, 6) 将返回字符串 6 cupcakes

getQuantityString(R.plurals.cupcakes, 0, 0) 将返回字符串 0 cupcakes

  1. 在转到您的 Kotlin 代码之前,请更新 strings.xml 中的 order_details 字符串资源,这样复数形式的 cupcakes 就不会再硬编码到代码中。
<string name="order_details">Quantity: %1$s \n Flavor: %2$s \nPickup date: %3$s \n
        Total: %4$s \n\n Thank you!</string>
  1. SummaryFragment 类中,更新 sendOrder() 方法以使用新的数量字符串。最简单的方法是首先从视图模型中计算出数量,然后将其存储在变量中。由于视图模型中的 quantity 的类型为 LiveData<Int>,因此 sharedViewModel.quantity.value 可能为 null。如果该值为 null,使用 0 作为 numberOfCupcakes 的默认值。

请将以下代码添加为 sendOrder() 方法的第一行代码。

val numberOfCupcakes = sharedViewModel.quantity.value ?: 0

Elvis 运算符 (?:) 表示如果左侧的表达式不为 null,则使用该表达式。但如果左侧的表达式为 null,请使用 Elvis 运算符右侧的表达式(在本例中为 0)。

  1. 然后,像之前一样格式化 order_details 字符串。使用 resources.getQuantityString(R.plurals.cupcakes, numberOfCupcakes, numberOfCupcakes) 创建格式化的纸杯蛋糕字符串,而不是直接传入 numberOfCupcakes 作为数量参数。

完整的 sendOrder() 方法应如下所示:

fun sendOrder() {
    val numberOfCupcakes = sharedViewModel.quantity.value ?: 0
    val orderSummary = getString(
        R.string.order_details,
        resources.getQuantityString(R.plurals.cupcakes, numberOfCupcakes, numberOfCupcakes),
        sharedViewModel.flavor.value.toString(),
        sharedViewModel.date.value.toString(),
        sharedViewModel.price.value.toString()
    )

    val intent = Intent(Intent.ACTION_SEND)
        .setType("text/plain")
        .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.new_cupcake_order))
        .putExtra(Intent.EXTRA_TEXT, orderSummary)

    if (activity?.packageManager?.resolveActivity(intent, 0) != null) {
        startActivity(intent)
    }
}
  1. 运行并测试您的代码。检查电子邮件正文中的订单摘要显示 1 cupcake、6 cupcakes 或 12 cupcakes。

至此,您已经完成了 Cupcake 应用的全部功能!恭喜!构建该应用无疑是一个极具挑战性的任务,但这也使您在成为 Android 开发者的道路上取得了巨大的进步!现在,您将能够成功将到目前为止学到的所有概念相结合,并在此过程中学到一些新的问题解决技巧。

剩下的步骤

现在请花些时间清理代码,这是练习您从之前的 Codelab 中学到的良好编码做法的好机会。

  • 优化导入
  • 重新格式化文件
  • 移除不使用或被注释掉的代码
  • 根据需要在代码中添加注释

如需提升您的应用的无障碍使用性,请在启用 Talkback 的情况下测试您的应用,以确保用户体验顺畅。在适当情况下,语音反馈有助于传达屏幕上各种元素的用途。此外,还要确保应用的所有元素都可使用滑动手势进行导航。

请仔细检查您实现的用例,确保它们在您的最终应用中发挥预期的作用。示例:

  • 将在设备旋转时保留数据(得益于视图模型)。
  • 如果您点按 UpBack 按钮,订单信息应该仍然可以在 FlavorFragmentPickupFragment 上正确显示。
  • 将订单发送到另一个应用将分享正确的订单详情。
  • 取消订单将清除订单中的所有信息。

如果您发现任何 bug,请立即修复。

复核工作做得不错!

6. 解决方案代码

此 Codelab 的解决方案代码可在下面显示的项目中找到。

如需获取此 Codelab 的代码并在 Android Studio 中打开它,请执行以下操作。

获取代码

  1. 点击提供的网址。此时,项目的 GitHub 页面会在浏览器中打开。
  2. 在项目的 GitHub 页面上,点击 Code 按钮,这时会出现一个对话框。

5b0a76c50478a73f.png

  1. 在对话框中,点击 Download ZIP 按钮,将项目保存到计算机上。等待下载完成。
  2. 在计算机上找到该文件(很可能在 Downloads 文件夹中)。
  3. 双击 ZIP 文件进行解压缩。系统将创建一个包含项目文件的新文件夹。

在 Android Studio 中打开项目

  1. 启动 Android Studio。
  2. Welcome to Android Studio 窗口中,点击 Open an existing Android Studio project

36cc44fcf0f89a1d.png

注意:如果 Android Studio 已经打开,请依次选择 File > New > Import Project 菜单选项。

21f3eec988dcfbe9.png

  1. Import Project 对话框中,前往解压缩的项目文件夹所在的位置(很可能在 Downloads 文件夹中)。
  2. 双击该项目文件夹。
  3. 等待 Android Studio 打开项目。
  4. 点击 Run 按钮 11c34fc5e516fb1c.png 以构建并运行应用。请确保该应用按预期构建。
  5. Project 工具窗口中浏览项目文件,了解应用的设置方式。

7. 总结

  • Android 将保留您访问的所有目的地的返回堆栈,其中,每个新目的地都将被推送到堆栈中。
  • 通过点按 UpBack 按钮,您可以将目的地从返回堆栈中弹出。
  • 使用 Jetpack Navigation 组件可帮助您从返回堆栈推出和弹出 fragment 目的地,以便轻松实现默认的 Back 按钮行为。
  • 为导航图中的操作指定 app:popUpTo 属性,即可从返回堆栈中弹出目的地,直到属性值中指定的目的地为止。
  • 如果 app:popUpTo 中指定的目的地也需要从返回堆栈中弹出,则为操作指定 app:popUpToInclusive="true"
  • 您可以通过使用 Intent.ACTION_SEND 和填充 intent extra(例如 Intent.EXTRA_EMAILIntent.EXTRA_SUBJECTIntent.EXTRA_TEXT 等)来创建隐式 intent,以将内容共享到电子邮件应用。
  • 如果您想根据数量使用不同的字符串资源,例如单数或复数形式,请使用 plurals 资源。

8. 了解更多内容

9. 自行练习

使用您自己的纸杯蛋糕订单流程变化来扩展 Cupcake 应用。示例:

  • 提供具有特殊条件的特殊口味,例如不支持当天取货。
  • 向用户询问纸杯蛋糕订单中的相关名称。
  • 如果纸杯蛋糕数量超过 1 份,用户即可为订单选择多个纸杯蛋糕口味。

您需要更新应用的哪些区域以适应这项新功能?

检查您的作品:

您已完成的应用应能正常运行而不出现错误。

10. 挑战任务

运用在构建 Cupcake 应用中所学到的知识构建满足您自身用例的应用。此类应用可以是用于订购披萨、三明治或其他您能想到的一切事物的应用!建议您在开始实现它之前,先草拟出应用的不同目的地。

如需获取其他设计理念的灵感,您还可以查看 Shrine 应用,这是一项 Material 研究报告,其中介绍了如何在自己的品牌中利用 Material 主题背景和组件。Shrine 应用比您构建的 Cupcake 应用要复杂很多,因此与其打算先开发一个极具挑战性的应用,不如先考虑设计一些您可以处理的小功能。然后,通过不断取得成功增强您的信心。

创建好自己的应用后,请在社交媒体上分享您所构建的应用。请使用 #LearningKotlin 标签,以便我们能够看到它!