Tina Tang's Blog

在哪裡跌倒了,就在哪裡躺下來

0%

Android筆記(29)-Navigation和back stack

瞭解 Android 如何處理應用程式的 tasks 和 back stack。這可讓您操控各種情況下的 back stack (例如取消訂單),讓使用者返回應用程式的第一個畫面,而非訂購流程的前一個畫面。

在先前的程式碼研究室中,您已開始實作 Cupcake app,現將在本程式碼研究室中完成其餘步驟。Cupcake app 有多個畫面,且會顯示杯子蛋糕的訂購流程。完成的 app 必須讓使用者能夠瀏覽 app,以執行下列操作:

  • 建立杯子蛋糕訂單
  • 使用 UpBack button 前往訂購流程的上一個步驟
  • 取消訂單
  • 將訂單傳送至其他 app (例如 email app)

學習目標

  • navigation 對於 app back stack 的影響
  • 如何實作自訂(custom) back stack 行為

實作 Up button 行為

Cupcake app 中,app bar 會顯示可返回上一個畫面的箭頭,此為「Up」button,您在先前的程式碼研究室中曾學過。「Up」button 目前沒有任何作用,因此請先在 app 中修正此 navigation bug。

  1. 您的 MainActivity 中應該已有使用 nav controller 設定 app bar (也稱為 action bar) 的程式碼。將 navController 設為 class 變數,以利於其他方法中使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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. 在同一個 class 中,新增程式碼來覆寫 onSupportNavigateUp() 函式。此程式碼會要求 navController 處理 app 的 navigating up。否則,請返回至處理「Up」button 的父類別實作 (在 AppCompatActivity 中)。
1
2
3
override fun onSupportNavigateUp(): Boolean {
return navController.navigateUp() || super.onSupportNavigateUp()
}
  1. 執行應用程式。「Up」button 現在應可在 FlavorFragmentPickupFragmentSummaryFragment 中運作。前往訂購流程中的上一個步驟時,fragments 應透過 view model 顯示正確的口味(flavor)和取貨日期(pickup date)。

瞭解 tasks 和 back stack

現在,請在 app 的訂購流程中加入「Cancel」button。在訂購過程中的任何時間點取消訂單,會讓使用者返回 StartFragment。若要處理此行為,您需瞭解 Android 中的 tasks 和 back stack。

Tasks

Android 的 activities 存在於 tasks 中。首次從 launcher icon 開啟 app 時,Android 會建立一個包含 main activity 的新 task。「task」是使用者執行特定工作 (例如查看 email、建立杯子蛋糕訂單、拍照) 時,可進行互動的一系列 activities。

系統會以 back stack 的排列方式來顯示 activities,此方式會將使用者造訪的新 activity 推送至 task 的 back stack 上。您可以將上述過程看成鬆餅的堆疊(stack),每一份新的鬆餅皆會添加於堆疊(stack)頂端。堆疊(stack)頂端的 activity 是指使用者目前正在互動的 activity。堆疊(stack)中,位於該 activity 下方的 activity 已移至背景且停止。

使用者想要往回瀏覽時,back stack 功能就能派上用場。Android 可以從 stack 頂端移除目前 activity、將其刪除,並重新啟動其下方的 activity。這稱為從 stack 中移除 activity,並將先前的 activity 移至前景(foreground),以便使用者進行互動。如果使用者想要反覆返回查看,Android 會持續將 activity 從 stack 頂端移除,直到接近 stack 底部為止。如果返回 stack 中沒有任何 activity,系統會將使用者導回至裝置的啟動器畫面(launcher screen)(或至啟動此 app)。

讓我們看看您透過以下 2 個 activities 實作的 Words app 版本:MainActivityDetailActivity

初次啟動 app 時,MainActivity 會開啟,並新增至 task 的 back stack 中。

只要按一下字母,DetailActivity 就會啟動,然後推送到 back stack 上。這表示已建立、啟動並重新啟用 DetailActivity,因此使用者可與其互動。系統會將 MainActivity 置於背景(background),並以灰色的背景顏色顯示於圖表(diagram)中。

若輕觸「Back」button,系統就會從 back stack 中彈出 DetailActivity,並刪除及結束 DetailActivity instance。

接著,back stack 頂端的下一個 item (MainActivity) 就會移至前景(foreground)。

注意:開啟應用程式後,若輕觸裝置上的「主畫面(Home)」,系統會將 app 的所有 task 移至背景(background)。若再次輕觸該 app 的啟動器圖示(launcher icon),Android 會確認 app 中是否存在現有 task ,並將該 task 移至前景(foreground) (包含完整 back stack)。如果沒有現有 task,Android 會為您建立新 task 並啟動 main activity,然後將其推送至 back stack。

如同 back stack 可追蹤使用者已開啟的 activities,只要與 Jetpack Navigation component 搭配運作,back stack 也可透過相同方式追蹤使用者造訪過的 fragment 目的地(destinations)。

只要使用 Navigation library,即可在使用者每次點選「Back」button 時,從返 back stack 中彈出 fragment 目的地(destination)。此預設行為無需實作任何設定。如果您需要自訂(custom) back stack 行為,才需要編寫程式碼,您將為 Cupcake app 進行此操作。

Cupcake app 的預設行為

讓我們看看 back stack 在 Cupcake app 中的運作方式。App 中只有一個 activity,但使用者會瀏覽多個 fragment 目的地。因此,「Back」button 在使用者輕觸時才返回上一個 fragment 目的地較為理想。

初次開啟 app 時,系統會顯示 StartFragment 目的地。該目的地會推送至 stack 頂端。

選取要訂購的杯子蛋糕數量後,將會前往 FlavorFragment,這個目的地會推送至返回 back stack 上。

當您選取口味並輕觸「Next」後,將會前往 PickupFragment,並推送至 back stack 上。

最後,選取自取日期並輕觸「Next」後,將會前往 SummaryFragment,這個目的地會新增至 back stack 頂端。

如果您從 SummaryFragment 中輕觸「Back」或「Up」button,SummaryFragment 會從 stack 中移除並刪除。

PickupFragment 現在位於 back stack 頂端,並向使用者顯示。

再次輕觸「Back」或「Up」按鈕,將會從 stack 中移除 PickupFragment,接著顯示 FlavorFragment

再次輕觸「Back」或「Up」按鈕,將會從 stack 中移除 FlavorFragment,接著顯示 StartFragment

當您在訂購流程中返回到上一個步驟時,一次只會移除一個目的地。但在下一個工作中,您會在 app 中新增取消訂單功能。為此,您必須一次移除 back stack 中的多個目的地,讓使用者返回 StartFragment 建立新訂單。

修改 Cupcake app 中的 back stack

修改 FlavorFragmentPickupFragmentSummaryFragment class 和 layout 檔案,為使用者提供取消訂單按鈕。

新增 navigation action

請先在 app 的 navigation graph 中加入 navigation actions,讓使用者能從後續目的地返回 StartFragment

  1. 前往「res」>「navigation」>「nav_graph.xml」檔案,然後選取「Design」檢視畫面,以開啟 Navigation Editor。
  2. 目前有 startFragmentflavorFragment 的 action、flavorFragmentpickupFragment 的 action,以及 pickupFragmentsummaryFragment 的 action。
  3. 按住並拖曳即可建立從 summaryFragmentstartFragment 的新 navigation action。如想複習如何在 navigation graph 中連結目的地,請參閱這些操作說明
  4. 按住並拖曳 pickupFragment 即可建立至 startFragment 的新 action。
  5. 按住並拖曳 flavorFragment 即可建立至 startFragment 的新 action。
  6. 完成後,navigation graph 應如下所示。

進行上述變更後,使用者即可從訂購流程中較後方的某個 fragments 返回至訂購流程的起始處。現在,您需要實際使用這些 action 進行 navigates 的程式碼。輕觸「Cancel」按鈕處即為適當位置。

在 layout 中新增「Cancel」按鈕

首先,請在所有 fragments (StartFragment 除外) 的 layout 檔案中新增「Cancel」按鈕。如果您已位於訂購流程的第一個畫面,便無需取消訂單。

  1. 開啟 fragment_flavor.xml layout 檔案。
  2. 您可以使用「Split」view 直接編輯 XML,且並排 view 預覽畫面。
  3. 在 subtotal text view 和「Next」按鈕之間新增「Cancel」按鈕。為其指派 resource ID @+id/cancel_button,並以 text 顯示 @string/cancel

該按鈕應與「Next」按鈕平行放置,以一列按鈕的形式呈現。針對 vertical constraint,請將「Cancel」按鈕 top constraint 與「Next」按鈕 top 同高。針對 horizontal constraints,請將「Cancel」按鈕的 start 處限制於 parent 中,並將其 end 處限制於「Next」按鈕的 start 處。

此外,請將「Cancel」按鈕的 height 設為 wrap_content,width 設為 0dp,以便將螢幕寬度平均分配給另一個按鈕。請注意,進入下一個步驟前,「Preview」窗格不會顯示此按鈕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...

<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」按鈕的 start constraint 從 app:layout_constraintStart_toStartOf="parent" 變更為 app:layout_constraintStart_toEndOf="@id/cancel_button"。此外,在「Cancel」按鈕上新增 end margin,讓兩個按鈕之間留有空白。現在,Android Studio 的「Preview」窗格中應會顯示「Cancel」按鈕。
1
2
3
4
5
6
7
8
9
10
11
...

<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. 在 visual style 方面,請使用 Material Outlined Button 樣式 (使用屬性 style="?attr/materialButtonOutlinedStyle"),使「Cancel」按鈕不會過於醒目,因「Next」按鈕是您希望使用者專注的主要動作。
1
2
3
<Button
android:id="@+id/cancel_button"
style="?attr/materialButtonOutlinedStyle" ... />

按鈕和位置現在看起來十分完美!

  1. 以同樣的方式,在 fragment_pickup.xml layout 檔案中新增「Cancel」按鈕。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...

<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」按鈕的 start constraint。接著,預覽畫面中會顯示「Cancel」按鈕。
1
2
3
<Button
android:id="@+id/next_button"
app:layout_constraintStart_toEndOf="@id/cancel_button" ... />
  1. fragment_summary.xml 檔案套用類似的變更,但這個 fragment 的 layout 稍有不同。您將在 parent vertical LinearLayout 中的「Send」按鈕下方新增「Cancel」按鈕,並在兩個按鈕之間保留一定間距。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
...

<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. 執行並測試 app。現在,FlavorFragmentPickupFragmentSummaryFragment 的 layouts 中應該會顯示「Cancel」按鈕。不過,輕觸該按鈕目前並不會執行任何動作。請在下一個步驟中為這些按鈕設定 click listeners。
新增「Cancel」按鈕的 click listener

在每個 fragment class (StartFragment 除外) 中新增 Helper 方法,以便在使用者點選「Cancel」按鈕時進行處理。

  1. 將這個 cancelOrder() 方法新增至 FlavorFragment。如果使用者在看到 flavor 選項時決定取消訂單,請呼叫 sharedViewModel.resetOrder() 清除 view model。接著,使用 ID 為 R.id.action_flavorFragment_to_startFragment 的 navigation action 返回 StartFragment
1
2
3
4
fun cancelOrder() {
sharedViewModel.resetOrder()
findNavController().navigate(R.id.action_flavorFragment_to_startFragment)
}
  • 如果您看到與 action resource ID 相關的錯誤,可能需要返回 nav_graph.xml 檔案,確認您的 navigation actions 也命名為相同名稱 (action_flavorFragment_to_startFragment)。
  1. 如要在 fragment_flavor.xml layout 的「Cancel」按鈕上設定 click listener,請使用 listener binding。點選此按鈕可叫用您剛才在 FragmentFlavor class 中建立的 cancelOrder() 方法。
1
2
3
<Button
android:id="@+id/cancel_button"
android:onClick="@{() -> flavorFragment.cancelOrder()}" ... />
  1. 針對 PickupFragment 重複執行相同的程序(process)。在 fragment class 中新增 cancelOrder() 方法,藉此重設訂單,並從 PickupFragment 瀏覽至 StartFragment
1
2
3
4
fun cancelOrder() {
sharedViewModel.resetOrder()
findNavController().navigate(R.id.action_pickupFragment_to_startFragment)
}
  1. fragment_pickup.xml 中,於「Cancel」按鈕上設定 click listener,以便在使用者點選時呼叫 cancelOrder() 方法。
1
2
3
<Button
android:id="@+id/cancel_button"
android:onClick="@{() -> pickupFragment.cancelOrder()}" ... />
  1. SummaryFragment 中的「Cancel」按鈕新增類似的程式碼,讓使用者可以返回 StartFragment。如果 androidx.navigation.fragment.findNavController 未自動 import,您可能需要自行 import。
1
2
3
4
fun cancelOrder() {
sharedViewModel.resetOrder()
findNavController().navigate(R.id.action_summaryFragment_to_startFragment)
}
  1. fragment_summary.xml 中按下「Cancel」按鈕時,將隨即呼叫 SummaryFragmentcancelOrder() 方法。
1
2
3
<Button
android:id="@+id/cancel_button"
android:onClick="@{() -> summaryFragment.cancelOrder()}" ... />
  1. 執行並測試 app,確認您剛才新增至每個 fragment 的邏輯。建立杯子蛋糕訂單時,輕觸 FlavorFragmentPickupFragmentSummaryFragment 上的「Cancel」按鈕,即可返回 StartFragment。繼續建立新訂單時,請注意系統已清除先前訂單中的資訊。

成效看起來不錯,但返回 StartFragment 後,向後 navigating 實際上有錯誤。請按照下列步驟重現錯誤。

  1. 按照訂購流程建立新的杯子蛋糕訂單,直到到達 summary 畫面為止。舉例來說,您可以訂購 12 個巧克力口味的杯子蛋糕,並選擇未來的取貨日期。

  2. 接著,輕觸「Cancel」。您應該會返回 StartFragment

  3. 這看起來沒問題,但如果您輕觸系統自帶的「Back」按鈕,就會回到訂單 summary 畫面,其中將顯示訂單摘要:訂購 0 個杯子蛋糕,未選擇任何口味。這是錯誤現象,不應向使用者顯示。

使用者可能不想回到訂購流程。此外,view model 中的所有訂單資料均已清除,因此這項資訊不實用。反之,輕觸 StartFragment 中的「Back」按鈕,應離開 Cupcake app。

以下我們將介紹 back stack 目前的情況,以及修正 bug 的方法。透過訂單 summary 畫面建立訂單時,每個目的地(destination)都會推送至 back stack 上。

您在 SummaryFragment 取消了訂單。當您使用 SummaryFragmentStartFragment 的 action 進行 navigated 時,Android 會新增另一個 StartFragment instance,做為 back stack 上的新目的地(destination)。

因此,當您輕觸 StartFragment 中的「Back」按鈕時,app 最終會重新顯示 SummaryFragment (包含空白訂單資訊)。

如要修正這個 navigation bug,請瞭解 Navigation component 如何讓使用者在使用 action 進行 navigating 時,從返 back stack 中移除其他目的地(destination)。

從 back stack 中移除其他目的地
★Navigation action:popUpTo 屬性

navigation graphnavigation action 中加入 app:popUpTo 屬性後,即可從 back stack 中移除多個目的地,直到到達指定目的地為止。如果指定 app:popUpTo="@id/startFragment",則會移除 back stack 中的目的地,直到到達 StartFragment 為止,此片段會保留在 stack 中。

將此變更新增至程式碼並執行 app 時,您將會發現只要取消訂單,就會回到 StartFragment。但這次,當您輕觸 StartFragment 的「Back」按鈕時,會再次看到 StartFragment (而不是結束 app)。這也不是預期出現的行為。如先前所述,由於您正在前往 StartFragment,Android 實際上會在 back stack 中新增 StartFragment 做為新目的地,因此現在 back stack 會有 2 個 StartFragment instances。因此,您必須輕觸「Back」按鈕兩次才能結束 app。

★Navigation action:popUpToInclusive 屬性

為修正這個新 bug,請要求將所有目的地從 back stack 中移除,直到 (且包含) StartFragment 為止。請在適當的 navigation actions 指定 app:popUpTo="@id/startFragment"app:popUpToInclusive="true",以達到此目標。如此一來,back stack 中就只會有一個新的 StartFragment instance。接著輕觸 StartFragment 中的「Back」按鈕,結束 app。我們現在要進行這項變更。

修改 navigation action
  1. 開啟「res」>「navigation」>「nav_graph.xml」檔案,前往 Navigation Editor。

  2. 選取從 summaryFragmentstartFragment 的 action,使其以藍色 highlighted。

  3. 展開右側的「Attributes」(如果尚未開啟)。在可修改的 attributes list 中尋找「Pop Behavior」。

  1. 透過下拉式選單(dropdown)的選項,將 popUpTo 設為 startFragment。這表示 back stack 中的所有目的地皆會移除 (從 stack 頂端開始往下移除),直到 startFragment 為止。
  1. 接著按一下「popUpToInclusive」checkbox,直到畫面上顯示勾號和「true」label 為止。這表示您想要移除目的地,直到 (且包含) back stack 中已經存在的 startFragment instance 為止。透過此方式,back stack 中就不會出現兩個 startFragment instance。
  1. 針對將 pickupFragment 連結到 startFragment 的 action 重複以上變更。
  1. 針對將 flavorFragment 連結到 startFragment 的 action 重複以上操作。

  2. 完成後,請查看 navigation graph 檔案的「Code」view,確認 app 變更內容正確無誤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<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 項 actions (action_flavorFragment_to_startFragmentaction_pickupFragment_to_startFragmentaction_summaryFragment_to_startFragment) 應新增 app:popUpTo="@id/startFragment"app:popUpToInclusive="true" 屬性。
  1. 接著執行 app。請按照訂購流程中的步驟操作,然後輕觸「Cancel」。返回 StartFragment 時,請輕觸「Back」按鈕 (僅限一次!) 退出 app。

簡而言之,當您取消訂單並返回 app 的第一個畫面時, back stack 中的所有 fragment 目的地都會移除,包括第一個出現的 StartFragment。完成 navigation action 後,StartFragment 會做為新目的地新增至 back stack。輕觸該處的「Back」後,會從 stack 移除 StartFragment,使 back stack 中完全沒有 fragment。Android 即完成 activity,且使用者離開 app。

app 應如下所示:


送出訂單

到目前為止,app 看起來很棒!但還剩下一部分。當您輕觸 SummaryFragment 上的「send order」按鈕時,仍會彈出 Toast 訊息。

如果訂單能夠自 app 發出,即可打造更加實用的體驗。善用在先前程式碼研究室中學到的知識,運用 implicit intent 將 app 資訊分享至其他 app。如此一來,使用者就可以在裝置上與 email app 分享杯子蛋糕訂單資訊,讓系統將訂單透過 email 傳送到杯子蛋糕店。

如要實作這項功能,請參閱上方的螢幕截圖,瞭解 email 主旨和 email 內文的結構。

您將會使用 strings.xml 檔案中已包含的字串。

1
2
<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 種不同的格式參數(format arguments),此為杯子蛋糕實際數量(quantity)所需口味(flavor)所需取貨日期(pickup date)總金額(total price)的預留位置。參數編號為 1 到 4,語法為 %1%4。參數類型也已指定 ($s 代表字串預期在此處)。

在 Kotlin 程式碼中,您可以在 R.string.order_details 上呼叫 getString(),後接 4 個參數 (順序很重要!)。舉例來說,呼叫 getString(R.string.order_details, "12", "Chocolate", "Sat Dec 12", "$24.00") 會建立下列字串,而這正是您所需的 email 內文。

1
2
3
4
5
6
Quantity: 12 cupcakes
Flavor: Chocolate
Pickup date: Sat Dec 12
Total: $24.00

Thank you!

注意:您可能會憶起已經學習過含有格式參數(format arguments)的字串資源。舉例來說,您在 app 中使用的 subtotal 和 total price 會宣告為:

1
2
<string name="subtotal_price">Subtotal %s</string>
<string name="total_price">Total %s</string>

其中 %s 是經過格式化(formatted)的 price 字串預留位置。

  1. SummaryFragment.kt 中修改 sendOrder() 方法。移除現有的 Toast 訊息。
1
2
3
fun sendOrder() {

}
  1. sendOrder() 方法中,建構 order summary 文字。從 shared view model 取得訂單數量、口味、日期和價格,建立格式化的 order_details 字串。
1
2
3
4
5
6
7
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() 方法中,建立將訂單分享至其他 app 的 implicit intent。請參閱說明文件,瞭解如何建立 email intent。請為 intent action 指定 Intent.ACTION_SEND、將 type 設為 "text/plain",並加入 email 主旨 (Intent.EXTRA_SUBJECT) 和 email 內文 (Intent.EXTRA_TEXT) 的 intent extras。視需要 import android.content.Intent
1
2
3
4
val intent = Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.new_cupcake_order))
.putExtra(Intent.EXTRA_TEXT, orderSummary)
  • 另提供額外 tip,如果您將此 app 調整為個人用途,可將 email 收件者預先填入為杯子蛋糕店的 email 地址。在 intent 中,您將以 intent extra Intent.EXTRA_EMAIL 指定 email 收件者。
  1. 由於此為 implicit intent,您不必事先得知哪個特定 component 或 app 會處理這項 intent。使用者會決定要使用哪一款 app 來達到 intent。但是,在使用這項 intent 啟動(launching) activity 前,請先檢查是否有 app 能處理此 intent。如果沒有能處理此 intent 的 app,這項檢查可防止 Cupcake app 當機,讓程式碼更加安全。
1
2
3
if (activity?.packageManager?.resolveActivity(intent, 0) != null) {
startActivity(intent)
}

透過存取 PackageManager 執行這項檢查,其具備裝置上所安裝 app packages 的相關資訊。只要 activity 和 packageManager 並非空值(null),就可以透過 fragment 的 activity 存取 PackageManager。使用您建立的 intent 呼叫 PackageManagerresolveActivity() 方法。如果結果不是空值(null),可以放心使用 intent 呼叫 startActivity()

  1. 執行 app 以測試程式碼。建立杯子蛋糕訂單,然後輕觸「Send Order to Another App」。畫面上顯示分享 dialog pops up 時,即可選取 Gmail app。如有需要,亦可選擇其他 app。如果您選擇 Gmail app,可能需要在裝置上設定帳戶 (如果尚未設定,例如您正在使用 emulator)。如果 email 內文中未顯示最新的杯子蛋糕訂單,您可能需要先捨棄目前的 email 草稿。

在不同情況下進行測試時,如果只有 1 個杯子蛋糕,可能會發現錯誤。order summary 顯示「1 cupcakes」,但是這種說法在英文中屬於文法錯誤。

反之,應顯示「1 cupcake」(非複數型態)。如要根據數量值選擇單數或複數型態的字詞,可以在 Android 中使用 quantity strings。只要宣告 plurals resource,即可根據數量指定不同的 string resources,例如單數或複數型態。

  1. strings.xml 檔案中新增 cupcakes 複數資源(plurals resource)。
1
2
3
4
<plurals name="cupcakes">
<item quantity="one">%d cupcake</item>
<item quantity="other">%d cupcakes</item>
</plurals>
  • 在單數(singular)情況 (quantity="one") 下,會使用單數字串(singular string)。在所有其他情況下 (quantity="other"),將會使用複數字串(plural string)。請注意,%d 為整數參數,而非 %s 的字串參數,當您格式化字串時,將會傳入此參數。

在 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 字串

注意:呼叫 getQuantityString() 時,您必須傳入數量兩次,因為第一個數量參數用於選取正確的複數字串(plural string)。第二個數量參數則用於實際字串資源的 %d 預留位置。

  1. 前往 Kotlin 程式碼前,請更新 strings.xml 中的 order_details 字串資源,使杯子蛋糕的複數(plural)版本不再以 hardcoded 的方式寫入。
1
2
<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 class 中,更新 sendOrder() 方法以使用新的數量字串。最簡單的方式是先從 view model 找出數量,然後儲存在變數中。由於 view model 中的 quantity 屬於 LiveData<Int> 類型,因此 sharedViewModel.quantity.value 可能為空值。如果為空值,請使用 0 做為 numberOfCupcakes 的預設值。

請將此新增為 sendOrder() 方法中的第一行程式碼。

1
val numberOfCupcakes = sharedViewModel.quantity.value ?: 0
  • elvis 運算子 (?:) 表示左側的運算式並非空值時,請使用該運算式。如果左側的運算式為空值,請使用 elvis 運算子右側的運算式 (本例中為 0)。
  1. 接著和先前一樣,將 order_details 字串格式化。請勿以 numberOfCupcakes 做為數量參數直接傳入,而是使用 resources.getQuantityString(R.plurals.cupcakes, numberOfCupcakes, numberOfCupcakes) 建立格式化的杯子蛋糕字串。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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. 執行並測試程式碼。檢查 email 內文中的訂單摘要是否顯示 1 個杯子蛋糕、6 個杯子蛋糕或 12 個杯子蛋糕。

透過此方法,您已完成 Cupcake app 的所有功能!恭喜!!這是一款極具挑戰性的 app,而您在成為 Android 開發人員的旅程中,獲得大幅的進展!您已成功整合目前為止學到的所有概念,同時在過程中整理出一些新的問題解決方式。