瞭解如何使用共用
ViewModel在相同 activity 的 fragment 之間共用資料,還有LiveData轉換等新概念。
學習目標
- 如何在更進階用途中導入建議的應用程式架構做法
- 如何在 activity 的各個 fragment 中使用共用的
ViewModel - 如何套用
LiveData轉換
建構項目
此杯子蛋糕應用程式會顯示杯子蛋糕的訂單流程,讓使用者選擇杯子蛋糕口味、數量和取貨日期。
範例應用程式總覽
杯子蛋糕應用程式總覽
杯子蛋糕應用程式示範如何設計及導入線上訂購應用程式。本課程結束時,您將完成有下列畫面的杯子蛋糕應用程式。使用者可以選擇杯子蛋糕訂單的數量、口味和其他選項。
下載本程式碼研究室的範例程式碼
本程式碼研究室提供範例程式碼,讓您以本程式碼研究室所教授的功能擴充應用程式。範例程式碼含有您先前在程式碼研究室中熟悉的程式碼。
請注意,如果您從 GitHub 下載範例程式碼,專案的資料夾名稱會是 android-basics-kotlin-cupcake-app-starter。在 Android Studio 中開啟專案時,請選取此資料夾。
範例程式碼逐步操作說明
- 在 Android Studio 中開啟已下載的專案。專案的資料夾名稱為
android-basics-kotlin-cupcake-app-starter。然後執行應用程式。 - 瀏覽檔案以瞭解範例程式碼。針對 layout 檔案,您可以使用右上角的「Split」選項,同時查看 layout 和 XML 的預覽畫面。
- 編譯並執行應用程式時,您會發現應用程式並不完整。除了顯示 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 | class MainActivity : AppCompatActivity() { |
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)
在 Android Studio 的「Project」視窗中,開啟 res > navigation > nav_graph.xml 檔案。如果尚未選取「Design」分頁標籤,請予以選取。
選取後即可開啟「Navigation Editor」,以視覺化方式呈現應用程式中的 navigation graph。您應該會看到應用程式中已有四個 fragments。
連結導覽圖中的 fragment 目的地。建立從
startFragment到flavorFragment的動作、從flavorFragment到pickupFragment的連接,以及從pickupFragment到summaryFragment的連接。如需更詳細的操作說明,請按照下列後續步驟操作。將滑鼠游標懸停在
startFragment上,直到 fragment 周圍顯示灰色框線,且 fragment 右側邊緣的中心顯示灰色圓圈圖示。按一下圓圈並拖曳至flavorFragment,然後放開滑鼠。
- 兩個 fragment 之間的箭頭表示已成功連接,因此您將可以從
startFragment前往flavorFragment。這就是「Navigation」動作,您可以在先前的程式碼研究室中學到。
- 同樣地,將新增從
flavorFragment到pickupFragment,以及從pickupFragment到summaryFragment的導覽動作(navigation actions)。建立 navigation actions 後,完成的 navigation graph 看起來會如下所示。
- 您建立的三個新動作(actions)應該也會顯示在「Component Tree」窗格中。
- 定義 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。
在「Project」視窗中,依序開啟 app > java > com.example.cupcake > StartFragment.kt 檔案。
在
onViewCreated()方法中,請留意, click listeners 設定在三個按鈕上。輕觸各個按鈕時,系統會呼叫orderCupcake()方法,並以杯子蛋糕數量 (1、6 或 12 個杯子蛋糕) 做為參數。
參考 code:
1 | orderOneCupcake.setOnClickListener { orderCupcake(1) } |
- 在
orderCupcake()方法中,將顯示 toast 訊息的程式碼替換成前往 flavor fragment 的程式碼。使用findNavController()方法取得NavController,並呼叫此方法的navigate(),並在傳入動作 IDR.id.action_startFragment_to_flavorFragment。請確認這項動作 ID 與您在nav_graph.xml中宣告的動作相符。
將
1 | fun orderCupcake(quantity: Int) { |
取代為
1 | fun orderCupcake(quantity: Int) { |
- 新增「Import」
importandroidx.navigation.fragment.findNavController,或從 Android Studio 提供的選項中選擇。
在 flavor 和 pickup fragments 中新增 Navigation
這項工作與上一個工作類似,您要在其他 fragment (flavor 和 pickup fragment) 中新增 navigation。
開啟 app > java > com.example.cupcake > FlavorFragment.kt。請注意,「Next」按鈕 click listener 內呼叫的方法為
goToNextScreen()方法。在 FlavorFragment.kt 的
goToNextScreen()方法中,取代顯示 Toast 訊息的程式碼,前往 pickup fragment。使用 action IDR.id.action_flavorFragment_to_pickupFragment並確認此 ID 與nav_graph.xml中宣告的 action 相符。
1 | fun goToNextScreen() { |
- 記得 import
androidx.navigation.fragment.findNavController。
- 同樣地,在 PickupFragment.kt 的
goToNextScreen()方法中,取代現有的程式碼以前往 summary fragment。
1 | fun goToNextScreen() { |
- Import
androidx.navigation.fragment.findNavController。
- 執行應用程式。確認按鈕可供切換畫面瀏覽。每個 fragment 顯示的資訊可能不完整,但請放心,您會在後續步驟中填入正確的 fragment。
更新 app bar 中的標題
瀏覽應用程式時,app bar 中的標題永遠顯示為杯子蛋糕。
根據目前 fragment 的功能提供更相關的標題,有助於改善使用者體驗。
使用 NavController 變更 app bar (也稱為 action bar) 中各 fragment 的標題,並使用「Up」 (←) 按鈕。
在 MainActivity.kt 中覆寫
onCreate()方法來設定 navigation controller。從NavHostFragment取得NavController的 instance。呼叫
setupActionBarWithNavController(navController)以傳入NavController的 instance。操作方式如下:在 app bar 中,根據目的地(destination)的 label 顯示標題;即使沒有頂層(top-level)目的地,也會顯示「Up」 按鈕。
1 | class MainActivity : AppCompatActivity(R.layout.activity_main) { |
- 在 Android Studio 顯示提示時新增必要的 import 項目。
1 | import android.os.Bundle |
設定每個 fragment 的 app bar 標題。開啟
navigation/nav_graph.xml並切換至「Code」分頁標籤。在
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 | <navigation ...> |
- 執行應用程式。請注意,在您前往各個 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 是程式設計的最佳做法。
在 Android Studio 的「Project」視窗中,以滑鼠右鍵按一下「com.example.cupcake」 >「New」 >「Package」。
系統會開啟「New Package」對話方塊,將套件命名為
com.example.cupcake.model。在
modelpackage 之下建立OrderViewModelKotlin class。在「Project」視窗中,以滑鼠右鍵按一下modelpackage,然後選取「New」>「Kotlin File/Class」。在新 dialog 中,給予檔案名稱OrderViewModel。在
OrderViewModel.kt中,變更 class signature 以從ViewModel擴展/延伸。
1 | import androidx.lifecycle.ViewModel |
在
OrderViewModelclass 中,將上述討論的屬性新增為privateval。請將屬性 type 變更為
LiveData,然後在屬性中加入 backing fields,這樣這些屬性就可觀察(observable),當 view model 中的 source data 發生變化時,UI 就會更新。
1 | /* 杯子蛋糕數量 */ |
您必須 import 以下 class:
1 | import androidx.lifecycle.LiveData |
在
OrderViewModelclass 中,新增上述方法。在方法中,傳入可變動屬性(mutable)的參數。由於這些
setter方法需要從 view model 外呼叫,因此請將其保留為public方法,也就是不需要在fun關鍵字之前使用private或其他瀏覽權限修飾符。Kotlin 中的預設瀏覽權限修飾符設定為public。
1 | fun setQuantity(numberCupcakes: Int) { |
- 建構並執行應用程式,確保不會發生編譯錯誤。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 的ViewModelinstance。不同 fragment 各有所不同。activityViewModels()範圍(scope):限定於目前 activity 的ViewModelinstance。因此,在相同 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 | // Syntax for property delegation |
- 在
StartFragmentclass 中,取得共用 view model 的引用做為 class 變數。使用fragment-ktxlibrary 中的by activityViewModels()Kotlin property delegate。
1 | private val sharedViewModel: OrderViewModel by activityViewModels() |
您可能需要 import 以下新資料:
1 | import androidx.fragment.app.activityViewModels |
針對
FlavorFragment、PickupFragment、SummaryFragmentclass 重複上述步驟,您將在程式碼研究室的後續章節中使用此sharedViewModelinstance。返回
StartFragmentclass 後,即可使用 view model。在orderCupcake()方法的開頭,先在共用 view model 中呼叫setQuantity()方法來更新數量,之後再前往 flavor fragment。
1 | fun orderCupcake(quantity: Int) { |
- 在
OrderViewModelclass 中新增下列方法以檢查是否已設定訂單的口味。您將在後續步驟中在StartFragmentclass 中使用此方法。
1 | fun hasNoFlavorSet(): Boolean { |
- 在
StartFragmentclass 中的orderCupcake()方法中,在設定數量後,若未設定口味,則先將預設口味設定為「Vanilla」(香草),之後再前往 flavor fragment。完整的方法看起來會像這樣:
1 | fun orderCupcake(quantity: Int) { |
- 建構 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
- 在
layout/fragment_flavor.xml中,在根<layout>標記內新增<data>標記。新增name為viewModel且type為com.example.cupcake.model.OrderViewModel的 layout 變數。請確認type屬性中的 package name 與 app 內共用 view model classOrderViewModel的 package name 相同。
1 | <layout ...> |
同樣地,請針對
fragment_pickup.xml及fragment_summary.xml重複上率步驟以新增viewModellayout 變數。您將在接下來幾節中使用此變數。fragment_start.xml未使用共用 view model,因此您不需要在此 layout 中加入這段程式碼。在
FlavorFragmentclass 的onViewCreated()中,將 view model instance 與 layout model 中的共用 view model instance 綁定(bind)。在binding?.apply區塊中加入以下程式碼。
1 | binding?.apply { |
套用 scope 函式
這可能是您首次在 Kotlin 中看到 apply 函式。apply 是 Kotlin 標準 library 中的 scope function。會在物件 context 內執行 code block。這樣可以建立臨時範圍(temporary scope),而且您可以在該範圍(scope)中即可存取物件而無需物件名稱。apply 的常見用途是設定物件。這類呼叫可以解讀為「套用下列指派作業(assignments)至物件」。
範例:
1 | clark.apply { |
- 在
PickupFragment和SummaryFragmentclass 中,針對onViewCreated()方法重複上述步驟。
1 | binding?.apply { |
- 在
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。
1 | <RadioGroup |
Listener bindings
Listener bindings 是指在 event 發生 (例如 onClick event) 時執行的 lambda 運算式。做法類似於 method 引用 (例如 textview.setOnClickListener(clickListener)),但 listener bindings 可讓您執行任意 data binding 運算式。
- 在
fragment_flavor.xml中,使用 listener bindings 將 event listeners 新增至 radio buttons。使用不含參數的lambda運算式,並呼叫viewModel。setFlavor()method 藉由傳入對應的 flavor 字串資源(string resource)。
1 | <RadioGroup |
- 執行 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)。
- 在
OrderViewModelclass 中新增名為getPickupOptions()的函式,以便建立並回傳(return) pickup dates list。在方法中,建立一個名為options的val變數,然後初始化為mutableListOf<String>()。
1 | private fun getPickupOptions(): List<String> { |
- 使用
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。
- 取得
Calendarinstance 並 assign 給新的變數(variable),將其設定為val。此變數會包含目前的日期(date)和時間(time)。而且 importjava.util.Calendar。
1 | val calendar = Calendar.getInstance() |
- 建置當天日期(current date)加上後續三個日期(following three dates)的 date list。由於這需要 4 個 date options,請重複此程式碼區塊 4 次。此
repeat區塊會格式化日期(format a date),將其加到 date options list 中,然後將日曆(calendar)增加 1 天。
1 | repeat(4) { |
- 在 method 的結尾 return 更新後的
options。以下是已完成的 method:
1 | private fun getPickupOptions(): List<String> { |
- 在
OrderViewModelclass 中,新增名為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 buttonoption0代表dateOptions[0](今天) - 在
viewModel中,radio buttonoption1代表dateOptions[1](明天) - 在
viewModel中,radio buttonoption2代表dateOptions[2](後天) - 在
viewModel中,radio buttonoption3代表dateOptions[3](大後天)
- 在
fragment_pickup.xml中,針對option0radio button 使用新的 layout 變數viewModel,以便根據 view model 中的date值來設定checked屬性。比較viewModel.date屬性與dateOptionslist 中的第一個字串 (也就是當天日期)。使用equals函式進行比較,最終 binding 運算式如下所示:
1 | @{viewModel.date.equals(viewModel.dateOptions[0])} |
針對相同的 radio button,使用 listener binding 新增 event listener 至
onClick屬性。按一下此按鈕後,viewModel就會呼叫setDate(),傳入dateOptions[0]。如果是相同的 radio button,請將
text屬性值設定為dateOptionslist 中的第一個字串。
1 | <RadioButton |
- 針對其他 radio button 重複上述步驟,並據此變更
dateOptions的索引(index)。
1 | <RadioButton |
- 執行應用程式後,當 pickup options 可用時就會看到未來幾天。螢幕截圖取決於您的目前日期而有所不同。請注意,依據預設不會選取任何選項。您將在下一個步驟中導入此動作。
- 在
OrderViewModelclass 中,建立名為resetOrder()的函式,重設 view model 中的MutableLiveData屬性。將dateOptionslist 中的現在日期值(current date value)指派給_date.value。
1 | fun resetOrder() { |
- 在 class 中新增
init區塊,並呼叫新 methodresetOrder()。
1 | init { |
- 從 class 中的屬性宣告移除初始值(initial value)。現在,您可以在建立
OrderViewModelinstance 時使用init區塊來初始化屬性。
1 | private val _quantity = MutableLiveData<Int>() |
- 再次執行應用程式。請注意,依據預設會選取今天的日期。
更新 summary fragment 以使用 view model
現在介紹最後一個 fragment 。訂單 summary fragment 是用來顯示訂單詳細資料的摘要(summary)。在這項工作中,請妥善利用共用 view model 中的所有訂單資訊,並使用 data binding 來更新螢幕上的訂單詳細資料(order details)。
- 請務必在
fragment_summary.xml中宣告 view model data 變數viewModel。
1 | <layout ...> |
在
SummaryFragment中,在onViewCreated()中確認binding.viewModel已初始化。在
fragment_summary.xml中,從 view model 中讀取,以更新畫面中的 order summary 詳細資料(details)。新增下列text屬性來更新數量(quantity)、口味(flavor)和日期(date) TextViews。quantity 為Inttype,因此您必須將其轉換為String。
1 | <TextView |
1 | <TextView |
1 | <TextView |
- 執行並測試應用程式,確認您所選的訂單選項已顯示在訂單摘要中。
根據 order detail 計算 price
查看本程式碼研究室的最終應用程式螢幕截圖時,您會注意到價格確實顯示在每個片段 (StartFragment 除外) 上,因此使用者在建立訂單時即可得知價格。
以下是我們的杯子蛋糕專賣店如何計算價格的規則。
- 每個杯子蛋糕 $2.00 美元
- 對於當天取貨,訂單中會再增加 $3.00 美元
因此,6 個杯子蛋糕訂單的價格為 6 個杯子蛋糕 x 每個 $2 美元 = $12 美元。如果同一位使用者想要當天取貨,$3 美元的附加費用會讓訂單總價變成 $15 美元。
更新 view model 中的 price
若要在應用程式中支援這項功能,請先處理杯子蛋糕的價格(price),並先忽略當天取貨費用。
- 開啟
OrderViewModel.kt,然後將每杯子蛋糕的價格(price)儲存在變數中。在 class 定義之外 (但在 import 陳述式之後),將此變數宣告在檔案頂端的 top-levelprivate常數。使用const修飾符,並使用val設定為唯讀。
1 | package ... |
- 請記得,常數值 (在 Kotlin 中以
const關鍵字標記) 不會變更,而且會在編譯期間知道該值。若要進一步瞭解常數,請參閱說明文件。
- 您已經定義了每杯子蛋糕的 price,請建立計算 price 的輔助方法。此方法可能是
private,因為只能在此 class 中使用。您會在下一個工作中變更 price 邏輯,以便納入當天取貨費用。
1 | private fun updatePrice() { |
筆記: A ?: B 意思是「當 A 不為 null 時就返回 A,當 A 為 null 時就返回 B」。
這段程式碼會將每杯子蛋糕的 price 乘以所訂購的杯子蛋糕 quantity。關於括號中的程式碼,因為 quantity.value 的值可以是空值,所以使用 elvis 運算子 (?:)。elvis 運算子 (?:) 表示左側運算式不是空值時,則使用該運算子。如果左側運算式是空值,則使用 elvis 運算子右側的運算式 (本例中為 0)。
- 在相同
OrderViewModelclass 中,在設定 quantity 時更新 price 變數。在setQuantity()函式中呼叫新函式。
1 | fun setQuantity(numberCupcakes: Int) { |
將 price 屬性 bind 到 UI
- 在
fragment_flavor.xml、fragment_pickup.xml和fragment_summary.xml的 layouts 中,確認已定義 type 為com.example.cupcake.model.OrderViewModel的 data variableviewModel。
1 | <layout ...> |
- 在每個 fragment class 的
onViewCreated()方法中,請務必將 fragment 中的 view model 物件 instance bind 至 layout 中的 view model 資料變數(data variable)。
1 | binding?.apply { |
- 在每個 fragment layout 中,如果在 layout 中有顯示 price,則使用
viewModel變數來設定 price。以修改fragment_flavor.xml檔案開始。舉例而言,如果是subtotaltext view,請將android:text屬性的值設定為"@{@string/subtotal_price(viewModel.price)}"。此 data binding layout 運算式使用字串資源@string/subtotal_price,並傳入參數,亦即來自 view model 的 price,因此輸出會顯示「Subtotal 12.0」(小計 12.0)。
1 | ... |
您正在使用 strings.xml 檔案所宣告的此字串資源:
1 | <string name="subtotal_price">Subtotal %s</string> |
- 執行應用程式。如果您在 start fragment 中選取「One cupcake」(一個杯子蛋糕),flavor fragment 會顯示「Subtotal 2.0」(小計 2.0)。如果您選取「Six cupcake」(六個杯子蛋糕),flavor fragment 會顯示「Subtotal 12.0」(小計 12.0),以此類推。您稍後會將 price 格式化為適當的貨幣格式,因此此行為目前是正常現象。
- 現在針對 pickup 和 summary fragments 進行類似的變更。在
fragment_pickup.xml和fragment_summary.xmllayouts 中,也修改 text views 以使用viewModelprice屬性。
fragment_pickup.xml
1 | ... |
fragment_summary.xml
1 | <TextView |
- 執行應用程式。確認 order summary 中顯示的價格是按 order quantity 1、6 和 12 個杯子蛋糕正確計算所得。如前所述,預期此刻 price 格式不正確 ($2 會顯示為 2.0,$12 會顯示為 12.0)。
當天取貨附加費
在這項工作中,您將導入第二項規則,就是當天取貨會額外收取 $3.00 美元。
- 在
OrderViewModel類別中,針對當天取貨費用定義新 top-level private 常數。
1 | private const val PRICE_FOR_SAME_DAY_PICKUP = 3.00 |
- 在
updatePrice()中,檢查使用者是否選取了當天取貨。檢查 view model (_date.value) 中的日期是否與dateOptionslist 中的第一個項目相同 (current day)。
1 | private fun updatePrice() { |
- 為了簡化這些計算,請引入 temporary 變數
calculatedPrice。計算更新的價格,然後將其重新指派給_price.value。
1 | private fun updatePrice() { |
- 從
setDate()method 呼叫updatePrice()helper method,即可指定當天取貨費用。
1 | fun setDate(pickupDate: String) { |
- 執行應用程式,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 的關聯。
- 在
FlavorFragment、PickupFragment、SummaryFragmentclass 的onViewCreated()方法中,在binding?.apply區塊中加入下列內容。這項操作會設定 binding 物件的 lifecycle owner。設定 lifecycle owner,app 將能觀察(observe)LiveData物件。
1 | binding?.apply { |
再次執行應用程式。在 pickup 畫面中,變更取貨日期(pickup date),並留意自動變更 price 方式的差異。且在 summary 畫面中正確反映取貨費用。
請注意,當您選取當天取貨時,訂單價格會增加「$3.00 美元」。選取未來日期的價格應是「杯子蛋糕數量 x $2.00 美元」。
- 使用不同杯子蛋糕 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>)。
- 在
OrderViewModelclass 中,將 backing property type 變更為LiveData<String>,而不是LiveData<Double>。price 的格式將為包含貨幣符號 (例如「$」) 的字串。您將在下一步驟修正初始化錯誤。
1 | private val _price = MutableLiveData<Double>() |
- 使用
Transformations.map()初始化新變數,並傳入_price和lambda函式。請在NumberFormatclass 中使用getCurrencyInstance()方法,以將 price 轉換成當地幣別格式(local currency format)。transformation code 看起來會像這樣。
1 | private val _price = MutableLiveData<Double>() |
- 請 import
androidx.lifecycle.Transformations和java.text.NumberFormat。
- 執行應用程式。現在畫面上應會顯示 subtotal 及 total 的字串格式價格。更容易使用!
- 測試是否正常運作。測試範例:訂購一個杯子蛋糕、訂購六個杯子蛋糕、訂購 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。
- 在 layout 檔案
fragment_start.xml中,新增名為startFragment且 type 為com.example.cupcake.StartFragment的 data variable。確認 fragment 的 package name 與應用程式的 package name 相符。
1 | <layout ...> |
- 在
StartFragment.kt的onViewCreated()方法中,將新的 data variable bind 至 fragment instance。您可以使用this關鍵字來存取 fragment 中的 fragment instance。移除binding?.apply區塊以及區塊內的程式碼。已完成的方法應如下所示。
1 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
- 在
fragment_start.xml中,使用 listener binding 新增 event listeners 到 button 的onClick屬性,在startFragment上呼叫orderCupcake(),並傳遞杯子蛋糕的數量。
1 | <Button |
執行應用程式。請注意,start fragment 中的 button click handlers 運作正常。
同樣地,您也可以在其他 layouts 中加入上述 data variable,以便 bind fragment instance
fragment_flavor.xml、fragment_pickup.xml和fragment_summary.xml。
在 fragment_flavor.xml 中:
1 | <layout ...> |
在 fragment_pickup.xml 中:
1 | <layout ...> |
在 fragment_summary.xml 中:
1 | <layout ...> |
在其餘 fragment class 中的
onViewCreated()中,刪除可手動設定 buttons 上 click listener 的程式碼。在
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 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
PickupFragment class 中的 onViewCreated() method 應如下所示:
1 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
在 SummaryFragment class method 中產生的 onViewCreated() method 應如下所示:
1 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
- 同樣地,在其他 layout 檔案中,新增 listener binding 運算式至 buttons 的
onClick屬性。
在 fragment_flavor.xml 中:
1 | <Button |
在 fragment_pickup.xml 中:
1 | <Button |
在 fragment_summary.xml 中:
1 | <Button |
- 執行應用程式以驗證 buttons 是否正常運作。您應該不會發現行為變更,但已使用 listener bindings 設定 click listeners!
恭喜您完成程式碼研究室,打造 Cupcake app!但是應用程式尚未處理完畢。在接下來的程式碼研究室中,您將新增 Cancel button 並修改返回堆疊(backstack)。您也會瞭解什麼是返回堆疊(backstack)和其他新主題。到時見!
總結
ViewModel是 Android 架構元件 的一部分,在設定變更期間會保留儲存在ViewModel中的應用程式資料。若要在應用程式中加入ViewModel,請建立新 class,並從ViewModelclass extend 該 class。- 共用
ViewModel會將應用程式的資料從多個 fragments 儲存在單一ViewModel中。應用程式中的多個 fragments 會使用其 activity 範圍(scope)來存取共用的ViewModel。 LifecycleOwner是一個具有 Android 生命週期的 class,例如 activity 或 fragment。- 唯有 lifecycle owner 處於 active 狀態 (
STARTED或RESUMED),LiveDataobserver 才會觀察應用程式 data 的變更。 - Listener bindings 是指在事件發生時 (例如
onClick事件) 執行的 lambda 運算式。做法類似於 method 引用 (例如textview.setOnClickListener(clickListener)),但 listener bindings 可讓您執行任意 data binding 運算式。 LiveDatatransformation 方法可讓您針對 sourceLiveData執行 data 操縱,並傳回產生的LiveData物件。- Android frameworks 提供了一個名為
SimpleDateFormat的 class,該 class 會以區分地區設定方式來格式化(formatting)並剖析(parsing)日期。這允許進行 dates 格式化 (date → text) 及剖析 (text → date)。