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.ktPickupFragment.ktSummaryFragment.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 目的地。建立從 startFragmentflavorFragment 的動作、從 flavorFragmentpickupFragment 的連接,以及從 pickupFragmentsummaryFragment 的連接。如需更詳細的操作說明,請按照下列後續步驟操作。

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

  1. 兩個 fragment 之間的箭頭表示已成功連接,因此您將可以從 startFragment 前往 flavorFragment。這就是「Navigation」動作,您可以在先前的程式碼研究室中學到。
  1. 同樣地,將新增從 flavorFragmentpickupFragment,以及從 pickupFragmentsummaryFragment 的導覽動作(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 (flavorpickup fragment) 中新增 navigation

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

  2. FlavorFragment.ktgoToNextScreen() 方法中,取代顯示 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.ktgoToNextScreen() 方法中,取代現有的程式碼以前往 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):限定於目前 fragmentViewModel instance。不同 fragment 各有所不同。
  • activityViewModels() 範圍(scope):限定於目前 activityViewModel instance。因此,在相同 activity 中的多個 fragment 中,instance 會保持不變
使用 Kotlin property delegate

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

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

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. 針對 FlavorFragmentPickupFragmentSummaryFragment 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 bindingview model data 綁定(bind)至 UI。系統也會根據使用者在 UI 中的選擇,更新共用 view model。

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

用使用者的選擇來更新 flavor

  1. layout/fragment_flavor.xml 中,在根 <layout> 標記內新增 <data> 標記。新增 nameviewModeltypecom.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.xmlfragment_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. PickupFragmentSummaryFragment 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 bindingsevent listeners 新增至 radio buttons。使用不含參數的 lambda 運算式,並呼叫 viewModelsetFlavor() 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)表示 DateTime 格式(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」。

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

  1. OrderViewModel class 中新增名為 getPickupOptions() 的函式,以便建立並回傳(return) pickup dates list。在方法中,建立一個名為 optionsval 變數,然後初始化為 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.SimpleDateFormatjava.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。quantityInt 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,當 Anull 時就返回 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.xmlfragment_pickup.xmlfragment_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.xmlfragment_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 狀態 (STARTEDRESUMED),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.lifecycleOwnerlifecycle owners 的關聯

  1. FlavorFragmentPickupFragmentSummaryFragment 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() 初始化新變數,並傳入 _pricelambda 函式。請在 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.Transformationsjava.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.ktonViewCreated() 方法中,將新的 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 listenersbuttononClick 屬性,在 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.xmlfragment_pickup.xmlfragment_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 variablefragment 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 運算式至 buttonsonClick 屬性。

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)和其他新主題。到時見!


總結

  • ViewModelAndroid 架構元件 的一部分,在設定變更期間會保留儲存在 ViewModel 中的應用程式資料。若要在應用程式中加入 ViewModel,請建立新 class,並從 ViewModel class extend 該 class。
  • 共用 ViewModel 會將應用程式的資料從多個 fragments 儲存在單一 ViewModel 中。應用程式中的多個 fragments 會使用其 activity 範圍(scope)來存取共用的 ViewModel
  • LifecycleOwner 是一個具有 Android 生命週期的 class,例如 activity 或 fragment。
  • 唯有 lifecycle owner 處於 active 狀態 (STARTEDRESUMED),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)