Tina Tang's Blog

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

0%

Android筆記(32)-專案:Lunch Tray應用程式

建構一款名為「Lunch Tray」的新應用程式

建構項目
您會建構一個名為 Lunch Tray 的訂餐應用程式、透過 data binding 實作 ViewModel,並在 fragment 之間加入 navigation 功能。


專案說明

本程式碼研究室將說明如何自行建構一款名為「Lunch Tray」的新應用程式。我們會引導您逐步完成 Lunch Tray 應用程式專案,包括在 Android Studio 中設定和測試專案。

本程式碼研究室與本課程中的其他部分不同。與先前的程式碼研究室不同,本程式碼研究室的目的並不是逐步說明如何建構應用程式,而是設定將由您獨力完成的專案,提供自行完成應用程式及檢查工作成果的相關指示。

我們改為一併在您將下載的應用程式中提供測試套件,而非程式碼解答。您將在 Android Studio 中執行這些測試 (本程式碼研究室稍後會說明操作方法),並查看程式碼是否通過測試。這可能需要多試幾次,即使是專業開發人員也很難第一次嘗試就通過所有測試!程式碼通過所有測試後,您就能將這項專案視為完成。

我們瞭解,您可能只是想獲得解答來對照檢查。我們特意不提供程式碼解答,是因為希望您能透過練習,體驗專業開發人員的作業環境。您可能會需要用到較不嫻熟的其他技能,例如:

  • 在 Google 上搜尋您在應用程式中不認得的字詞、錯誤訊息和程式碼片段。
  • 測試程式碼、解讀錯誤,然後變更程式碼並重複測試。
  • 回去閱讀先前 Android 基本概念中的內容,溫故知新。
  • 將您知道可順利執行的程式碼 (例如專案內提供的程式碼,或是單元 3 中其他應用程式先前的解決方案程式碼) 與您編寫的程式碼進行比對。

乍看之下可能很困難,但我們百分之百相信如果您能夠完成單元 3,就已經對這項專案做好準備了。請按照自己的步調進行,不要放棄,我們對您有信心。


完成的應用程式總覽

歡迎來到專案:Lunch Tray!

您或許已經知道,navigation 是 Android 開發作業的基本要素。無論是使用應用程式瀏覽食譜、尋找前往喜愛餐廳的路線,還是訂餐這件最重要的事,您都很有可能需要瀏覽多個畫面的內容。在這個專案中,您會運用在單元 3 中學到的技巧,建構一個名為 Lunch Tray 的午餐訂購應用程式,並且實作 view model、data binding,以及多個畫面間的 navigation 功能

以下是應用程式最終的螢幕截圖。初次啟動 Lunch Tray 應用程式時,系統會向使用者顯示歡迎畫面,內含一個「Start Order」按鈕。

按一下「Start Order」後,使用者就能從可用的選項中選擇主餐。使用者可以變更所選項目,進而更新底部顯示的「Subtotal」部分。

下一個畫面可讓使用者新增配菜。

之後的畫面可讓使用者選取小菜。

最後,系統會向使用者顯示訂單費用的摘要,並細分為小計、銷售稅和總費用。使用者也可以提交或取消訂單。

這兩種選項都會帶使用者返回第一個畫面。如果使用者提交了訂單,畫面底部應會顯示浮動式訊息,讓他們知道訂單已提交。


開始操作

下載專案程式碼

請注意,資料夾名稱是 android-basics-kotlin-lunch-tray-app。在 Android Studio 中開啟專案時,請選取這個資料夾。

範例程式碼網址:
https://github.com/google-developer-training/android-basics-kotlin-lunch-tray-app/tree/main
具有範例程式碼的分支版本名稱:main

在開始實作 ViewModel 和 navigation 功能前,請花點時間確認專案已順利完成建構,並熟悉該專案。首次執行應用程式時,您會看到空白畫面。這是因為您尚未設定 navigation graph,因此 MainActivity 不會顯示任何 fragment。

專案結構應與您處理的其他專案類似。系統會提供 data、model和 ui 的個別 package,以及 resource 的個別目錄。

data class MenuItem

使用者可以訂購的所有午餐選項 (主餐、配菜和小菜) 會以「model」package 的 MenuItem 類別呈現。MenuItem 物件包含菜品名稱(name)、菜品說明(description)、價格(price)和菜品類型(type)

1
2
3
4
5
6
7
8
data class MenuItem(
val name: String, // 菜品名稱
val description: String, // 菜品說明
val price: Double, // 價格
val type: Int // 類型(主餐、配菜和小菜)
) {
fun getFormattedPrice(): String = NumberFormat.getCurrencyInstance().format(price)
}

object ItemType

type 是以「constants」package 中 ItemType 物件的 Int 呈現。

1
2
3
4
5
object ItemType {
val ENTREE = 1 // 主餐
val SIDE_DISH = 2 // 配菜
val ACCOMPANIMENT = 3 // 小菜
}

object DataSource

您可以在 data package 的 DataSource.kt 中找到個別 MenuItem 物件。

1
2
3
4
5
6
7
8
9
10
11
object DataSource {
val menuItems = mapOf(
"cauliflower" to
MenuItem(
name = "Cauliflower",
description = "Whole cauliflower, brined, roasted, and deep fried",
price = 7.00,
type = ItemType.ENTREE
),
...
}

這個物件只包含一個 map,其中有索引 key 以及對應的 value MenuItem。您將從 ObjectViewModel 存取 DataSource,您必須先實作 ObjectViewModel

ViewModel - class OrderViewModel

正如前一頁的螢幕截圖所示,應用程式會要求使用者提供以下三樣資訊:主餐、配菜和小菜。接著,訂單摘要畫面會顯示小計,並根據所選餐點計算銷售稅,然後用來算出訂單總金額。

在「model」package 中開啟 OrderViewModel.kt,您就會看到幾個已經定義的變數。menuItems 屬性可讓您從 ViewModel 存取 DataSource

1
2
// DataSource 中 menuItems 的 Map
val menuItems = DataSource.menuItems

首先,previousEntreePricepreviousSidePricepreviousAccompanimentPrice 也有一些變數。小計會在使用者做出選擇時更新 (而不是在最後加總),因此如果使用者在前往下一個畫面之前變更了所選項目,系統就會透過這些變數來追蹤使用者先前的選項。這些變數可確保小計反映了先前和目前選取項目的價差。

1
2
3
private var previousEntreePrice = 0.0 // 先前主餐價格
private var previousSidePrice = 0.0 // 先前配菜價格
private var previousAccompanimentPrice = 0.0 // 先前小菜價格

此外,還有 _entree_side_accompaniment 這類 private 變數,可用於儲存目前選取的選項。這些都屬於 MutableLiveData<MenuItem?> 類型。每個類型都伴隨 public backing 屬性 entreesideaccompaniment (屬於不可變動的 LiveData<MenuItem?> 類型)。您可以透過 fragment 的 layout 來存取這些內容,讓所選 item 顯示在畫面上。LiveData 物件中包含的 MenuItem 也可以是空值,因為使用者也可以不選取主餐、配菜和/或小菜。

1
2
3
4
5
6
7
8
9
10
11
// 使用者選取的主餐
private val _entree = MutableLiveData<MenuItem?>()
val entree: LiveData<MenuItem?> = _entree

// 使用者選取的配菜
private val _side = MutableLiveData<MenuItem?>()
val side: LiveData<MenuItem?> = _side

// 使用者選取的小菜
private val _accompaniment = MutableLiveData<MenuItem?>()
val accompaniment: LiveData<MenuItem?> = _accompaniment

小計、總計與稅金也有 LiveData 變數,其採數字格式(number formatting)設定,因此能以貨幣形式顯示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 訂單小計(預設0.0元)
private val _subtotal = MutableLiveData(0.0)
// 使用 Transformations.map() 設定 subtotal 顯示的格式(當地幣別)
val subtotal: LiveData<String> = Transformations.map(_subtotal) {
NumberFormat.getCurrencyInstance().format(it)
}

// 訂單總計(預設0.0元)
private val _total = MutableLiveData(0.0)
// 使用 Transformations.map() 設定 total 顯示的格式(當地幣別)
val total: LiveData<String> = Transformations.map(_total) {
NumberFormat.getCurrencyInstance().format(it)
}

// 訂單稅金(預設0.0元)
private val _tax = MutableLiveData(0.0)
// 使用 Transformations.map() 設定 tax 顯示的格式(當地幣別)
val tax: LiveData<String> = Transformations.map(_tax) {
NumberFormat.getCurrencyInstance().format(it)
}

最後,tax rate 是硬式編碼(hardcoded)的 0.08 (8%)。

1
private val taxRate = 0.08

您必須實作 OrderViewModel 中的六個方法。

setEntree()setSide()setAccompaniment()
這些方法應該以相同方式適用於主餐、配菜和小菜。舉例來說,setEntree() 應執行以下操作:

  1. 如果 _entree 不是 null (也就是使用者已選取主餐,但後來變更了選項),請將 previousEntreePrice 設為 current _entree 的價格。
  2. 如果 _subtotal 不是 null,請從 subtotal 減去 previousEntreePrice
  3. _entree 的值更新為傳遞至函式的 entree (使用 menuItems 存取 MenuItem)。
  4. 呼叫 updateSubtotal(),傳遞新選取的主餐價格。

setSide()setAccompaniment() 的邏輯與 setEntree() 的實作相同。

updateSubtotal()
系統會呼叫 updateSubtotal(),並加上應加入小計的新價格參數。這個方法需要執行以下三件事:

  1. 如果 _subtotal 不是 null,請將 itemPrice 新增至 _subtotal
  2. 如果 _subtotalnull,請將 _subtotal 設為 itemPrice
  3. 設定或更新 _subtotal 後,呼叫 calculateTaxAndTotal() 即可更新這些值,以反映新的小計。

calculateTaxAndTotal()
calculateTaxAndTotal() 應根據小計(subtotal)來更新稅金(tax)的變數和總金額(total)。實作如下方法:

  1. _tax 設為 tax rate 乘上 subtotal。
  2. _total 設為 subtotal 加上 tax。

resetOrder()
使用者提交或取消訂單時,系統會呼叫 resetOrder()。當使用者建立新訂單時,請確保應用程式不會留下任何資料。
建議您將在 OrderViewModel 修改的所有變數設回原始值 (0.0 或空值),藉此實作 resetOrder()

建立 data binding 變數

在 layout 檔案中實作 data binding。開啟 layout 檔案,並新增 OrderViewModel type 和/或對應 fragment class 的 data binding 變數

您需要實作所有 TODO 註解,才能在四個 layout 檔案中設定 textclick listeners

  1. fragment_entree_menu.xml
  2. fragment_side_menu.xml
  3. fragment_accompaniment_menu.xml
  4. fragment_checkout.xml

系統會在 layout 檔案中的 TODO 註解標示每個特定工作,步驟摘要如下。

  1. fragment_entree_menu.xml<data> 標記中,新增 EntreeMenuFragment 的 binding 變數。對於每個 radio button,您需在按鈕已選取的情況下,於 ViewModel 中設定主餐。subtotal text view 的 text 應隨之更新。此外,您也需設定 cancel_buttonnext_buttononClick 屬性,以便分別取消訂單或前往下一個畫面。

  2. fragment_side_menu.xml 中執行相同操作,新增 SideMenuFragment 的 binding 變數,但在點選每個 radio button 時,於 view model 中設定配菜。subtotal text 也會需要更新,而您也需為 cancel 和 next button 設定 onClick 屬性。

  3. 再次執行相同的操作,但在 fragment_accompaniment_menu.xml 中,這次使用 AccompanimentMenuFragment 的 binding 變數,在每個 radio button 皆已選取時設定小菜。此外,您也需設定 subtotal text、cancel button 和 next button 的屬性。

  4. fragment_checkout.xml 中,您需要新增 <data> 標記,以便定義 binding 變數。而在 <data> 標記內,請新增兩個 binding 變數:一個用於 OrderViewModel,另一個用於 CheckoutFragment。在 text view 中,您需從 OrderViewModel 設定所選主餐、配菜和小菜的名稱與價格。您還需要設定 OrderViewModel 中的小計、稅金和總金額。接著,使用 CheckoutFragment 中的適當函式,設定訂單提交和取消時的 onClickAttributes

初始化 fragment 中的 data binding 變數

初始化 onViewCreated() 方法中對應 fragment 檔案內的 data binding 變數。

  1. EntreeMenuFragment
  2. SideMenuFragment
  3. AccompanimentMenuFragment
  4. CheckoutFragment

建立 navigation graph

單元 3 中已說明,activity 中的 FragmentContainerView 會代管 navigation graph。開啟 activity_main.xml 並使用以下程式碼來取代 TODO,以宣告 FragmentContainerView

1
2
3
4
5
6
7
8
9
10
11
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/mobile_navigation"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

mobile_navigation.xml navigation graph 位於 res/navigation package 中。

這是應用程式的 navigation graph,但此檔案目前為空白。您的工作是為 navigation graph 新增目的地,並建立以下在不同畫面之間導覽的模型。

  1. StartOrderFragment 前往 EntreeMenuFragment
  2. EntreeMenuFragment 前往 SideMenuFragment
  3. SideMenuFragment 前往 AccompanimentMenuFragment
  4. AccompanimentMenuFragment 前往 CheckoutFragment
  5. CheckoutFragment 前往 StartOrderFragment
  6. EntreeMenuFragment 前往 StartOrderFragment
  7. SideMenuFragment 前往 StartOrderFragment
  8. AccompanimentMenuFragment 前往 StartOrderFragment
  9. 起始目的地 應為 StartOrderFragment

設定 navigation graph 後,您需在 fragment 類別中執行導覽。在 fragment 中實作剩餘的 TODOMainActivity.kt 註解。

  1. 針對 EntreeMenuFragmentSideMenuFragmentAccompanimentMenuFragment 中的 goToNextScreen() 方法,前往應用程式中的下一個畫面。
  2. 針對 EntreeMenuFragmentSideMenuFragmentAccompanimentMenuFragmentCheckoutFragment 中的 cancelOrder() 方法,首先在 sharedViewModel 上呼叫 resetOrder(),然後前往 StartOrderFragment
  3. StartOrderFragment 中,實作 setOnClickListener() 以前往 EntreeMenuFragment
  4. CheckoutFragment 中實作 submitOrder() 方法。在 sharedViewModel 上呼叫 resetOrder(),然後前往 StartOrderFragment
  5. 最後在 MainActivity.kt 中,將 NavHostFragmentnavController 設為 navController

注意:OrderViewModel 會處理這個應用程式的所有資料。您不需要傳送任何參數給目的地 fragment。

完成結果

完成後的 Lunch Tray App 執行結果如下:

可以至我的 Github 查看程式碼:
https://github.com/linglingdr00/Lunch-Tray-App-Practice


測試應用程式

Lunch Tray 專案包含一個「androidTest」目標,有多種測試案例:MenuContentTestsNavigationTestsOrderFunctionalityTests

執行測試

如要執行測試,您可以執行下列其中一項操作:

若是單一測試案例,請開啟測試案例類別,然後按一下類別宣告左側的綠色箭頭。接著從選單中選取「Run」選項。這麼做將會執行測試案例中的所有測試。

您通常只需要執行一項測試,例如在只有一個測試失敗,而其他測試都通過時。執行單一測試的做法,與執行整個測試案例一樣。請按一下綠色箭頭,並選取「Run」選項。

如果您有多個測試案例,也可以執行整個測試套件。就像執行應用程式一樣,您可以在「Run」選單中找到這個選項。

請注意,Android Studio 預設會執行您執行的最後一個目標 (應用程式、測試目標等),因此如果選單仍顯示「Run」>「Run ‘app’」,您可以依序選取「Run」>「Run」執行測試目標。

然後從彈出式選單中選擇測試目標。

測試結果

  • MenuContentTests

  • NavigationTests

  • OrderFunctionalityTests