瞭解如何使用
ViewModel架構元件來儲存應用程式資料。如果在設定變更或其他事件期間,刪除架構並重新建立 activity 和 fragment,儲存的資料不會遺失。
學習目標
- Android 應用程式架構基本概念簡介。
- 如何在應用程式中使用
ViewModel類別。 - 如何使用
ViewModel,透過裝置設定變更保留 UI 資料。 - Kotlin 的幕後屬性。
- 如何使用質感設計元件庫中的
MaterialAlertDialog。 - 建立
Unscramble遊戲應用程式,可讓使用者猜測打散的字詞。
範例應用程式總覽
遊戲總覽
Unscramble 應用程式為單人字詞重組遊戲。本應用程式一次會顯示一個打散的字詞,且玩家必須使用打散的所有字母猜出這個字詞。只要字詞正確無誤,玩家即可得分,否則玩家可任意進行嘗試。應用程式也具備略過目前字詞的選項。應用程式左上角會顯示字詞計數,也就是目前遊戲中已遊玩過的字詞數。每場遊戲共有 10 字。
下載範例程式碼
如果您使用 GitHub 中的範例程式碼,請注意資料夾名稱是 android-basics-kotlin-unscramble-app-starter。在 Android Studio 中開啟專案時,請選取這個資料夾。
範例程式碼總覽
- 在 Android Studio 中開啟含有範例程式碼的專案。
- 在 Android 裝置或模擬器上執行應用程式。
- 透過數個字詞進行遊戲,請輕觸「提交」和「略過」按鈕。 請注意,輕觸按鈕時會顯示下一個字詞,並增加字詞計數。
- 請留意,分數只會在輕觸「Submit」按鈕時提升。
範例程式碼相關問題
玩遊戲時,您可能已注意到下列錯誤:
- 按一下「Submit」按鈕時,應用程式不會檢查玩家的字詞。玩家總是可以得分。
- 無法結束遊戲。應用程式可讓您遊玩超過 10 個字詞。
- 遊戲畫面會顯示打散的字詞、玩家分數和字詞計數。旋轉裝置或模擬器變更螢幕方向。請注意,目前的字詞、分數和字詞計數都會消失,遊戲也會重新開始。
應用程式的主要問題
設定變更時 (例如裝置螢幕方向變更),範例應用程式不會儲存及還原應用程式狀態和資料。
您可以使用 onSaveInstanceState() callback 解決此問題。不過,使用 onSaveInstanceState() 方法時,您必須編寫額外的程式碼將狀態儲存在套件中,並實作邏輯以擷取該狀態。此外,可儲存的資料量極少。
您可以使用在本課程所學到的 Android 架構元件來解決這些問題。
範例程式碼逐步操作說明
您下載的範例程式碼包含已為您預先設計的遊戲畫面版面配置。本課程重點為實作遊戲邏輯。您需要使用架構元件來實作建議的應用程式架構,並解決上述問題。以下是部分檔案的簡要逐步操作說明,可協助您快速上手。
game_fragment.xml
- 在「設計」檢視畫面中開啟
res/layout/game_fragment.xml。 - 這包含應用程式中唯一畫面的版面配置,也就是遊戲畫面。
- 此版面配置包含玩家字詞的文字欄位,以及顯示分數和字詞計數的
TextViews。另外還提供說明、「Submit」按鈕和「Skip」按鈕,方便玩遊戲。
main_activity.xml
以單一遊戲片段定義主要 activity 版面配置。
res/values 資料夾
colors.xml包含應用程式中使用的主題色彩strings.xml包含應用程式所需的所有字串themes和styles資料夾內含應用程式的 UI 自訂項目
MainActivity.kt
包含預設範本產生的程式碼,可將 activity 的內容檢視畫面設為 main_activity.xml。
ListOfWords.kt
此檔案內含遊戲中使用的字詞清單、每場遊戲字詞數量上限,以及玩家針對每個正確字詞所得分數的常數。
GameFragment.kt
這是應用程式中唯一的 fragment,也是大部分遊戲動作發生處:
- 變數是根據目前打散的字詞 (
currentScrambledWord)、字詞計數 (currentWordCount) 和分數 (score) 所定義。 - 已定義可存取名為
binding之game_fragment檢視畫面的 binding 物件 instance。 onCreateView()函式會使用 binding 物件加載game_fragment版面配置 XML。onViewCreated()函式會設定按鈕點選監聽器,並更新 UI。onSubmitWord()是「提交」按鈕的點擊事件監聽器,此函式會顯示下一個打散的字詞、清除文字欄位,並在未驗證玩家字詞的情況下增加分數和字詞計數。onSkipWord()是「略過」按鈕的點擊事件監聽器,此函式會更新與onSubmitWord()類似的 UI (分數除外)。getNextScrambledWord()是一項輔助函式,其可從字詞清單中挑選隨機字詞,並隨機排序這些字母。- 系統會分別使用
restartGame()和exitGame()函式重新啟動及結束遊戲,您稍後將會使用這些函式。 setErrorTextField()可清除文字欄位內容,並重設錯誤狀態。updateNextWordOnScreen()函式可顯示新的打散字詞。
瞭解應用程式架構
架構可提供相關規範,協助您在應用程式內分配類別間的責任。設計良好的應用程式架構可協助您擴大應用程式,並於日後擴充其他功能。此外,也能讓團隊更輕鬆進行協作。
最常見的架構原則為:關注點分離,以及透過模型使用 UI。
關注點分離
關注點分離的設計原則為,應用程式應區分成不同類別,每個類別具有不同責任。
透過模型使用 UI
另一個重要原則是,您應透過模型 (建議為持續性模型) 使用 UI。模型是負責處理應用程式資料的元件。模型與應用程式中的 Views 和應用程式元件無關,因此不受應用程式的生命週期和相關關注點影響。
Android 架構中的主要類別或元件包括 UI controller (activity/fragment)、ViewModel、LiveData 和 Room。這些元件負責生命週期的部分複雜度,且可避免發生生命週期相關問題。您將在後續的程式碼研究室中學習 LiveData 和 Room。
下圖為架構的基本部分:
UI Controller (activity/fragment)
activity 和 fragment 為 UI controller。UI controller 可控制 UI,方法包括在畫面產生 view、擷取使用者事件,以及與使用者互動之 UI 相關的任何其他內容。App 中的資料或與這些資料相關的決策邏輯不應屬於 UI controller 類別。
Android 系統可能會因為特定使用者互動或記憶體不足等系統情況,而隨時刪除 UI controller。由於這些事件不在您的控管之下,您不應在 UI controller 中儲存任何應用程式資料或狀態。反之,應該在 ViewModel 中新增資料相關的決策邏輯。
舉例來說,Unscramble 應用程式中的打散字詞、分數和字詞計數會顯示於 fragment (UI controller) 中。決策程式碼應位於 ViewModel 中,例如判斷下一個打散的字詞,以及分數和字詞計數的計算。
ViewModel
ViewModel 是 view 中顯示的應用程式資料(data)模型。模型是負責處理應用程式資料的元件。其可讓您的應用程式遵循透過模型使用 UI 的架構原則。
activity 或 fragment 遭到 Android 架構刪除並重新建立時,未刪除的應用程式相關資料會由 ViewModel 進行儲存。在設定變更期間,系統會自動保留 ViewModel 物件 (不會像 activity 或 fragment instance一般遭到刪除),讓處於保留狀態的資料立即用於下一個 activity 或 fragment instance。
如要在應用程式中實作 ViewModel,請擴充架構元件庫中的 ViewModel 類別,並將應用程式資料儲存在該類別中。
★總結:
| fragment/activity (UI controller) 責任 | ViewModel 責任 |
|---|---|
| activity 和 fragment 應負責在畫面中產生 view 和資料(data) ,並回應使用者事件。 | ViewModel 負責保留及處理 UI 所需的所有資料。其不得存取 view 階層 (例如 view binding 物件),或保留 activity/fragment 的引用。 |
新增 ViewModel
在這項工作中,您需將 ViewModel 新增至應用程式,以儲存應用程式資料 (打散的字詞、字詞計數和分數)。
您的應用程式架構如下。MainActivity 包含 GameFragment,而 GameFragment 會從 GameViewModel 存取遊戲的相關資訊。
在 Android Studio 中,「Android」 視窗的「Gradle Scripts」資料夾下,開啟
build.gradle(Module:Unscramble.app)檔案。如要在應用程式中使用
ViewModel,請確認dependencies區塊中具有 ViewModel 程式庫依附元件。此步驟已經完成。視程式庫的最新版本而定,所產生程式碼中的程式庫版本編號可能有所不同。
1 | // ViewModel |
- 建立名為
GameViewModel的新 Kotlin 類別檔案。在「Android」視窗中,於「ui.game」資料夾上按一下滑鼠右鍵。選取「New」>「Kotlin File/Class」。
輸入名稱
GameViewModel,然後從清單中選取「Class」。將
GameViewModel變更為ViewModel的子類別。ViewModel為抽象類別,因此您必須將其擴充,才能在應用程式中使用。請參閱下方的GameViewModel類別定義。
1 | class GameViewModel : ViewModel() { |
將 ViewModel 附加至 fragment
如要建立 ViewModel 與 UI controller (activity/fragment) 的關聯,請在 UI controller 內建立 ViewModel 的引用 (物件)。
在這個步驟中,您會在對應的 UI controller (GameFragment) 中建立 GameViewModel 的 object instance。
- 在
GameFragment類別頂部新增GameViewModel類型的屬性。 - 使用
by viewModels()Kotlin 屬性委派功能將GameViewModel初始化。您將在下一節深入瞭解。
1 | private val viewModel: GameViewModel by viewModels() |
- 如果 Android Studio 顯示提示,請匯入
androidx.fragment.app.viewModels。
Kotlin 屬性委派
在 Kotlin 中,每個可變動 (var) 屬性都會自動產生屬性的 getter 和 setter 函式。當您指派值或讀取屬性值時,系統將會呼叫 setter 和 getter 函式。
唯讀屬性 (val) 與可變動屬性稍有不同。根據預設,只會產生 getter 函式。讀取唯讀屬性的值時,系統會呼叫 getter 函式。
- Kotlin 中的屬性委派功能可協助您將
getter-setter責任移交給其他類別。 - 此類別 (稱為「委派類別」) 可提供屬性的
getter和setter函式,並處理其變更。
委派屬性是使用 by 子句和委派類別 instance 來定義:
1 | // Syntax for property delegation |
在應用程式中,如使用預設的 GameViewModel 建構函式初始化 view model,則如下所示:
1 | private val viewModel = GameViewModel() |
裝置經過設定變更後,應用程式將失去 viewModel 引用的狀態。舉例來說,如果您旋轉裝置,系統就會刪除並重新建立 activity,而您將再次擁有具備初始狀態的新 view model。
請改用屬性委派方法,並將 viewModel 物件的責任委派給另一個名為 viewModels 的類別。這代表當您存取 viewModel 物件時,該物件會由委派類別 viewModels 於內部進行處理。委派類別會在第一次存取時為您建立 viewModel 物件,並透過設定變更保留其值,並在要求時傳回該值。
將資料移至 ViewModel
將應用程式的 UI data 與 UI controller (Activity / Fragment 類別) 分離,以便您充分遵循上述單一責任原則。您的 activity 和 fragment 負責在畫面中產生 view 和 data,ViewModel 則負責保留及處理 UI 所需的所有資料。
在這項工作中,您必須將資料變數從 GameFragment 移至 GameViewModel 類別。
- 將資料變數
score、currentWordCount、currentScrambledWord移至GameViewModel類別。
1 | class GameViewModel : ViewModel() { |
- 請注意未解決的參照錯誤。這是因為屬性僅供
ViewModel使用,且無法由 UI 控制器進行存取。您將在下一個步驟修正這些錯誤。
如要解決這個問題,屬性的可見度修飾符不得為 public,資料不可由其他類別編輯。此操作具有風險,因為外部類別可能會以非預期的方式,變更未遵循檢視模式中指定遊戲規則的資料。舉例來說,外部類別可以將 score 變更為負值。
ViewModel 內的資料應可編輯,因此應為 private 和 var。在 ViewModel 外部,資料應可供讀取,但無法編輯,因此資料應以 public 和 val 的形式呈現。為了達成這個行為,Kotlin 提供名為幕後屬性(Backing properties)的功能。
幕後屬性
幕後屬性可讓您從 getter 傳回確切物件以外的項目。
您已瞭解 Kotlin 架構會為每個屬性產生 getter 和 setter。getter 和 setter 方法可覆寫此類方法 (一或兩種),並提供您自訂的行為。如要實作幕後屬性,您將會覆寫 getter 方法,以傳回唯讀資料版本。幕後屬性範例:
1 | // Declare private mutable variable that can only be modified |
舉例來說,假設您想在應用程式內,將應用程式資料設為僅供 ViewModel 使用:
在 ViewModel 類別中:
_count屬性為private,且可變動。因此,只能在ViewModel類別中進行存取及編輯。慣例是在private屬性字首加上底線。
ViewModel 類別外:
- Kotlin 中的預設瀏覽權限修飾符為
public,因此count是公開狀態,且可從 UI controller 等其他類別存取。由於只有get()方法遭到覆寫,因此這個屬性不可變動且為唯讀。外部類別存取這個屬性時,系統會傳回_count的值,且該值無法修改。這種做法可確保ViewModel中的應用程式資料不會受到外部類別非必要和不安全的變更,但可讓外部呼叫者安全地存取其值。
將幕後屬性新增至 currentScrambledWord
- 在
GameViewModel中,變更currentScrambledWord宣告以新增幕後屬性。目前您只能在GameViewModel中存取及編輯_currentScrambledWord。UI controllerGameFragment可以使用唯讀屬性currentScrambledWord讀取其值。
1 | private var _currentScrambledWord = "test" |
- 在
GameFragment中,更新updateNextWordOnScreen()方法,以使用唯讀的viewModel屬性currentScrambledWord。
1 | private fun updateNextWordOnScreen() { |
- 在
GameFragment中,刪除onSubmitWord()和onSkipWord()方法中的程式碼。您將於稍後實作這些方法。您現在應該能夠正確編譯程式碼,而不會產生錯誤。
ViewModel 的生命週期
只要 activity 或 fragment 的範圍保持運作,該架構就會使 ViewModel 保持運作。如果 ViewModel 的擁有者因設定變更 (例如螢幕旋轉) 而遭到刪除,系統並不會將其刪除。擁有者的新 instance 會重新連線至現有的 ViewModel instance,如下圖所示:
瞭解 ViewModel 生命週期
在 GameViewModel 和 GameFragment 中新增記錄功能,以進一步瞭解 ViewModel 的生命週期。
- 在
GameViewModel.kt中,新增含有記錄陳述式的init區塊。
1 | class GameViewModel : ViewModel() { |
- Kotlin 會提供初始化器區塊 (也稱為
init區塊),做為物件 instance 初始化期間,所需初始設定程式碼的位置。初始化器區塊的前面會加上init關鍵字,後為大括號 {}。這個程式碼區塊會在首次建立並初始化物件 instance 時執行。
在
GameViewModel類別中,覆寫onCleared()方法(Control+o)。在您卸離相關 fragment 或 activity 完成後,系統會將ViewModel刪除。在ViewModel刪除之前,系統會呼叫onCleared()callback。在
onCleared()中新增記錄(log)陳述式,以追蹤GameViewModel生命週期。
1 | override fun onCleared() { |
- 在
GameFragment的onCreateView()中找到 binding 物件引用後,請新增 log 陳述式以記錄 fragment 的建立作業。初次建立及每次重新建立 (例如設定變更等事件) fragment 時,系統會觸發onCreateView()callback。
1 | override fun onCreateView( |
- 在
GameFragment中覆寫onDetach()callback 方法,以在對應的 activity 和 fragment 刪除時呼叫此方法。
1 | override fun onDetach() { |
- 在 Android Studio 中執行應用程式,開啟「Logcat」視窗,然後篩選
GameFragment。請注意,GameFragment和GameViewModel已建立。
1 | com.example.android.unscramble D/GameFragment: GameFragment created/re-created! |
- 在裝置或模擬器上啟用自動旋轉設定,並變更螢幕方向數次。每次都會刪除並重新建立
GameFragment,但GameViewModel只會建立一次,而且不會在每次呼叫時重新建立或刪除。
1 | com.example.android.unscramble D/GameFragment: GameFragment created/re-created! |
- 離開遊戲,或使用返回箭頭離開應用程式。
GameViewModel已刪除,並呼叫onCleared()callback。GameFragment已刪除。
1 | com.example.android.unscramble D/GameFragment: GameViewModel destroyed! |
填入 ViewModel
在這項工作中,您將使用輔助方法進一步填入 GameViewModel,以便取得下一個字詞、驗證玩家的字詞能否增加分數,並檢查字詞計數來結束遊戲。
延遲初始化
通常在宣告變數時,您必須先提供初始值。不過,如果您還沒準備好指派值,可以稍後再進行初始化。為了延遲在 Kotlin 中初始化屬性,您可以使用關鍵字 lateinit,表示延遲初始化。如果您確保在使用前先初始化屬性,可以使用 lateinit 宣告屬性。記憶體必須先初始化,才能分配給變數。如果您在初始化之前就嘗試存取變數,應用程式將會異常終止。
取得下一個字詞
在 GameViewModel 類別中建立 getNextWord() 方法,且具備下列功能:
- 從
allWordsList取得隨機字詞,並將其指派給currentWord. - 將
currentWord中的字母打散,以產生打散的字詞,並將其指派給currentScrambledWord - 處理打散與未打散字詞相同的情形。
- 請確定您在遊戲期間不會重複出現相同的字詞。
請在 GameViewModel 類別中執行下列步驟:
- 於
GameViewModel, 中,新增MutableList<String>類型的新類別變數 (名為wordsList),以保留遊戲中使用的字詞清單,避免重複出現。 - 新增另一個名為
currentWord的類別變數,以保留玩家嘗試重組的字詞。由於您稍後會初始化此屬性,請使用lateinit關鍵字。
1 | private var wordsList: MutableList<String> = mutableListOf() |
- 在
init區塊上新增名為getNextWord()的新private方法,且無不會傳回任何內容的參數。 - 從
allWordsList取得隨機字詞,並將其指派給currentWord。
1 | private fun getNextWord() { |
- 在
getNextWord()中,將currentWord字串轉換為字元陣列,並將其指派給名為tempWord的新val。如要打散字詞,請使用 Kotlin 方法shuffle()隨機變換此陣列中的字元。
1 | val tempWord = currentWord.toCharArray() |
Array與MutableList相似,但其初始化時有固定的大小。Array無法展開或縮減大小 (您必須複製陣列才能調整大小),而MutableList具有add()和remove()函式,因此可以調整大小。
- 有時,隨機變換後的字元順序會與原始字詞相同。在要隨機變換的呼叫周圍加上下列
while迴圈,以在打散字詞不同於原始字詞前持續進行迴圈。
1 | while (String(tempWord).equals(currentWord, false)) { |
- 新增
if-else區塊,以確認是否已使用字詞。如果wordsList包含currentWord,請呼叫getNextWord()。如果沒有,請以剛打散的字詞更新_currentScrambledWord值、增加字詞計數,並將新字詞新增至wordsList。
1 | if (wordsList.contains(currentWord)) { |
- 以下是完整的
getNextWord()方法,供您參考。
1 | /* |
延遲初始化 currentScrambledWord
現在您已建立 getNextWord() 方法,以取得下一個打散的字詞。初次初始化 GameViewModel 時,系統會呼叫此方法。使用 init 區塊初始化類別中的 lateinit 屬性 (例如目前字詞)。如此一來,畫面上顯示的第一個字詞會是打散的字詞,而不是「test」。
- 執行應用程式。請注意,第一個字詞一律為「test」。
- 如要在應用程式起始處顯示打散的字詞,請呼叫
getNextWord()方法,藉此讓系統更新currentScrambledWord。呼叫GameViewModelinit區塊中的getNextWord()方法。
1 | init { |
- 在
_currentScrambledWord屬性中加入lateinit修飾符。由於未提供初始值,請明確提及String資料類型。
1 | private lateinit var _currentScrambledWord: String |
- 執行應用程式。請注意,應用程式啟動時會顯示新的打散字詞。太棒了!
新增 Helper 方法
接下來,請加入 Helper 方法來處理和修改 ViewModel 中的資料。您將在後續工作中使用此方法。
在 GameViewModel 類別中,新增另一個 nextWord(). 方法。接著從清單中取得下一個字詞,並在字詞計數少於 MAX_NO_OF_WORDS 時傳回 true。
1 | /* |
對話方塊
在範例程式碼中,即使已遊玩 10 個字詞,遊戲也不會結束。請修改應用程式,在使用者遊玩 10 個字詞後結束遊戲,並顯示含有最終分數的對話方塊。使用者還可以選擇重新遊玩或離開遊戲。
這是您初次在應用程式中新增對話方塊。對話方塊是一個小視窗 (畫面),可提示使用者做出決定或輸入額外資訊。一般而言,如果對話方塊未填滿整個畫面,則使用者必須執行操作才能繼續操作。Android 提供不同類型的對話方塊。在本程式碼研究室中,您將瞭解「快訊對話方塊」。
快訊對話方塊剖析
- 快訊對話方塊
- 標題 (選填)
- 訊息
- 文字按鈕
實作最終分數對話方塊
使用質感設計元件庫中的 MaterialAlertDialog,在應用程式中加入符合質感設計指南的對話方塊。由於對話方塊與 UI 相關,因此 GameFragment 將負責建立並顯示最終分數對話方塊。
- 首先,在
score變數中新增幕後屬性。在GameViewModel中,將score變數宣告變更為以下內容。
1 | private var _score = 0 |
- 在
GameFragment中,新增名為showFinalScoreDialog()的私人函式。如要建立MaterialAlertDialog,請使用MaterialAlertDialogBuilder類別逐步建立對話方塊的內容。使用 fragment 的requireContext()方法呼叫傳遞內容的MaterialAlertDialogBuilder建構函式。requireContext()方法會傳回非空值的Context。
1 | /* |
- 顧名思義,
Context是指應用程式、activity 或 fragment 的結構定義或目前狀態。其包含與 activity、fragment 或應用程式相關的資訊。其通常用於存取資源、資料庫和其他系統服務。在這個步驟中,您必須傳遞 fragment 結構定義,以建立快訊對話方塊。 - 如果 Android Studio 顯示提示,請
importcom.google.android.material.dialog.MaterialAlertDialogBuilder。
- 加入程式碼以設定快訊對話方塊的標題,請使用
strings.xml的字串資源。
1 | MaterialAlertDialogBuilder(requireContext()) |
- 設定訊息以顯示最終分數,並使用先前新增的分數變數 (
viewModel.score) 唯讀版本。
1 | .setMessage(getString(R.string.you_scored, viewModel.score)) |
使用
setCancelable()方法並傳遞false,使快訊對話方塊在按下返回鍵時無法取消。使用
setNegativeButton()和setPositiveButton()方法新增「離開」和「再玩一次」兩個文字按鈕。從 lambda 分別呼叫exitGame()和restartGame()。
1 | .setNegativeButton(getString(R.string.exit)) { _, _ -> |
- 這個語法對您來說可能較陌生,但其為
setNegativeButton(getString(R.string.exit), { _, _ -> exitGame()})的簡寫,其中setNegativeButton()方法會納入兩個參數:String及可用 lambda 表示的DialogInterface.OnClickListener()函式。如果傳入的最後一個引數是函式,您可以將 lambda 運算式放在括號外。這就是所謂的結尾 lambda 語法。系統接受這兩種程式碼編寫方式 (lambda 位於括號內或外)。這同樣適用於setPositiveButton函式。
- 最後加入
show(),即可建立並顯示快訊對話方塊。
1 | .show() |
- 以下是完整的
showFinalScoreDialog()方法,供您參考。
1 | /* |
實作「Submit」按鈕的 OnClickListener
在這項工作中,您要使用 ViewModel 和新增的快訊對話方塊,實作「Submit」按鈕點擊事件監聽器的遊戲邏輯。
顯示打散的字詞
- 如果您尚未在
GameFragment中刪除onSubmitWord()內的程式碼 (輕觸「Submit」按鈕時會呼叫此程式碼),請先完成這項操作。 - 請在
viewModel.nextWord()方法的傳回值新增檢查。如果為true,則可以使用其他字詞,因此請使用updateNextWordOnScreen()更新畫面上打散的字詞。否則遊戲將會結束,並顯示含有最終分數的快訊對話方塊。
1 | private fun onSubmitWord() { |
執行應用程式!使用一些字詞進行遊戲。別忘了,您尚未實作「Skip」按鈕,因此無法略過該字詞。
請注意,文字欄位不會更新,因此玩家必須手動刪除上一個字詞。快訊對話方塊中的最終分數永遠為零。您將在後續步驟中修正這些錯誤。
新增 Helper 方法以驗證玩家字詞
- 在
GameViewModel中,新增名為increaseScore()的新私人方法,且不含參數和傳回值。透過SCORE_INCREASE將score變數提高。
1 | private fun increaseScore() { |
在
GameViewModel中,新增名為isUserWordCorrect()的 Helper 方法,其會傳回Boolean並將玩家字詞String做為參數。在
isUserWordCorrect()中驗證玩家的字詞,如果答案正確無誤,則增加分數。這會更新快訊對話方塊中的最終分數。
更新文字欄位
顯示文字欄位中的錯誤
針對 Material 文字欄位,TextInputLayout 內建能顯示錯誤訊息的功能。舉例來說,在下列文字欄位中,標籤顏色有所變更、顯示錯誤圖示、顯示錯誤訊息等。
如要在文字欄位中顯示錯誤,您可以在程式碼中以動態方式設定錯誤訊息,或在版面配置檔案中以靜態方式設定錯誤訊息。設定及重設程式碼中錯誤的範例如下:
1 | // Set error text |
在範例程式碼中,您會發現已定義 setErrorTextField(error: Boolean) Helper 方法,以協助您設定及重設文字欄位中的錯誤。根據是否要在文字欄位中顯示錯誤,使用 true 或 false 做為輸入參數,呼叫此方法。
範例程式碼中的程式碼片段
1 | private fun setErrorTextField(error: Boolean) { |
在這項工作中,您會實作 onSubmitWord() 方法。使用者提交字詞時,您可透過檢查原始字詞,驗證使用者的答案。如果字詞正確無誤,請前往下一個字詞 (如果遊戲已結束,則顯示對話方塊)。如果字詞有誤,請在文字欄位中顯示錯誤,並繼續使用目前的字詞。
- 在
GameFragment中(onSubmitWord()開始的部分),建立名為playerWord的val。從binding變數的文字欄位中擷取文字,將玩家的字詞儲存在其中。
1 | private fun onSubmitWord() { |
在
onSubmitWord()中的playerWord宣告下方,驗證玩家的字詞。新增 if 陳述式,以使用isUserWordCorrect()方法檢查玩家的字詞,並傳入playerWord。在 if 區塊中,重設文字欄位,呼叫
setErrorTextField傳入false。將現有程式碼移至 if 區塊中。
1 | private fun onSubmitWord() { |
- 如果使用者字詞不正確,請在文字欄位中顯示錯誤訊息。將 else 區塊新增至上述 if 區塊,並呼叫
setErrorTextField()傳入true。已完成的onSubmitWord()方法應如下所示:
1 | private fun onSubmitWord() { |
- 執行應用程式,並透過一些字詞進行遊戲。如果玩家的字詞正確無誤,按一下「Submit」按鈕即可清除字詞,否則系統會顯示「Try again!」的訊息。請注意,「略過」按鈕目前仍未運作。您將在下一個工作中加入此實作。
實作略過按鈕
在這項工作中,您要新增 onSkipWord() 實作,用於處理使用者輕觸「Skip」按鈕時的情況。
- 與
onSubmitWord()類似,請在onSkipWord()方法中新增條件。如為true,請在畫面上顯示文字並重設文字欄位。如為false,且這回合沒有其他字詞,則顯示含有最終分數的快訊對話方塊。
1 | /* |
- 執行您的應用程式。遊玩遊戲。請注意,「略過」和「提交」按鈕可正常運作。非常好!
確認 ViewModel 將會保留資料
在這項工作中,於 GameFragment 中新增 log,以觀察在設定變更期間,您的應用程式資料是否會保留在 ViewModel 中。如要存取 GameFragment 中的 currentWordCount,您必須使用幕後屬性公開唯讀版本。
在
GameViewModel中,在currentWordCount變數上按一下滑鼠右鍵,然後選取「Refactor」>「Rename…」。在新名稱前加上底線_currentWordCount。新增支援欄位。
1 | private var _currentWordCount = 0 |
- 在
GameFragment內的onCreateView()中,在回傳敘述上方新增另一個 log,以列印應用程式資料、字詞、分數和字詞計數。
1 | Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " + |
- 在 Android Studio 中,開啟「Logcat」,並篩選
GameFragment。執行應用程式,使用一些字詞進行遊戲。變更裝置的螢幕方向。fragment (UI controller) 會刪除並重新建立。觀察記錄。您現在可以看到分數和字詞計數增加!
1 | com.example.android.unscramble D/GameFragment: GameFragment created/re-created! |
- 請注意,螢幕方向變更時,應用程式資料會保存在
ViewModel中。您將在後續的程式碼研究室中使用LiveData和 data biniding,更新 UI 上的分數值和字詞計數。
更新遊戲重新啟動邏輯
再次執行應用程式,使用所有字詞進行遊戲。在「Congratulations!」快訊對話方塊中,按一下「PLAY AGAIN」。由於字詞計數現已達到
MAX_NO_OF_WORDS值,因此應用程式無法讓您再玩一次。您必須將字詞計數重設為 0,才能再次從頭開始遊戲。如要重設應用程式資料,請在
GameViewModel中新增名為reinitializeData()的方法。將分數和字詞計數設為0。清除字詞清單並呼叫getNextWord()方法。
1 | /* |
- 在
GameFragment頂端的方法restartGame()中,呼叫新建立的方法reinitializeData()。
1 | private fun restartGame() { |
- 再次執行應用程式。開始遊戲。看到祝賀對話方塊時,請按一下「Play Again」。現在,您應該可以成功再次遊玩遊戲!
應用程式最終畫面應如下所示。這個遊戲會顯示十個隨機打散的字詞,讓玩家進行重組。您可選擇「略過」字詞,或猜測字詞,然後輕觸「提交」。如果答案正確,分數將會增加。答案不正確會在文字欄位中顯示錯誤狀態。隨著每個新字詞的進行,字詞計數也會增加。
請注意,畫面上顯示的分數和字詞計數尚未更新。但這些資訊仍會儲存在檢視模型中,並在設定變更 (例如裝置旋轉) 期間保留。您將在後續的程式碼研究室中,更新畫面上的分數和字詞計數。
遊戲將在 10 個字詞後結束,畫面上會出現快訊對話方塊,顯示最終分數和結束遊戲或再玩一次的選項。
恭喜!您已建立第一個 ViewModel,並成功儲存資料!
總結
- Android 應用程式架構指南建議將具有不同責任的類別分離,並透過模型使用 UI。
- UI Controller 是一種 UI 類別,例如
Activity或Fragment。UI 控制器只能包含處理 UI 和作業系統互動的邏輯;其不應做為在 UI 中顯示的資料來源。將該資料和任何相關的邏輯存放在ViewModel中。 ViewModel類別會儲存和管理 UI 相關資料。ViewModel類別可在螢幕旋轉等變更時保留資料。ViewModel是建議使用的 Android 架構元件之一。