瞭解 Android 如何處理應用程式的 tasks 和 back stack。這可讓您操控各種情況下的 back stack (例如取消訂單),讓使用者返回應用程式的第一個畫面,而非訂購流程的前一個畫面。
在先前的程式碼研究室中,您已開始實作 Cupcake app,現將在本程式碼研究室中完成其餘步驟。Cupcake app 有多個畫面,且會顯示杯子蛋糕的訂購流程。完成的 app 必須讓使用者能夠瀏覽 app,以執行下列操作:
- 建立杯子蛋糕訂單
- 使用 Up 或 Back button 前往訂購流程的上一個步驟
- 取消訂單
- 將訂單傳送至其他 app (例如 email app)
學習目標
- navigation 對於 app back stack 的影響
- 如何實作自訂(custom) back stack 行為
實作 Up button 行為
在 Cupcake app 中,app bar 會顯示可返回上一個畫面的箭頭,此為「Up」button,您在先前的程式碼研究室中曾學過。「Up」button 目前沒有任何作用,因此請先在 app 中修正此 navigation bug。

- 您的
MainActivity
中應該已有使用 nav controller 設定 app bar (也稱為 action bar) 的程式碼。將navController
設為 class 變數,以利於其他方法中使用。
1 | class MainActivity : AppCompatActivity(R.layout.activity_main) { |
- 在同一個 class 中,新增程式碼來覆寫
onSupportNavigateUp()
函式。此程式碼會要求navController
處理 app 的 navigating up。否則,請返回至處理「Up」button 的父類別實作 (在AppCompatActivity
中)。
1 | override fun onSupportNavigateUp(): Boolean { |
- 執行應用程式。「Up」button 現在應可在
FlavorFragment
、PickupFragment
和SummaryFragment
中運作。前往訂購流程中的上一個步驟時,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 版本:MainActivity
和 DetailActivity
。
初次啟動 app 時,MainActivity
會開啟,並新增至 task 的 back stack 中。

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

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

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

如同 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
修改 FlavorFragment
、PickupFragment
和 SummaryFragment
class 和 layout 檔案,為使用者提供取消訂單按鈕。
新增 navigation action
請先在 app 的 navigation graph 中加入 navigation actions,讓使用者能從後續目的地返回 StartFragment
。
- 前往「res」>「navigation」>「nav_graph.xml」檔案,然後選取「Design」檢視畫面,以開啟 Navigation Editor。
- 目前有
startFragment
至flavorFragment
的 action、flavorFragment
至pickupFragment
的 action,以及pickupFragment
至summaryFragment
的 action。 - 按住並拖曳即可建立從
summaryFragment
至startFragment
的新 navigation action。如想複習如何在 navigation graph 中連結目的地,請參閱這些操作說明。 - 按住並拖曳
pickupFragment
即可建立至startFragment
的新 action。 - 按住並拖曳
flavorFragment
即可建立至startFragment
的新 action。 - 完成後,navigation graph 應如下所示。

進行上述變更後,使用者即可從訂購流程中較後方的某個 fragments 返回至訂購流程的起始處。現在,您需要實際使用這些 action 進行 navigates 的程式碼。輕觸「Cancel」按鈕處即為適當位置。
在 layout 中新增「Cancel」按鈕
首先,請在所有 fragments (StartFragment
除外) 的 layout 檔案中新增「Cancel」按鈕。如果您已位於訂購流程的第一個畫面,便無需取消訂單。
- 開啟
fragment_flavor.xml
layout 檔案。 - 您可以使用「Split」view 直接編輯 XML,且並排 view 預覽畫面。
- 在 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 | ... |
- 在
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 | ... |
- 在 visual style 方面,請使用 Material Outlined Button 樣式 (使用屬性
style="?attr/materialButtonOutlinedStyle"
),使「Cancel」按鈕不會過於醒目,因「Next」按鈕是您希望使用者專注的主要動作。
1 | <Button |
按鈕和位置現在看起來十分完美!

- 以同樣的方式,在
fragment_pickup.xml
layout 檔案中新增「Cancel」按鈕。
1 | ... |
- 請一併更新「Next」按鈕的 start constraint。接著,預覽畫面中會顯示「Cancel」按鈕。
1 | <Button |
- 對
fragment_summary.xml
檔案套用類似的變更,但這個 fragment 的 layout 稍有不同。您將在 parent verticalLinearLayout
中的「Send」按鈕下方新增「Cancel」按鈕,並在兩個按鈕之間保留一定間距。

1 | ... |
- 執行並測試 app。現在,
FlavorFragment
、PickupFragment
和SummaryFragment
的 layouts 中應該會顯示「Cancel」按鈕。不過,輕觸該按鈕目前並不會執行任何動作。請在下一個步驟中為這些按鈕設定 click listeners。
新增「Cancel」按鈕的 click listener
在每個 fragment class (StartFragment 除外) 中新增 Helper 方法,以便在使用者點選「Cancel」按鈕時進行處理。
- 將這個
cancelOrder()
方法新增至FlavorFragment
。如果使用者在看到 flavor 選項時決定取消訂單,請呼叫sharedViewModel.resetOrder()
清除 view model。接著,使用 ID 為R.id.action_flavorFragment_to_startFragment
的 navigation action 返回StartFragment
。
1 | fun cancelOrder() { |
- 如果您看到與 action resource ID 相關的錯誤,可能需要返回
nav_graph.xml
檔案,確認您的 navigation actions 也命名為相同名稱 (action_flavorFragment_to_startFragment
)。
- 如要在
fragment_flavor.xml
layout 的「Cancel」按鈕上設定 click listener,請使用 listener binding。點選此按鈕可叫用您剛才在FragmentFlavor
class 中建立的cancelOrder()
方法。
1 | <Button |
- 針對
PickupFragment
重複執行相同的程序(process)。在 fragment class 中新增cancelOrder()
方法,藉此重設訂單,並從PickupFragment
瀏覽至StartFragment
。
1 | fun cancelOrder() { |
- 在
fragment_pickup.xml
中,於「Cancel」按鈕上設定 click listener,以便在使用者點選時呼叫cancelOrder()
方法。
1 | <Button |
- 為
SummaryFragment
中的「Cancel」按鈕新增類似的程式碼,讓使用者可以返回StartFragment
。如果androidx.navigation.fragment.findNavController
未自動 import,您可能需要自行 import。
1 | fun cancelOrder() { |
- 在
fragment_summary.xml
中按下「Cancel」按鈕時,將隨即呼叫SummaryFragment
的cancelOrder()
方法。
1 | <Button |
- 執行並測試 app,確認您剛才新增至每個 fragment 的邏輯。建立杯子蛋糕訂單時,輕觸
FlavorFragment
、PickupFragment
或SummaryFragment
上的「Cancel」按鈕,即可返回StartFragment
。繼續建立新訂單時,請注意系統已清除先前訂單中的資訊。
成效看起來不錯,但返回 StartFragment
後,向後 navigating 實際上有錯誤。請按照下列步驟重現錯誤。
按照訂購流程建立新的杯子蛋糕訂單,直到到達 summary 畫面為止。舉例來說,您可以訂購 12 個巧克力口味的杯子蛋糕,並選擇未來的取貨日期。
接著,輕觸「Cancel」。您應該會返回
StartFragment
。這看起來沒問題,但如果您輕觸系統自帶的「Back」按鈕,就會回到訂單 summary 畫面,其中將顯示訂單摘要:訂購 0 個杯子蛋糕,未選擇任何口味。這是錯誤現象,不應向使用者顯示。

使用者可能不想回到訂購流程。此外,view model 中的所有訂單資料均已清除,因此這項資訊不實用。反之,輕觸 StartFragment
中的「Back」按鈕,應離開 Cupcake app。
以下我們將介紹 back stack 目前的情況,以及修正 bug 的方法。透過訂單 summary 畫面建立訂單時,每個目的地(destination)都會推送至 back stack 上。

您在 SummaryFragment
取消了訂單。當您使用 SummaryFragment
至 StartFragment
的 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 graph 的 navigation 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
開啟「res」>「navigation」>「nav_graph.xml」檔案,前往 Navigation Editor。
選取從
summaryFragment
至startFragment
的 action,使其以藍色 highlighted。展開右側的「Attributes」(如果尚未開啟)。在可修改的 attributes list 中尋找「Pop Behavior」。

- 透過下拉式選單(dropdown)的選項,將
popUpTo
設為startFragment
。這表示 back stack 中的所有目的地皆會移除 (從 stack 頂端開始往下移除),直到startFragment
為止。

- 接著按一下「popUpToInclusive」checkbox,直到畫面上顯示勾號和「true」label 為止。這表示您想要移除目的地,直到 (且包含) back stack 中已經存在的
startFragment
instance 為止。透過此方式,back stack 中就不會出現兩個startFragment
instance。

- 針對將
pickupFragment
連結到startFragment
的 action 重複以上變更。

針對將
flavorFragment
連結到startFragment
的 action 重複以上操作。完成後,請查看 navigation graph 檔案的「Code」view,確認 app 變更內容正確無誤。
1 | <navigation |
- 請注意,這 3 項 actions (
action_flavorFragment_to_startFragment
、action_pickupFragment_to_startFragment
和action_summaryFragment_to_startFragment
) 應新增app:popUpTo="@id/startFragment"
和app:popUpToInclusive="true"
屬性。
- 接著執行 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 | <string name="new_cupcake_order">New Cupcake Order</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 | Quantity: 12 cupcakes |
- 在
SummaryFragment.kt
中修改sendOrder()
方法。移除現有的Toast
訊息。
1 | fun sendOrder() { |
- 在
sendOrder()
方法中,建構 order summary 文字。從 shared view model 取得訂單數量、口味、日期和價格,建立格式化的order_details
字串。
1 | val orderSummary = getString( |
- 在
sendOrder()
方法中,建立將訂單分享至其他 app 的 implicit intent。請參閱說明文件,瞭解如何建立 email intent。請為 intent action 指定Intent.ACTION_SEND
、將 type 設為"text/plain"
,並加入 email 主旨 (Intent.EXTRA_SUBJECT
) 和 email 內文 (Intent.EXTRA_TEXT
) 的 intent extras。視需要 importandroid.content.Intent
。
1 | val intent = Intent(Intent.ACTION_SEND) |
- 另提供額外 tip,如果您將此 app 調整為個人用途,可將 email 收件者預先填入為杯子蛋糕店的 email 地址。在 intent 中,您將以 intent extra
Intent.EXTRA_EMAIL
指定 email 收件者。
- 由於此為 implicit intent,您不必事先得知哪個特定 component 或 app 會處理這項 intent。使用者會決定要使用哪一款 app 來達到 intent。但是,在使用這項 intent 啟動(launching) activity 前,請先檢查是否有 app 能處理此 intent。如果沒有能處理此 intent 的 app,這項檢查可防止 Cupcake app 當機,讓程式碼更加安全。
1 | if (activity?.packageManager?.resolveActivity(intent, 0) != null) { |
透過存取 PackageManager
執行這項檢查,其具備裝置上所安裝 app packages 的相關資訊。只要 activity 和 packageManager
並非空值(null),就可以透過 fragment 的 activity
存取 PackageManager
。使用您建立的 intent 呼叫 PackageManager
的 resolveActivity()
方法。如果結果不是空值(null),可以放心使用 intent 呼叫 startActivity()
。
- 執行 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,例如單數或複數型態。
- 在
strings.xml
檔案中新增 cupcakes 複數資源(plurals resource)。
1 | <plurals name="cupcakes"> |
- 在單數(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)
會傳回 6cupcakes
字串getQuantityString(R.plurals.cupcakes, 0, 0)
會傳回 0cupcakes
字串
- 前往 Kotlin 程式碼前,請更新
strings.xml
中的order_details
字串資源,使杯子蛋糕的複數(plural)版本不再以 hardcoded 的方式寫入。
1 | <string name="order_details">Quantity: %1$s \n Flavor: %2$s \nPickup date: %3$s \n |
- 在
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
)。
- 接著和先前一樣,將
order_details
字串格式化。請勿以numberOfCupcakes
做為數量參數直接傳入,而是使用resources.getQuantityString(R.plurals.cupcakes, numberOfCupcakes, numberOfCupcakes)
建立格式化的杯子蛋糕字串。
完整的 sendOrder()
方法如下所示:
1 | fun sendOrder() { |
- 執行並測試程式碼。檢查 email 內文中的訂單摘要是否顯示 1 個杯子蛋糕、6 個杯子蛋糕或 12 個杯子蛋糕。
透過此方法,您已完成 Cupcake app 的所有功能!恭喜!!這是一款極具挑戰性的 app,而您在成為 Android 開發人員的旅程中,獲得大幅的進展!您已成功整合目前為止學到的所有概念,同時在過程中整理出一些新的問題解決方式。