Tina Tang's Blog

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

0%

Android筆記(28)-在各Fragment之間共用ViewModel

瞭解如何使用共用 ViewModel 在相同 activity 的 fragment 之間共用資料,還有 LiveData 轉換等新概念。

學習目標

  • 如何在更進階用途中導入建議的應用程式架構做法
  • 如何在 activity 的各個 fragment 中使用共用的 ViewModel
  • 如何套用 LiveData 轉換

建構項目
此杯子蛋糕應用程式會顯示杯子蛋糕的訂單流程,讓使用者選擇杯子蛋糕口味、數量和取貨日期。


範例應用程式總覽

杯子蛋糕應用程式總覽

杯子蛋糕應用程式示範如何設計及導入線上訂購應用程式。本課程結束時,您將完成有下列畫面的杯子蛋糕應用程式。使用者可以選擇杯子蛋糕訂單的數量、口味和其他選項。

下載本程式碼研究室的範例程式碼

本程式碼研究室提供範例程式碼,讓您以本程式碼研究室所教授的功能擴充應用程式。範例程式碼含有您先前在程式碼研究室中熟悉的程式碼。

請注意,如果您從 GitHub 下載範例程式碼,專案的資料夾名稱會是 android-basics-kotlin-cupcake-app-starter。在 Android Studio 中開啟專案時,請選取此資料夾。

範例程式碼逐步操作說明

  1. 在 Android Studio 中開啟已下載的專案。專案的資料夾名稱為 android-basics-kotlin-cupcake-app-starter。然後執行應用程式。
  2. 瀏覽檔案以瞭解範例程式碼。針對 layout 檔案,您可以使用右上角的「Split」選項,同時查看 layout 和 XML 的預覽畫面。
  3. 編譯並執行應用程式時,您會發現應用程式並不完整。除了顯示 Toast 訊息外,這些按鈕的作用不大,而且無法導覽至其他 fragment。

以下說明專案裡的重要檔案。

MainActivity:
MainActivity 和預設產生的程式碼相似,用於將 activity 的內容檢視設定為 activity_main.xml。此程式碼使用參數化建構函式 AppCompatActivity(@LayoutRes int contentLayoutId),其 layout 會加載為 super.onCreate(savedInstanceState) 的一部分。

MainActivity 類別的程式碼

1
class MainActivity : AppCompatActivity(R.layout.activity_main)

與使用預設 AppCompatActivity 建構函式的下列程式碼相同:

1
2
3
4
5
6
7
class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}

Layout (res/layout 資料夾):
layout 資源資料夾含有 activity 和 fragment layout 檔案。這些是較簡單的 layout 檔案,而在先前的程式碼研究室中已熟悉 XML。

  • fragment_start.xml 是應用程式中顯示的第一個畫面。這項產品提供杯子蛋糕圖片和三個按鈕,可供選擇想要訂購的杯子數量:一個杯子蛋糕、六個杯子蛋糕和十二個杯子蛋糕。
  • fragment_flavor.xml 會顯示以圓形按鈕選項呈現的杯子蛋糕口味清單,以及「Next」按鈕。
  • fragment_pickup.xml 提供選擇取貨日的選項,按一下「Next」按鈕即可前往摘要畫面。
  • fragment_summary.xml 會顯示訂單詳細資料摘要,例如數量、口味,以及可將訂單傳送至其他應用程式的按鈕。

Fragment 類別:

  • StartFragment.kt 是應用程式中顯示的第一個畫面。此類別含有三個按鈕的 view binding 程式碼和 click handler。
  • FlavorFragment.kt、PickupFragment.kt 和 SummaryFragment.kt 類別主要包含樣板程式碼,以及「Next」或「Send Order to Another App」按鈕的 click handler,且會顯示 toast 訊息。

完成導覽圖(Navigation Graph)

在這項工作中,我們將連結杯子蛋糕應用程式的畫面,並在應用程式中導入適當的 navigation 功能。
您記得必須使用 Navigation component 嗎?請按照這份指南中的複習,瞭解如何設定專案和應用程式,目標是:

  • 加入 Jetpack Navigation library
  • 在 activity 中新增 NavHost
  • 建立 navigation graph
  • 在 navigation graph 中新增 fragment 目的地

在 navigation graph 中連接目的地(destinations)

  1. 在 Android Studio 的「Project」視窗中,開啟 res > navigation > nav_graph.xml 檔案。如果尚未選取「Design」分頁標籤,請予以選取。

  2. 選取後即可開啟「Navigation Editor」,以視覺化方式呈現應用程式中的 navigation graph。您應該會看到應用程式中已有四個 fragments。

注意:如果目的(destination) fragments 在 Android Studio 中以不同方式排列,請點擊並拖曳目的地(destinations),以類似上述螢幕截圖的方式重新排列,您在程式碼研究室中則能更輕鬆設定 navigation 動作(actions)。

  1. 連結導覽圖中的 fragment 目的地。建立從 startFragment 到 flavorFragment 的動作、從 flavorFragment 到 pickupFragment 的連接,以及從 pickupFragment 到 summaryFragment 的連接。如需更詳細的操作說明,請按照下列後續步驟操作。

  2. 將滑鼠游標懸停在 startFragment 上,直到 fragment 周圍顯示灰色框線,且 fragment 右側邊緣的中心顯示灰色圓圈圖示。按一下圓圈並拖曳至 flavorFragment,然後放開滑鼠。

  1. 兩個 fragment 之間的箭頭表示已成功連接,因此您將可以從 startFragment 前往 flavorFragment。這就是「Navigation」動作,您可以在先前的程式碼研究室中學到。
  1. 同樣地,將新增從 flavorFragment 到 pickupFragment,以及從 pickupFragment 到 summaryFragment 的導覽動作(navigation actions)。建立 navigation actions 後,完成的 navigation graph 看起來會如下所示。
  1. 您建立的三個新動作(actions)應該也會顯示在「Component Tree」窗格中。
  1. 定義 navigation graph 時,建議您也指定開始目的地(start destination)。目前您可以看到 startFragment 旁有一個小型房屋圖示。
  • 這表示 startFragment 將是第一個顯示在 NavHost 中的 fragment。設定為應用程式所需的預期行為。日後只要在任一 fragment 上按一下滑鼠右鍵,然後選取「Set as Start Destination」選單選項,即可隨時變更起點的位置。

從 start fragment 前往 flavor fragment

接下來,您必須新增程式碼,目的是在第一個 fragment 中輕觸按鈕時從 startFragment 前往 flavorFragment,而非顯示 Toast 訊息。以下是 start fragment layout 的參考資料。您在之後的任務中,必須將杯子蛋糕的數量(quantity)傳遞至 flavor fragment。

  1. 在「Project」視窗中,依序開啟 app > java > com.example.cupcake > StartFragment.kt 檔案。

  2. 在 onViewCreated() 方法中,請留意, click listeners 設定在三個按鈕上。輕觸各個按鈕時,系統會呼叫 orderCupcake() 方法,並以杯子蛋糕數量 (1、6 或 12 個杯子蛋糕) 做為參數。

參考 code:

1
2
3
orderOneCupcake.setOnClickListener { orderCupcake(1) }
orderSixCupcakes.setOnClickListener { orderCupcake(6) }
orderTwelveCupcakes.setOnClickListener { orderCupcake(12) }
  1. 在 orderCupcake() 方法中,將顯示 toast 訊息的程式碼替換成前往 flavor fragment 的程式碼。使用 findNavController() 方法取得 NavController,並呼叫此方法的 navigate(),並在傳入動作 ID R.id.action_startFragment_to_flavorFragment。請確認這項動作 ID 與您在 nav_graph.xml 中宣告的動作相符。

將

1
2
3
fun orderCupcake(quantity: Int) {
Toast.makeText(activity, "Ordered $quantity cupcake(s)", Toast.LENGTH_SHORT).show()
}

取代為

1
2
3
fun orderCupcake(quantity: Int) {
findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
  1. 新增「Import」import androidx.navigation.fragment.findNavController,或從 Android Studio 提供的選項中選擇。

在 flavor 和 pickup fragments 中新增 Navigation

這項工作與上一個工作類似,您要在其他 fragment (flavor 和 pickup fragment) 中新增 navigation。

  1. 開啟 app > java > com.example.cupcake > FlavorFragment.kt。請注意,「Next」按鈕 click listener 內呼叫的方法為 goToNextScreen() 方法。

  2. 在 FlavorFragment.kt 的 goToNextScreen() 方法中,取代顯示 Toast 訊息的程式碼,前往 pickup fragment。使用 action ID R.id.action_flavorFragment_to_pickupFragment 並確認此 ID 與 nav_graph.xml 中宣告的 action 相符。

1
2
3
fun goToNextScreen() {
findNavController().navigate(R.id.action_flavorFragment_to_pickupFragment)
}
  • 記得 import androidx.navigation.fragment.findNavController。
  1. 同樣地,在 PickupFragment.kt 的 goToNextScreen() 方法中,取代現有的程式碼以前往 summary fragment。
1
2
3
fun goToNextScreen() {
findNavController().navigate(R.id.action_pickupFragment_to_summaryFragment)
}
  • Import androidx.navigation.fragment.findNavController。
  1. 執行應用程式。確認按鈕可供切換畫面瀏覽。每個 fragment 顯示的資訊可能不完整,但請放心,您會在後續步驟中填入正確的 fragment。

更新 app bar 中的標題

瀏覽應用程式時,app bar 中的標題永遠顯示為杯子蛋糕。
根據目前 fragment 的功能提供更相關的標題,有助於改善使用者體驗。
使用 NavController 變更 app bar (也稱為 action bar) 中各 fragment 的標題,並使用「Up」 (←) 按鈕。

  1. 在 MainActivity.kt 中覆寫 onCreate() 方法來設定 navigation controller。從 NavHostFragment 取得 NavController 的 instance。

  2. 呼叫 setupActionBarWithNavController(navController) 以傳入 NavController 的 instance。操作方式如下:在 app bar 中,根據目的地(destination)的 label 顯示標題;即使沒有頂層(top-level)目的地,也會顯示「Up」 按鈕。

1
2
3
4
5
6
7
8
9
10
11
12
class MainActivity : AppCompatActivity(R.layout.activity_main) {

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

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

setupActionBarWithNavController(navController)
}
}
  1. 在 Android Studio 顯示提示時新增必要的 import 項目。
1
2
3
import android.os.Bundle
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupActionBarWithNavController
  1. 設定每個 fragment 的 app bar 標題。開啟 navigation/nav_graph.xml 並切換至「Code」分頁標籤。

  2. 在 nav_graph.xml 中,修改每個 fragment 目的地的 android:label 屬性。使用已在範例應用程式中宣告的下列字串資源。

  • 如果是 start fragment,請使用 @string/app_name,其 value 為 Cupcake。
  • 如果是 flavor fragment,請使用 @string/choose_flavor,其 value 為 Choose Flavor。
  • 如果是 pickup fragment,請使用 @string/choose_pickup_date,其 value 為 Choose Pickup Date。
  • 如果是 summary fragment,請使用 @string/order_summary,其 value 為 Order Summary。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<navigation ...>
<fragment
android:id="@+id/startFragment"
...
android:label="@string/app_name" ... >
<action ... />
</fragment>
<fragment
android:id="@+id/flavorFragment"
...
android:label="@string/choose_flavor" ... >
<action ... />
</fragment>
<fragment
android:id="@+id/pickupFragment"
...
android:label="@string/choose_pickup_date" ... >
<action ... />
</fragment>
<fragment
android:id="@+id/summaryFragment"
...
android:label="@string/order_summary" ... />
</navigation>
  1. 執行應用程式。請注意,在您前往各個 fragment 目的地時,app bar 中的標題會隨之變更。另請注意,app bar 上會顯示「Up」按鈕 (箭頭 ←)。如果輕觸按鈕,則不會採取任何行動。在下一個程式碼研究室中,您會實作「Up」按鈕行為。

建立共用 ViewModel

現在要開始在各個 fragments 中填入正確 data。您將使用共用 ViewModel 將 app data 儲存在單一 ViewModel 中。app 中的多個 fragments 會使用其 activity 範圍(scope)來存取共用的(shared) ViewModel。

在大部分 production app 中,不同 fragments 間共用 data 是常見的用途。舉例來說,在 Cupcake app 的(本程式碼實驗室)最終版本中(請注意下方螢幕截圖),使用者在第一個畫面選取杯子蛋糕數量,系統在第二個畫面依杯子蛋糕的數量計算並顯示價格。同樣地,summary 畫面也使用口味和取貨日期等其他 app data。

從 app 功能的角度來看,您可以選擇將這筆訂單資訊儲存在單一 ViewModel 中,即可在此 activity 中的各 fragments 之間共用。請記得 ViewModel 是 Android 架構元件的一部分。在設定變更期間,會保留儲存在 ViewModel 中的 app data。若要在 app 中新增 ViewModel,您必須建立可從 ViewModel class 擴充的新 class。

建立 OrderViewModel

在這項工作中,您會為稱為 OrderViewModel 的 Cupcake app 建立共用的 ViewModel。您也會將 app data 新增為 ViewModel 及方法中的屬性,用於更新及修改資料。以下是類別的屬性:

  • Order quantity(訂單數量):Integer
  • Cupcake flavor(杯子蛋糕口味):String
  • Pickup date(取貨日期):String
  • Price(價格):Double
採用 ViewModel 最佳做法

在 ViewModel,建議您勿將 view model data 設為 public 變數。否則,app data 可能會被外部 class 以意想不到的方式修改,並造成 app 無法預料的情況。應將這些可變動(mutable)的屬性設為 private,並導入 backing property,並視需要公開每個屬性的 public 不可變動(immutable)版本。慣例是在 private 可變動屬性名稱前面加上 (_) 作為前綴。

請根據使用者的選擇,採用下列方式更新上述屬性:

  • setQuantity(numberCupcakes: Int)
  • setFlavor(desiredFlavor: String)
  • setDate(pickupDate: String)

Price 不需要 setter 方法,因為您將使用其他屬性在 OrderViewModel 內計算價格。下列步驟將說明如何導入共用 ViewModel。

您將在專案中建立名為 model 的新 package,並新增 OrderViewModel class。如此一來,系統會分隔 view model 程式碼與 UI 程式碼(fragment 和 activity)。根據功能,將程式碼分隔為不同 package 是程式設計的最佳做法。

  1. 在 Android Studio 的「Project」視窗中,以滑鼠右鍵按一下「com.example.cupcake」 >「New」 >「Package」。

  2. 系統會開啟「New Package」對話方塊,將套件命名為 com.example.cupcake.model。

  3. 在 model package 之下建立 OrderViewModel Kotlin class。在「Project」視窗中,以滑鼠右鍵按一下 model package,然後選取「New」>「Kotlin File/Class」。在新 dialog 中,給予檔案名稱 OrderViewModel。

  4. 在 OrderViewModel.kt 中,變更 class signature 以從 ViewModel 擴展/延伸。

1
2
3
4
5
import androidx.lifecycle.ViewModel

class OrderViewModel : ViewModel() {

}
  1. 在 OrderViewModel class 中,將上述討論的屬性新增為 private val。

  2. 請將屬性 type 變更為 LiveData,然後在屬性中加入 backing fields,這樣這些屬性就可觀察(observable),當 view model 中的 source data 發生變化時,UI 就會更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 杯子蛋糕數量 */
private val _quantity = MutableLiveData<Int>(0) // 內部可變動(mutable)
val quantity: LiveData<Int> = _quantity // 外部不可變動(immutable)

/* 杯子蛋糕口味 */
private val _flavor = MutableLiveData<String>("") // 內部可變動(mutable)
val flavor: LiveData<String> = _flavor // 外部不可變動(immutable)

/* 取貨日期 */
private val _date = MutableLiveData<String>("") // 內部可變動(mutable)
val date: LiveData<String> = _date // 外部不可變動(immutable)

/* 價格 */
private val _price = MutableLiveData<Double>(0.0) // 內部可變動(mutable)
val price: LiveData<Double> = _price // 外部不可變動(immutable)

您必須 import 以下 class:

1
2
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
  1. 在 OrderViewModel class 中,新增上述方法。在方法中,傳入可變動屬性(mutable)的參數。

  2. 由於這些 setter 方法需要從 view model 外呼叫,因此請將其保留為 public 方法,也就是不需要在 fun 關鍵字之前使用 private 或其他瀏覽權限修飾符。Kotlin 中的預設瀏覽權限修飾符設定為 public。

1
2
3
4
5
6
7
8
9
10
11
fun setQuantity(numberCupcakes: Int) {
_quantity.value = numberCupcakes
}

fun setFlavor(desiredFlavor: String) {
_flavor.value = desiredFlavor
}

fun setDate(pickupDate: String) {
_date.value = pickupDate
}
  1. 建構並執行應用程式,確保不會發生編譯錯誤。UI 不應出現任何可見的變更。

做得好!現在,您已經開始使用 view model。隨著您在 app 中建構更多功能,您會逐漸向此 class 添加更多程式碼。

如果您在 Android Studio 中看到以灰色字型顯示的類別名稱、屬性名稱或方法名稱,這是正常現象。這表示類別、屬性或方法或是目前未使用,但一定會存在!


使用 ViewModel 更新 UI

在這項工作中,您將使用所建立的共用 view model 來更 app 的 UI。導入共用 view model 的主要差異,就是從 UI Controller 存取該 model 的方式。您將使用 activity instance 而非 fragment instance,後續章節將示範如何執行這項作業。

意指可在各 fragment 之間共用 view model。每個 fragment 都可以存取 view model,藉此查看訂單的部分詳細資訊,或是在 view model 中更新部分 data。

★更新 StartFragment 即可使用 view model

如要在 StartFragment 中使用共用 view model,您必須使用 activityViewModels() 而非 viewModels() 委派類別來初始化 OrderViewModel。

  • viewModels() 範圍(scope):限定於目前 fragment 的 ViewModel instance。不同 fragment 各有所不同。
  • activityViewModels() 範圍(scope):限定於目前 activity 的 ViewModel instance。因此,在相同 activity 中的多個 fragment 中,instance 會保持不變。
使用 Kotlin property delegate

在 Kotlin 中,每個可變動 (var) 屬性都會自動產生屬性的 getter 和 setter 函式。當您設定屬性的 value 或讀取屬性的 value 時,會呼叫 setter 和 getter 函式。(針對唯讀屬性 (val),根據預設只會產生 getter 函式。在讀取唯讀屬性的 value 時,會呼叫 getter 函式。)

  • Kotlin 中的 property delegate 功能可協助您將 getter-setter 責任移交給其他 class。
  • 此 class (稱為「delegate class」) 可提供屬性的 getter 和 setter 函式,並處理其變更。

delegate property 是使用 by 子句和 delegate class instance 來定義:

1
2
// Syntax for property delegation
var <property-name> : <property-type> by <delegate-class>()
  1. 在 StartFragment class 中,取得共用 view model 的引用做為 class 變數。使用 fragment-ktx library 中的 by activityViewModels() Kotlin property delegate。
1
private val sharedViewModel: OrderViewModel by activityViewModels()

您可能需要 import 以下新資料:

1
2
import androidx.fragment.app.activityViewModels
import com.example.cupcake.model.OrderViewModel
  1. 針對 FlavorFragment、PickupFragment、SummaryFragment class 重複上述步驟,您將在程式碼研究室的後續章節中使用此 sharedViewModel instance。

  2. 返回 StartFragment class 後,即可使用 view model。在 orderCupcake() 方法的開頭,先在共用 view model 中呼叫 setQuantity() 方法來更新數量,之後再前往 flavor fragment。

1
2
3
4
fun orderCupcake(quantity: Int) {
sharedViewModel.setQuantity(quantity)
findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
  1. 在 OrderViewModel class 中新增下列方法以檢查是否已設定訂單的口味。您將在後續步驟中在 StartFragment class 中使用此方法。
1
2
3
fun hasNoFlavorSet(): Boolean {
return _flavor.value.isNullOrEmpty()
}
  1. 在 StartFragment class 中的 orderCupcake() 方法中,在設定數量後,若未設定口味,則先將預設口味設定為「Vanilla」(香草),之後再前往 flavor fragment。完整的方法看起來會像這樣:
1
2
3
4
5
6
7
fun orderCupcake(quantity: Int) {
sharedViewModel.setQuantity(quantity)
if (sharedViewModel.hasNoFlavorSet()) {
sharedViewModel.setFlavor(getString(R.string.vanilla))
}
findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
  1. 建構 app 以確保不會發生編譯錯誤。但 UI 並不會出現任何可見的變更。

搭配使用 ViewModel 與 data binding

接下來,您需要使用 data binding 將 view model data 綁定(bind)至 UI。系統也會根據使用者在 UI 中的選擇,更新共用 view model。

複習 data binding
請注意,data binding library 是 Android Jetpack 的一部分。data binding 使用宣告式格式,將 layout 中的 ,UI 元件 bind 至 app 中的 resource data。簡單來說,data binding 將 data (從程式碼) bind 至 views + view binding (binding views to code)。設定這些 bindings,並啟用自動進行更新後,即使您忘記從程式碼手動更新 UI,也能降低錯誤發生的機率。

用使用者的選擇來更新 flavor

  1. 在 layout/fragment_flavor.xml 中,在根 <layout> 標記內新增 <data> 標記。新增 name 為 viewModel 且 type 為 com.example.cupcake.model.OrderViewModel 的 layout 變數。請確認 type 屬性中的 package name 與 app 內共用 view model class OrderViewModel 的 package name 相同。
1
2
3
4
5
6
7
8
9
10
11
<layout ...>

<data>
<variable
name="viewModel"
type="com.example.cupcake.model.OrderViewModel" />
</data>

<ScrollView ...>

...
  1. 同樣地,請針對 fragment_pickup.xml 及 fragment_summary.xml 重複上率步驟以新增 viewModel layout 變數。您將在接下來幾節中使用此變數。fragment_start.xml 未使用共用 view model,因此您不需要在此 layout 中加入這段程式碼。

  2. 在 FlavorFragment class 的 onViewCreated() 中,將 view model instance 與 layout model 中的共用 view model instance 綁定(bind)。在 binding?.apply 區塊中加入以下程式碼。

1
2
3
4
5
binding?.apply {
// 設定 viewModel 為共用 view model
this.viewModel = sharedViewModel
...
}
套用 scope 函式

這可能是您首次在 Kotlin 中看到 apply 函式。apply 是 Kotlin 標準 library 中的 scope function。會在物件 context 內執行 code block。這樣可以建立臨時範圍(temporary scope),而且您可以在該範圍(scope)中即可存取物件而無需物件名稱。apply 的常見用途是設定物件。這類呼叫可以解讀為「套用下列指派作業(assignments)至物件」。

範例:

1
2
3
4
5
6
7
8
9
10
clark.apply {
firstName = "Clark"
lastName = "James"
age = 18
}

// 沒有套用 scope function 的程式碼如下所示。
clark.firstName = "Clark"
clark.lastName = "James"
clark.age = 18
  1. 在 PickupFragment 和 SummaryFragment class 中,針對 onViewCreated() 方法重複上述步驟。
1
2
3
4
5
binding?.apply {
// 設定 viewModel 為共用 view model
this.viewModel = sharedViewModel
...
}
  1. 在 fragment_flavor.xml 中,使用新的 layout 變數 viewModel,根據 view model 的 flavor 值,設定 radio button 的 checked 屬性。如果 radio button 表示的 flavor 與儲存在 view model 中的 flavor 相同,則 radio button 顯示為已選取 (checked = true)。已選取「Vanilla」(香草) RadioButton 的 binding 運算式如下所示:
1
@{viewModel.flavor.equals(@string/vanilla)}
  • 基本上,您會使用 equals 函式來比較 viewModel.flavor 屬性與對應的字串資源,藉此判定已檢查狀態是 True 還是 False。

注意: 請記得,binding 運算式以 @ 符號開頭,並放在大括號 {} 內。

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
<RadioGroup
...>

<RadioButton
android:id="@+id/vanilla"
...
android:checked="@{viewModel.flavor.equals(@string/vanilla)}"
.../>

<RadioButton
android:id="@+id/chocolate"
...
android:checked="@{viewModel.flavor.equals(@string/chocolate)}"
.../>

<RadioButton
android:id="@+id/red_velvet"
...
android:checked="@{viewModel.flavor.equals(@string/red_velvet)}"
.../>

<RadioButton
android:id="@+id/salted_caramel"
...
android:checked="@{viewModel.flavor.equals(@string/salted_caramel)}"
.../>

<RadioButton
android:id="@+id/coffee"
...
android:checked="@{viewModel.flavor.equals(@string/coffee)}"
.../>
</RadioGroup>

Listener bindings

Listener bindings 是指在 event 發生 (例如 onClick event) 時執行的 lambda 運算式。做法類似於 method 引用 (例如 textview.setOnClickListener(clickListener)),但 listener bindings 可讓您執行任意 data binding 運算式。

  1. 在 fragment_flavor.xml 中,使用 listener bindings 將 event listeners 新增至 radio buttons。使用不含參數的 lambda 運算式,並呼叫 viewModel。setFlavor() method 藉由傳入對應的 flavor 字串資源(string resource)。
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
<RadioGroup
...>

<RadioButton
android:id="@+id/vanilla"
...
android:onClick="@{() -> viewModel.setFlavor(@string/vanilla)}"
.../>

<RadioButton
android:id="@+id/chocolate"
...
android:onClick="@{() -> viewModel.setFlavor(@string/chocolate)}"
.../>

<RadioButton
android:id="@+id/red_velvet"
...
android:onClick="@{() -> viewModel.setFlavor(@string/red_velvet)}"
.../>

<RadioButton
android:id="@+id/salted_caramel"
...
android:onClick="@{() -> viewModel.setFlavor(@string/salted_caramel)}"
.../>

<RadioButton
android:id="@+id/coffee"
...
android:onClick="@{() -> viewModel.setFlavor(@string/coffee)}"
.../>
</RadioGroup>
  1. 執行 app,並注意在 flavor fragment 中如何依據預設選取的「Vanilla」(香草) 選項。

漂亮!現在可以移到下一個 fragments。


更新 pickup 和 summary fragment 以使用 view model

Navigate 應用程式,並留意,在 pickup fragment 中的 radio button option labels 為空白。在這項工作中,會計算 4 個可用的取貨日期(pickup dates),並顯示在 pickup fragment 中。有很多種顯示格式化(formatted)日期的方法,Android 提供多種實用的公用程式(utilities)來執行此動作。

建立 pickup options list

Date formatter

Android 架構提供一個稱為 SimpleDateFormat 的 class,該 class 會以區分地區設定方式來格式化並剖析 date。這允許 date 進行格式化 (date → text) 及剖析 (text → date)。

您可以傳入格式字串(pattern string)和地區設定(locale),以建立 SimpleDateFormat 的 instance:

1
SimpleDateFormat("E MMM d", Locale.getDefault())

"E MMM d" 等格式字串(pattern string)表示 Date 和 Time 格式(formats)。從 'A' 到 'Z' 和 'a' 到 'z' 的字母都會視為 pattern letters,代表 date 或 time string 的元件(components)。例如,d 代表一個月中的日期(day in a month)、y 代表年份(year),M 代表月份(month)。如果 date 是 2018 年 1 月 4 日,則 pattern string "EEE, MMM d" 會剖析為 "Wed, Jul 4"。若需完整的 pattern letters 清單,請參閱說明文件。

Locale 物件代表特定的地理區域、政治或文化區域。代表語言/國家/地區/變化版本(variant)組合。Locales 會根據當地慣例來改變資訊 (例如 numbers 或 dates) 的顯示方式。由於世界不同地區表達日期和時間的格式不同,因此日期和時間會依地區設定而有所不同。您將使用 Locale.getDefault() 方法擷取使用者裝置上設定的 locale 資訊,並傳遞至 SimpleDateFormat 建構函式(constructor)中。

Android 中的 Locale 是 language 和 country code 的組合。 language codes 是兩個小寫英文字母 ISO language codes,例如「en」表示英文。country codes 是由兩個大寫英文字母組成的 ISO country codes,例如美國為「US」。

現在使用 SimpleDateFormat 和 Locale 來判斷 Cupcake app 的可取貨日期(pickup date)。

  1. 在 OrderViewModel class 中新增名為 getPickupOptions() 的函式,以便建立並回傳(return) pickup dates list。在方法中,建立一個名為 options 的 val 變數,然後初始化為 mutableListOf<String>()。
1
2
3
private fun getPickupOptions(): List<String> {
val options = mutableListOf<String>()
}
  1. 使用 SimpleDateFormat 傳遞 pattern string "E MMM d" 和 locale 來建立 formatter string。在 pattern string 中,E 代表星期幾,且會剖析為「Tue Dec 10」。
1
val formatter = SimpleDateFormat("E MMM d", Locale.getDefault())
  • 當 Android Studio 出現提示時,import java.text.SimpleDateFormat 和 java.util.Locale。
  1. 取得 Calendar instance 並 assign 給新的變數(variable),將其設定為 val。此變數會包含目前的日期(date)和時間(time)。而且 import java.util.Calendar。
1
val calendar = Calendar.getInstance()
  1. 建置當天日期(current date)加上後續三個日期(following three dates)的 date list。由於這需要 4 個 date options,請重複此程式碼區塊 4 次。此 repeat 區塊會格式化日期(format a date),將其加到 date options list 中,然後將日曆(calendar)增加 1 天。
1
2
3
4
repeat(4) {
options.add(formatter.format(calendar.time))
calendar.add(Calendar.DATE, 1)
}
  1. 在 method 的結尾 return 更新後的 options。以下是已完成的 method:
1
2
3
4
5
6
7
8
9
10
11
private fun getPickupOptions(): List<String> {
val options = mutableListOf<String>()
val formatter = SimpleDateFormat("E MMM d", Locale.getDefault())
val calendar = Calendar.getInstance()
// Create a list of dates starting with the current date and the following 3 dates
repeat(4) {
options.add(formatter.format(calendar.time))
calendar.add(Calendar.DATE, 1)
}
return options
}
  1. 在 OrderViewModel class 中,新增名為 val 的 class 屬性 dateOptions。使用您剛剛建立的 getPickupOptions() 方法進行初始化。
1
val dateOptions = getPickupOptions()

更新 layout 以顯示 pickup options

現在 view model 中有四個可用的 pickup dates,更新 fragment_pickup.xml layout 以顯示這些日期。您還可以使用 data binding 來顯示每個 radio button 的已勾選(checked)狀態,並在選取不同 radio button 時更新 view model 的日期。實作方式類似於 flavor fragment 中的 data binding。

在 fragment_pickup.xml 中:

  • 在 viewModel 中,radio button option0 代表 dateOptions[0] (今天)
  • 在 viewModel 中,radio button option1 代表 dateOptions[1] (明天)
  • 在 viewModel 中,radio button option2 代表 dateOptions[2] (後天)
  • 在 viewModel 中,radio button option3 代表 dateOptions[3] (大後天)
  1. 在 fragment_pickup.xml 中,針對 option0 radio button 使用新的 layout 變數 viewModel,以便根據 view model 中的 date 值來設定 checked 屬性。比較 viewModel.date 屬性與 dateOptions list 中的第一個字串 (也就是當天日期)。使用 equals 函式進行比較,最終 binding 運算式如下所示:
1
@{viewModel.date.equals(viewModel.dateOptions[0])}
  1. 針對相同的 radio button,使用 listener binding 新增 event listener 至 onClick 屬性。按一下此按鈕後,viewModel 就會呼叫 setDate(),傳入 dateOptions[0]。

  2. 如果是相同的 radio button,請將 text 屬性值設定為 dateOptions list 中的第一個字串。

1
2
3
4
5
6
7
8
<RadioButton
android:id="@+id/option0"
...
android:checked="@{viewModel.date.equals(viewModel.dateOptions[0])}"
android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[0])}"
android:text="@{viewModel.dateOptions[0]}"
...
/>
  1. 針對其他 radio button 重複上述步驟,並據此變更 dateOptions 的索引(index)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<RadioButton
android:id="@+id/option1"
...
android:checked="@{viewModel.date.equals(viewModel.dateOptions[1])}"
android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[1])}"
android:text="@{viewModel.dateOptions[1]}"
... />

<RadioButton
android:id="@+id/option2"
...
android:checked="@{viewModel.date.equals(viewModel.dateOptions[2])}"
android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[2])}"
android:text="@{viewModel.dateOptions[2]}"
... />

<RadioButton
android:id="@+id/option3"
...
android:checked="@{viewModel.date.equals(viewModel.dateOptions[3])}"
android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[3])}"
android:text="@{viewModel.dateOptions[3]}"
... />
  1. 執行應用程式後,當 pickup options 可用時就會看到未來幾天。螢幕截圖取決於您的目前日期而有所不同。請注意,依據預設不會選取任何選項。您將在下一個步驟中導入此動作。
  1. 在 OrderViewModel class 中,建立名為 resetOrder() 的函式,重設 view model 中的 MutableLiveData 屬性。將 dateOptions list 中的現在日期值(current date value)指派給 _date.value。
1
2
3
4
5
6
fun resetOrder() {
_quantity.value = 0
_flavor.value = ""
_date.value = dateOptions[0]
_price.value = 0.0
}
  1. 在 class 中新增 init 區塊,並呼叫新 method resetOrder()。
1
2
3
init {
resetOrder()
}
  1. 從 class 中的屬性宣告移除初始值(initial value)。現在,您可以在建立 OrderViewModel instance 時使用 init 區塊來初始化屬性。
1
2
3
4
5
6
7
8
9
10
11
private val _quantity = MutableLiveData<Int>()
val quantity: LiveData<Int> = _quantity

private val _flavor = MutableLiveData<String>()
val flavor: LiveData<String> = _flavor

private val _date = MutableLiveData<String>()
val date: LiveData<String> = _date

private val _price = MutableLiveData<Double>()
val price: LiveData<Double> = _price
  1. 再次執行應用程式。請注意,依據預設會選取今天的日期。

更新 summary fragment 以使用 view model

現在介紹最後一個 fragment 。訂單 summary fragment 是用來顯示訂單詳細資料的摘要(summary)。在這項工作中,請妥善利用共用 view model 中的所有訂單資訊,並使用 data binding 來更新螢幕上的訂單詳細資料(order details)。

  1. 請務必在 fragment_summary.xml 中宣告 view model data 變數 viewModel。
1
2
3
4
5
6
7
8
9
10
11
<layout ...>

<data>
<variable
name="viewModel"
type="com.example.cupcake.model.OrderViewModel" />
</data>

<ScrollView ...>

...
  1. 在 SummaryFragment 中,在 onViewCreated() 中確認 binding.viewModel 已初始化。

  2. 在 fragment_summary.xml 中,從 view model 中讀取,以更新畫面中的 order summary 詳細資料(details)。新增下列 text 屬性來更新數量(quantity)、口味(flavor)和日期(date) TextViews。quantity 為 Int type,因此您必須將其轉換為 String。

1
2
3
4
5
<TextView
android:id="@+id/quantity"
...
android:text="@{viewModel.quantity.toString()}"
... />
1
2
3
4
5
<TextView
android:id="@+id/flavor"
...
android:text="@{viewModel.flavor}"
... />
1
2
3
4
5
<TextView
android:id="@+id/date"
...
android:text="@{viewModel.date}"
... />
  1. 執行並測試應用程式,確認您所選的訂單選項已顯示在訂單摘要中。

根據 order detail 計算 price

查看本程式碼研究室的最終應用程式螢幕截圖時,您會注意到價格確實顯示在每個片段 (StartFragment 除外) 上,因此使用者在建立訂單時即可得知價格。

以下是我們的杯子蛋糕專賣店如何計算價格的規則。

  • 每個杯子蛋糕 $2.00 美元
  • 對於當天取貨,訂單中會再增加 $3.00 美元

因此,6 個杯子蛋糕訂單的價格為 6 個杯子蛋糕 x 每個 $2 美元 = $12 美元。如果同一位使用者想要當天取貨,$3 美元的附加費用會讓訂單總價變成 $15 美元。

更新 view model 中的 price

若要在應用程式中支援這項功能,請先處理杯子蛋糕的價格(price),並先忽略當天取貨費用。

  1. 開啟 OrderViewModel.kt,然後將每杯子蛋糕的價格(price)儲存在變數中。在 class 定義之外 (但在 import 陳述式之後),將此變數宣告在檔案頂端的 top-level private 常數。使用 const 修飾符,並使用 val 設定為唯讀。
1
2
3
4
5
6
7
8
package ...

import ...

private const val PRICE_PER_CUPCAKE = 2.00

class OrderViewModel : ViewModel() {
...
  • 請記得,常數值 (在 Kotlin 中以 const 關鍵字標記) 不會變更,而且會在編譯期間知道該值。若要進一步瞭解常數,請參閱說明文件。
  1. 您已經定義了每杯子蛋糕的 price,請建立計算 price 的輔助方法。此方法可能是 private,因為只能在此 class 中使用。您會在下一個工作中變更 price 邏輯,以便納入當天取貨費用。
1
2
3
private fun updatePrice() {
_price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
}

筆記: A ?: B 意思是「當 A 不為 null 時就返回 A,當 A 為 null 時就返回 B」。

這段程式碼會將每杯子蛋糕的 price 乘以所訂購的杯子蛋糕 quantity。關於括號中的程式碼,因為 quantity.value 的值可以是空值,所以使用 elvis 運算子 (?:)。elvis 運算子 (?:) 表示左側運算式不是空值時,則使用該運算子。如果左側運算式是空值,則使用 elvis 運算子右側的運算式 (本例中為 0)。

  1. 在相同 OrderViewModel class 中,在設定 quantity 時更新 price 變數。在 setQuantity() 函式中呼叫新函式。
1
2
3
4
fun setQuantity(numberCupcakes: Int) {
_quantity.value = numberCupcakes
updatePrice()
}

將 price 屬性 bind 到 UI

  1. 在 fragment_flavor.xml、fragment_pickup.xml 和 fragment_summary.xml 的 layouts 中,確認已定義 type 為 com.example.cupcake.model.OrderViewModel 的 data variable viewModel。
1
2
3
4
5
6
7
8
9
10
11
<layout ...>

<data>
<variable
name="viewModel"
type="com.example.cupcake.model.OrderViewModel" />
</data>

<ScrollView ...>

...
  1. 在每個 fragment class 的 onViewCreated() 方法中,請務必將 fragment 中的 view model 物件 instance bind 至 layout 中的 view model 資料變數(data variable)。
1
2
3
4
binding?.apply {
viewModel = sharedViewModel
...
}
  1. 在每個 fragment layout 中,如果在 layout 中有顯示 price,則使用 viewModel 變數來設定 price。以修改 fragment_flavor.xml 檔案開始。舉例而言,如果是 subtotal text view,請將 android:text 屬性的值設定為 "@{@string/subtotal_price(viewModel.price)}"。此 data binding layout 運算式使用字串資源 @string/subtotal_price,並傳入參數,亦即來自 view model 的 price,因此輸出會顯示「Subtotal 12.0」(小計 12.0)。
1
2
3
4
5
6
7
8
...

<TextView
android:id="@+id/subtotal"
android:text="@{@string/subtotal_price(viewModel.price)}"
... />

...

您正在使用 strings.xml 檔案所宣告的此字串資源:

1
<string name="subtotal_price">Subtotal %s</string>
  1. 執行應用程式。如果您在 start fragment 中選取「One cupcake」(一個杯子蛋糕),flavor fragment 會顯示「Subtotal 2.0」(小計 2.0)。如果您選取「Six cupcake」(六個杯子蛋糕),flavor fragment 會顯示「Subtotal 12.0」(小計 12.0),以此類推。您稍後會將 price 格式化為適當的貨幣格式,因此此行為目前是正常現象。
  1. 現在針對 pickup 和 summary fragments 進行類似的變更。在 fragment_pickup.xml 和 fragment_summary.xml layouts 中,也修改 text views 以使用 viewModel price 屬性。

fragment_pickup.xml

1
2
3
4
5
6
7
8
9
...

<TextView
android:id="@+id/subtotal"
...
android:text="@{@string/subtotal_price(viewModel.price)}"
... />

...

fragment_summary.xml

1
2
3
4
5
<TextView
android:id="@+id/total"
...
android:text="@{@string/total_price(viewModel.price)}"
... />
  1. 執行應用程式。確認 order summary 中顯示的價格是按 order quantity 1、6 和 12 個杯子蛋糕正確計算所得。如前所述,預期此刻 price 格式不正確 ($2 會顯示為 2.0,$12 會顯示為 12.0)。

當天取貨附加費

在這項工作中,您將導入第二項規則,就是當天取貨會額外收取 $3.00 美元。

  1. 在 OrderViewModel 類別中,針對當天取貨費用定義新 top-level private 常數。
1
private const val PRICE_FOR_SAME_DAY_PICKUP = 3.00
  1. 在 updatePrice() 中,檢查使用者是否選取了當天取貨。檢查 view model (_date.value) 中的日期是否與 dateOptions list 中的第一個項目相同 (current day)。
1
2
3
4
5
6
7
8
9
private fun updatePrice() {
// price = quantity * 每個杯子蛋糕的價錢
_price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE

// 檢查使用者是否選取了當天取貨
if (dateOptions[0] == _date.value) {

}
}
  1. 為了簡化這些計算,請引入 temporary 變數 calculatedPrice。計算更新的價格,然後將其重新指派給 _price.value。
1
2
3
4
5
6
7
8
9
10
11
12
private fun updatePrice() {
// price = quantity * 每個杯子蛋糕的價錢
var calculatedPrice = (quantity.value ?: 0) * PRICE_PER_CUPCAKE

// 檢查使用者是否選取了當天取貨
if (dateOptions[0] == _date.value) {
// 將目前價格加上當天取貨費
calculatedPrice += PRICE_FOR_SAME_DAY_PICKUP
}
// 設 _price 的值為 calculatedPrice
_price.value = calculatedPrice
}
  1. 從 setDate() method 呼叫 updatePrice() helper method,即可指定當天取貨費用。
1
2
3
4
fun setDate(pickupDate: String) {
_date.value = pickupDate
updatePrice()
}
  1. 執行應用程式,navigate 應用程式。您會發現,變更取貨日期不會從 total price 中扣除當天取貨費用。這是因為在 view model 中 price 有所變更,但不會通知 binding layout。

設定 Lifecycle owner 以觀察 LiveData

LifecycleOwner 是一個具有 Android lifecycle 的 class,例如 activity 或 fragment。唯有 lifecycle owner 處於 active 狀態 (STARTED 或 RESUMED),LiveData observer 才會觀測 app data 的變更。

在 app 中,LiveData 物件或可觀察 data 是 view model 中的 price 屬性。lifecycle owners 是flavor、pickup 和 summary fragments。LiveData observers 是 layout 檔案中的 binding 運算式,當中包含可觀察 data,例如 price。透過 Data Binding,可觀察值(observable value)變更時,也會自動更新其 bind 的 UI elements。

binding 運算式範例:

1
android:text="@{@string/subtotal_price(viewModel.price)}"

為了自動更新 UI elements,您必須在 app 中建立 binding.lifecycleOwner 與 lifecycle owners 的關聯。

  1. 在 FlavorFragment、PickupFragment、SummaryFragment class 的 onViewCreated() 方法中,在 binding?.apply 區塊中加入下列內容。這項操作會設定 binding 物件的 lifecycle owner。設定 lifecycle owner,app 將能觀察(observe) LiveData 物件。
1
2
3
4
binding?.apply {
lifecycleOwner = viewLifecycleOwner
...
}
  1. 再次執行應用程式。在 pickup 畫面中,變更取貨日期(pickup date),並留意自動變更 price 方式的差異。且在 summary 畫面中正確反映取貨費用。

  2. 請注意,當您選取當天取貨時,訂單價格會增加「$3.00 美元」。選取未來日期的價格應是「杯子蛋糕數量 x $2.00 美元」。

  1. 使用不同杯子蛋糕 quantities、flavors 和 pickup dates 來測試不同情況。現在,您應該會在每個 fragment 上看到 view model 的更新 price。最棒的是,您不用撰寫額外的 Kotlin 程式碼,就能讓 UI 每次都更新 price。

如要完成 price 功能的導入,您必須將 price 格式設定為當地幣別。

使用 LiveData transformation 將 price 格式化

LiveData transformation 方法可讓您針對 source LiveData 執行資料操縱,並傳回產生的 LiveData 物件。簡單來說,此會將 LiveData 的值轉換為其他值。除非 observer 觀測到 LiveData 物件,否則不會計算這些轉換。

Transformations.map() 其中一種是轉換函式,此方法採用 LiveData 和函式做為參數。函式會操控 source LiveData,並傳回可觀測的更新值。

以下列舉幾個可使用 LiveData transformation 的範例:

  • 設定 date、time strings 的顯示格式
  • 排序 items 的 list
  • 篩選 items 或將 items 分組
  • 從 list 計算結果,例如所有 items 的加總、items 數目、傳回的最後一個 items 等等。

在這項工作中,您必須使用 Transformations.map() 方法將設定 price 的格式為使用當地幣別。這會將原始價格從十進位(decimal)值 (LiveData<Double>) 轉換為字串(string)值 (LiveData<String>)。

  1. 在 OrderViewModel class 中,將 backing property type 變更為 LiveData<String>,而不是 LiveData<Double>。price 的格式將為包含貨幣符號 (例如「$」) 的字串。您將在下一步驟修正初始化錯誤。
1
2
private val _price = MutableLiveData<Double>()
val price: LiveData<String>
  1. 使用 Transformations.map() 初始化新變數,並傳入 _price 和 lambda 函式。請在 NumberFormat class 中使用 getCurrencyInstance() 方法,以將 price 轉換成當地幣別格式(local currency format)。transformation code 看起來會像這樣。
1
2
3
4
private val _price = MutableLiveData<Double>()
val price: LiveData<String> = Transformations.map(_price) {
NumberFormat.getCurrencyInstance().format(it)
}
  • 請 import androidx.lifecycle.Transformations 和 java.text.NumberFormat。
  1. 執行應用程式。現在畫面上應會顯示 subtotal 及 total 的字串格式價格。更容易使用!
  1. 測試是否正常運作。測試範例:訂購一個杯子蛋糕、訂購六個杯子蛋糕、訂購 12 個杯子蛋糕。確認每個螢幕上的價格正確無誤。Flavor 和 Pickup fragments 中應顯示「Subtotal $2.00」,而 order summary 中應顯示「Total $2.00」。也請確認 order summary 顯示正確的 order details。

使用 listener binding 來設定 click listeners

在這項工作中,您將使用 listener binding 來將 fragment classes 中的 button click listeners bind 至 layout。

  1. 在 layout 檔案 fragment_start.xml 中,新增名為 startFragment 且 type 為 com.example.cupcake.StartFragment 的 data variable。確認 fragment 的 package name 與應用程式的 package name 相符。
1
2
3
4
5
6
7
8
9
<layout ...>

<data>
<variable
name="startFragment"
type="com.example.cupcake.StartFragment" />
</data>
...
<ScrollView ...>
  1. 在 StartFragment.kt 的 onViewCreated() 方法中,將新的 data variable bind 至 fragment instance。您可以使用 this 關鍵字來存取 fragment 中的 fragment instance。移除 binding?.apply 區塊以及區塊內的程式碼。已完成的方法應如下所示。
1
2
3
4
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.startFragment = this
}
  1. 在 fragment_start.xml 中,使用 listener binding 新增 event listeners 到 button 的 onClick 屬性,在 startFragment 上呼叫 orderCupcake(),並傳遞杯子蛋糕的數量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Button
android:id="@+id/order_one_cupcake"
android:onClick="@{() -> startFragment.orderCupcake(1)}"
... />

<Button
android:id="@+id/order_six_cupcakes"
android:onClick="@{() -> startFragment.orderCupcake(6)}"
... />

<Button
android:id="@+id/order_twelve_cupcakes"
android:onClick="@{() -> startFragment.orderCupcake(12)}"
... />
  1. 執行應用程式。請注意,start fragment 中的 button click handlers 運作正常。

  2. 同樣地,您也可以在其他 layouts 中加入上述 data variable,以便 bind fragment instance fragment_flavor.xml、fragment_pickup.xml 和 fragment_summary.xml。

在 fragment_flavor.xml 中:

1
2
3
4
5
6
7
8
9
10
11
12
<layout ...>

<data>
<variable
... />

<variable
name="flavorFragment"
type="com.example.cupcake.FlavorFragment" />
</data>

<ScrollView ...>

在 fragment_pickup.xml 中:

1
2
3
4
5
6
7
8
9
10
11
12
<layout ...>

<data>
<variable
... />

<variable
name="pickupFragment"
type="com.example.cupcake.PickupFragment" />
</data>

<ScrollView ...>

在 fragment_summary.xml 中:

1
2
3
4
5
6
7
8
9
10
11
12
<layout ...>

<data>
<variable
... />

<variable
name="summaryFragment"
type="com.example.cupcake.SummaryFragment" />
</data>

<ScrollView ...>
  1. 在其餘 fragment class 中的 onViewCreated() 中,刪除可手動設定 buttons 上 click listener 的程式碼。

  2. 在 onViewCreated() 方法中,會 bind fragment data variable 與 fragment instance。您必須在這裡以不同方式使用 this 關鍵字,因為在 binding?.apply 區塊中,關鍵字 this 是指 binding instance,而不是 fragment instance。使用 @ 並明確指定 fragment class name,例如 this@FlavorFragment。已完成的 onViewCreated() 方法如下所示:

FlavorFragment class 中的 onViewCreated() method 應如下所示:

1
2
3
4
5
6
7
8
9
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

binding?.apply {
lifecycleOwner = viewLifecycleOwner
viewModel = sharedViewModel
flavorFragment = this@FlavorFragment
}
}

PickupFragment class 中的 onViewCreated() method 應如下所示:

1
2
3
4
5
6
7
8
9
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

binding?.apply {
lifecycleOwner = viewLifecycleOwner
viewModel = sharedViewModel
pickupFragment = this@PickupFragment
}
}

在 SummaryFragment class method 中產生的 onViewCreated() method 應如下所示:

1
2
3
4
5
6
7
8
9
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

binding?.apply {
lifecycleOwner = viewLifecycleOwner
viewModel = sharedViewModel
summaryFragment = this@SummaryFragment
}
}
  1. 同樣地,在其他 layout 檔案中,新增 listener binding 運算式至 buttons 的 onClick 屬性。

在 fragment_flavor.xml 中:

1
2
3
4
<Button
android:id="@+id/next_button"
android:onClick="@{() -> flavorFragment.goToNextScreen()}"
... />

在 fragment_pickup.xml 中:

1
2
3
4
<Button
android:id="@+id/next_button"
android:onClick="@{() -> pickupFragment.goToNextScreen()}"
... />

在 fragment_summary.xml 中:

1
2
3
4
<Button
android:id="@+id/send_button"
android:onClick="@{() -> summaryFragment.sendOrder()}"
...>
  1. 執行應用程式以驗證 buttons 是否正常運作。您應該不會發現行為變更,但已使用 listener bindings 設定 click listeners!

恭喜您完成程式碼研究室,打造 Cupcake app!但是應用程式尚未處理完畢。在接下來的程式碼研究室中,您將新增 Cancel button 並修改返回堆疊(backstack)。您也會瞭解什麼是返回堆疊(backstack)和其他新主題。到時見!


總結

  • ViewModel 是 Android 架構元件 的一部分,在設定變更期間會保留儲存在 ViewModel 中的應用程式資料。若要在應用程式中加入 ViewModel,請建立新 class,並從 ViewModel class extend 該 class。
  • 共用 ViewModel 會將應用程式的資料從多個 fragments 儲存在單一 ViewModel 中。應用程式中的多個 fragments 會使用其 activity 範圍(scope)來存取共用的 ViewModel。
  • LifecycleOwner 是一個具有 Android 生命週期的 class,例如 activity 或 fragment。
  • 唯有 lifecycle owner 處於 active 狀態 (STARTED 或 RESUMED),LiveData observer 才會觀察應用程式 data 的變更。
  • Listener bindings 是指在事件發生時 (例如 onClick 事件) 執行的 lambda 運算式。做法類似於 method 引用 (例如 textview.setOnClickListener(clickListener)),但 listener bindings 可讓您執行任意 data binding 運算式。
  • LiveData transformation 方法可讓您針對 source LiveData 執行 data 操縱,並傳回產生的 LiveData 物件。
  • Android frameworks 提供了一個名為 SimpleDateFormat 的 class,該 class 會以區分地區設定方式來格式化(formatting)並剖析(parsing)日期。這允許進行 dates 格式化 (date → text) 及剖析 (text → date)。