Tina Tang's Blog

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

0%

Android筆記(26)-將資料儲存於ViewModel中

瞭解如何使用 ViewModel 架構元件來儲存應用程式資料。如果在設定變更或其他事件期間,刪除架構並重新建立 activity 和 fragment,儲存的資料不會遺失。

學習目標

  • Android 應用程式架構基本概念簡介。
  • 如何在應用程式中使用 ViewModel 類別。
  • 如何使用 ViewModel,透過裝置設定變更保留 UI 資料。
  • Kotlin 的幕後屬性。
  • 如何使用質感設計元件庫中的 MaterialAlertDialog
  • 建立 Unscramble 遊戲應用程式,可讓使用者猜測打散的字詞。

範例應用程式總覽

遊戲總覽

Unscramble 應用程式為單人字詞重組遊戲。本應用程式一次會顯示一個打散的字詞,且玩家必須使用打散的所有字母猜出這個字詞。只要字詞正確無誤,玩家即可得分,否則玩家可任意進行嘗試。應用程式也具備略過目前字詞的選項。應用程式左上角會顯示字詞計數,也就是目前遊戲中已遊玩過的字詞數。每場遊戲共有 10 字。

下載範例程式碼

如果您使用 GitHub 中的範例程式碼,請注意資料夾名稱是 android-basics-kotlin-unscramble-app-starter。在 Android Studio 中開啟專案時,請選取這個資料夾。

範例程式碼網址:https://github.com/google-developer-training/android-basics-kotlin-unscramble-app/tree/starter
含有範例程式碼的模組名稱: starter

範例程式碼總覽

  1. 在 Android Studio 中開啟含有範例程式碼的專案。
  2. 在 Android 裝置或模擬器上執行應用程式。
  3. 透過數個字詞進行遊戲,請輕觸「提交」和「略過」按鈕。 請注意,輕觸按鈕時會顯示下一個字詞,並增加字詞計數。
  4. 請留意,分數只會在輕觸「Submit」按鈕時提升。

範例程式碼相關問題

玩遊戲時,您可能已注意到下列錯誤:

  1. 按一下「Submit」按鈕時,應用程式不會檢查玩家的字詞。玩家總是可以得分。
  2. 無法結束遊戲。應用程式可讓您遊玩超過 10 個字詞。
  3. 遊戲畫面會顯示打散的字詞、玩家分數和字詞計數。旋轉裝置或模擬器變更螢幕方向。請注意,目前的字詞、分數和字詞計數都會消失,遊戲也會重新開始。
應用程式的主要問題

設定變更時 (例如裝置螢幕方向變更),範例應用程式不會儲存及還原應用程式狀態和資料。
您可以使用 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 包含應用程式所需的所有字串
  • themesstyles 資料夾內含應用程式的 UI 自訂項目

MainActivity.kt
包含預設範本產生的程式碼,可將 activity 的內容檢視畫面設為 main_activity.xml

ListOfWords.kt
此檔案內含遊戲中使用的字詞清單、每場遊戲字詞數量上限,以及玩家針對每個正確字詞所得分數的常數。

GameFragment.kt
這是應用程式中唯一的 fragment,也是大部分遊戲動作發生處:

  • 變數是根據目前打散的字詞 (currentScrambledWord)、字詞計數 (currentWordCount) 和分數 (score) 所定義。
  • 已定義可存取名為 bindinggame_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)、ViewModelLiveDataRoom。這些元件負責生命週期的部分複雜度,且可避免發生生命週期相關問題。您將在後續的程式碼研究室中學習 LiveDataRoom

下圖為架構的基本部分:

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 存取遊戲的相關資訊。

  1. 在 Android Studio 中,「Android」 視窗的「Gradle Scripts」資料夾下,開啟 build.gradle(Module:Unscramble.app) 檔案。

  2. 如要在應用程式中使用 ViewModel,請確認 dependencies 區塊中具有 ViewModel 程式庫依附元件。此步驟已經完成。視程式庫的最新版本而定,所產生程式碼中的程式庫版本編號可能有所不同。

1
2
// ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
  1. 建立名為 GameViewModel 的新 Kotlin 類別檔案。在「Android」視窗中,於「ui.game」資料夾上按一下滑鼠右鍵。選取「New」>「Kotlin File/Class」。
  1. 輸入名稱 GameViewModel,然後從清單中選取「Class」。

  2. GameViewModel 變更為 ViewModel 的子類別。ViewModel 為抽象類別,因此您必須將其擴充,才能在應用程式中使用。請參閱下方的 GameViewModel 類別定義。

1
2
class GameViewModel : ViewModel() {
}

將 ViewModel 附加至 fragment

如要建立 ViewModel 與 UI controller (activity/fragment) 的關聯,請在 UI controller 內建立 ViewModel 的引用 (物件)。

在這個步驟中,您會在對應的 UI controller (GameFragment) 中建立 GameViewModel 的 object instance。

  1. GameFragment 類別頂部新增 GameViewModel 類型的屬性。
  2. 使用 by viewModels() Kotlin 屬性委派功能將 GameViewModel 初始化。您將在下一節深入瞭解。
1
private val viewModel: GameViewModel by viewModels()
  1. 如果 Android Studio 顯示提示,請匯入 androidx.fragment.app.viewModels

Kotlin 屬性委派

在 Kotlin 中,每個可變動 (var) 屬性都會自動產生屬性的 gettersetter 函式。當您指派值或讀取屬性值時,系統將會呼叫 settergetter 函式。

唯讀屬性 (val) 與可變動屬性稍有不同。根據預設,只會產生 getter 函式讀取唯讀屬性的值時,系統會呼叫 getter 函式。

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

委派屬性是使用 by 子句和委派類別 instance 來定義:

1
2
// Syntax for property delegation
var <property-name> : <property-type> by <delegate-class>()

在應用程式中,如使用預設的 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 類別。

  1. 將資料變數 scorecurrentWordCountcurrentScrambledWord 移至 GameViewModel 類別。
1
2
3
4
5
6
class GameViewModel : ViewModel() {

private var score = 0
private var currentWordCount = 0
private var currentScrambledWord = "test"
...
  1. 請注意未解決的參照錯誤。這是因為屬性僅供 ViewModel 使用,且無法由 UI 控制器進行存取。您將在下一個步驟修正這些錯誤。

如要解決這個問題,屬性的可見度修飾符不得為 public,資料不可由其他類別編輯。此操作具有風險,因為外部類別可能會以非預期的方式,變更未遵循檢視模式中指定遊戲規則的資料。舉例來說,外部類別可以將 score 變更為負值。

ViewModel 內的資料應可編輯,因此應為 privatevar。在 ViewModel 外部,資料應可供讀取,但無法編輯,因此資料應以 publicval 的形式呈現。為了達成這個行為,Kotlin 提供名為幕後屬性(Backing properties)的功能。

幕後屬性

幕後屬性可讓您從 getter 傳回確切物件以外的項目。
您已瞭解 Kotlin 架構會為每個屬性產生 gettersetter
gettersetter 方法可覆寫此類方法 (一或兩種),並提供您自訂的行為。如要實作幕後屬性,您將會覆寫 getter 方法,以傳回唯讀資料版本。幕後屬性範例:

1
2
3
4
5
6
7
8
9
10
// Declare private mutable variable that can only be modified
// within the class it is declared.
private var _count = 0

// Declare another public immutable field and override its getter method.
// Return the private property's value in the getter method.
// When count is accessed, the get() function is called and
// the value of _count is returned.
val count: Int
get() = _count

舉例來說,假設您想在應用程式內,將應用程式資料設為僅供 ViewModel 使用:

ViewModel 類別中:

  • _count 屬性為 private,且可變動。因此,只能在 ViewModel 類別中進行存取及編輯。慣例是在 private 屬性字首加上底線。

ViewModel 類別外:

  • Kotlin 中的預設瀏覽權限修飾符為 public,因此 count 是公開狀態,且可從 UI controller 等其他類別存取。由於只有 get() 方法遭到覆寫,因此這個屬性不可變動且為唯讀。外部類別存取這個屬性時,系統會傳回 _count 的值,且該值無法修改。這種做法可確保 ViewModel 中的應用程式資料不會受到外部類別非必要和不安全的變更,但可讓外部呼叫者安全地存取其值。

將幕後屬性新增至 currentScrambledWord

  1. GameViewModel 中,變更 currentScrambledWord 宣告以新增幕後屬性。目前您只能在 GameViewModel 中存取及編輯 _currentScrambledWord。UI controller GameFragment 可以使用唯讀屬性 currentScrambledWord 讀取其值。
1
2
3
private var _currentScrambledWord = "test"
val currentScrambledWord: String
get() = _currentScrambledWord
  1. GameFragment 中,更新 updateNextWordOnScreen() 方法,以使用唯讀的 viewModel 屬性 currentScrambledWord
1
2
3
private fun updateNextWordOnScreen() {
binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
}
  1. GameFragment 中,刪除 onSubmitWord()onSkipWord() 方法中的程式碼。您將於稍後實作這些方法。您現在應該能夠正確編譯程式碼,而不會產生錯誤。

警告: 請勿揭露 ViewModel 中的可變動資料欄位。請確認此資料無法從其他類別進行修改。ViewModel 中的可變動資料一律為 private


ViewModel 的生命週期

只要 activity 或 fragment 的範圍保持運作,該架構就會使 ViewModel 保持運作。如果 ViewModel 的擁有者因設定變更 (例如螢幕旋轉) 而遭到刪除,系統並不會將其刪除。擁有者的新 instance 會重新連線至現有的 ViewModel instance,如下圖所示:

瞭解 ViewModel 生命週期

GameViewModelGameFragment 中新增記錄功能,以進一步瞭解 ViewModel 的生命週期。

  1. GameViewModel.kt 中,新增含有記錄陳述式的 init 區塊。
1
2
3
4
5
6
7
class GameViewModel : ViewModel() {
init {
Log.d("GameFragment", "GameViewModel created!")
}

...
}
  • Kotlin 會提供初始化器區塊 (也稱為 init 區塊),做為物件 instance 初始化期間,所需初始設定程式碼的位置。初始化器區塊的前面會加上 init 關鍵字,後為大括號 {}。這個程式碼區塊會在首次建立並初始化物件 instance 時執行。
  1. GameViewModel 類別中,覆寫 onCleared() 方法(Control+o)。在您卸離相關 fragment 或 activity 完成後,系統會將 ViewModel 刪除。在 ViewModel 刪除之前,系統會呼叫 onCleared() callback。

  2. onCleared() 中新增記錄(log)陳述式,以追蹤 GameViewModel 生命週期。

1
2
3
4
override fun onCleared() {
super.onCleared()
Log.d("GameFragment", "GameViewModel destroyed!")
}
  1. GameFragmentonCreateView() 中找到 binding 物件引用後,請新增 log 陳述式以記錄 fragment 的建立作業。初次建立及每次重新建立 (例如設定變更等事件) fragment 時,系統會觸發 onCreateView() callback。
1
2
3
4
5
6
7
8
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = GameFragmentBinding.inflate(inflater, container, false)
Log.d("GameFragment", "GameFragment created/re-created!")
return binding.root
}
  1. GameFragment 中覆寫 onDetach() callback 方法,以在對應的 activity 和 fragment 刪除時呼叫此方法。
1
2
3
4
override fun onDetach() {
super.onDetach()
Log.d("GameFragment", "GameFragment destroyed!")
}
  1. 在 Android Studio 中執行應用程式,開啟「Logcat」視窗,然後篩選 GameFragment。請注意,GameFragmentGameViewModel 已建立。
1
2
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
  1. 在裝置或模擬器上啟用自動旋轉設定,並變更螢幕方向數次。每次都會刪除並重新建立 GameFragment,但 GameViewModel 只會建立一次,而且不會在每次呼叫時重新建立或刪除。
1
2
3
4
5
6
7
8
9
10
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
  1. 離開遊戲,或使用返回箭頭離開應用程式。GameViewModel 已刪除,並呼叫 onCleared() callback。GameFragment 已刪除。
1
2
com.example.android.unscramble D/GameFragment: GameViewModel destroyed!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!

填入 ViewModel

在這項工作中,您將使用輔助方法進一步填入 GameViewModel,以便取得下一個字詞、驗證玩家的字詞能否增加分數,並檢查字詞計數來結束遊戲。

延遲初始化

通常在宣告變數時,您必須先提供初始值。不過,如果您還沒準備好指派值,可以稍後再進行初始化。為了延遲在 Kotlin 中初始化屬性,您可以使用關鍵字 lateinit,表示延遲初始化。如果您確保在使用前先初始化屬性,可以使用 lateinit 宣告屬性。記憶體必須先初始化,才能分配給變數。如果您在初始化之前就嘗試存取變數,應用程式將會異常終止。

取得下一個字詞

GameViewModel 類別中建立 getNextWord() 方法,且具備下列功能:

  • allWordsList 取得隨機字詞,並將其指派給 currentWord.
  • currentWord 中的字母打散,以產生打散的字詞,並將其指派給 currentScrambledWord
  • 處理打散與未打散字詞相同的情形。
  • 請確定您在遊戲期間不會重複出現相同的字詞。

請在 GameViewModel 類別中執行下列步驟:

  1. GameViewModel, 中,新增 MutableList<String> 類型的新類別變數 (名為 wordsList),以保留遊戲中使用的字詞清單,避免重複出現。
  2. 新增另一個名為 currentWord 的類別變數,以保留玩家嘗試重組的字詞。由於您稍後會初始化此屬性,請使用 lateinit 關鍵字。
1
2
private var wordsList: MutableList<String> = mutableListOf()
private lateinit var currentWord: String
  1. init 區塊上新增名為 getNextWord() 的新 private 方法,且無不會傳回任何內容的參數。
  2. allWordsList 取得隨機字詞,並將其指派給 currentWord
1
2
3
private fun getNextWord() {
currentWord = allWordsList.random()
}
  1. getNextWord() 中,將 currentWord 字串轉換為字元陣列,並將其指派給名為 tempWord 的新 val。如要打散字詞,請使用 Kotlin 方法 shuffle() 隨機變換此陣列中的字元。
1
2
val tempWord = currentWord.toCharArray()
tempWord.shuffle()
  • ArrayMutableList 相似,但其初始化時有固定的大小。Array 無法展開或縮減大小 (您必須複製陣列才能調整大小),而 MutableList 具有 add()remove() 函式,因此可以調整大小。
  1. 有時,隨機變換後的字元順序會與原始字詞相同。在要隨機變換的呼叫周圍加上下列 while 迴圈,以在打散字詞不同於原始字詞前持續進行迴圈。
1
2
3
while (String(tempWord).equals(currentWord, false)) {
tempWord.shuffle()
}
  1. 新增 if-else 區塊,以確認是否已使用字詞。如果 wordsList 包含 currentWord,請呼叫 getNextWord()。如果沒有,請以剛打散的字詞更新 _currentScrambledWord 值、增加字詞計數,並將新字詞新增至 wordsList
1
2
3
4
5
6
7
if (wordsList.contains(currentWord)) {
getNextWord()
} else {
_currentScrambledWord = String(tempWord)
++currentWordCount
wordsList.add(currentWord)
}
  1. 以下是完整的 getNextWord() 方法,供您參考。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
* Updates currentWord and currentScrambledWord with the next word.
*/
private fun getNextWord() {
currentWord = allWordsList.random()
val tempWord = currentWord.toCharArray()
tempWord.shuffle()

while (String(tempWord).equals(currentWord, false)) {
tempWord.shuffle()
}
if (wordsList.contains(currentWord)) {
getNextWord()
} else {
_currentScrambledWord = String(tempWord)
++currentWordCount
wordsList.add(currentWord)
}
}

延遲初始化 currentScrambledWord

現在您已建立 getNextWord() 方法,以取得下一個打散的字詞。初次初始化 GameViewModel 時,系統會呼叫此方法。使用 init 區塊初始化類別中的 lateinit 屬性 (例如目前字詞)。如此一來,畫面上顯示的第一個字詞會是打散的字詞,而不是「test」。

  1. 執行應用程式。請注意,第一個字詞一律為「test」。
  2. 如要在應用程式起始處顯示打散的字詞,請呼叫 getNextWord() 方法,藉此讓系統更新 currentScrambledWord。呼叫 GameViewModel init 區塊中的 getNextWord() 方法。
1
2
3
4
init {
Log.d("GameFragment", "GameViewModel created!")
getNextWord()
}
  1. _currentScrambledWord 屬性中加入 lateinit 修飾符。由於未提供初始值,請明確提及 String 資料類型。
1
private lateinit var _currentScrambledWord: String
  1. 執行應用程式。請注意,應用程式啟動時會顯示新的打散字詞。太棒了!

新增 Helper 方法

接下來,請加入 Helper 方法來處理和修改 ViewModel 中的資料。您將在後續工作中使用此方法。

GameViewModel 類別中,新增另一個 nextWord(). 方法。接著從清單中取得下一個字詞,並在字詞計數少於 MAX_NO_OF_WORDS 時傳回 true

1
2
3
4
5
6
7
8
9
10
/*
* Returns true if the current word count is less than MAX_NO_OF_WORDS.
* Updates the next word.
*/
fun nextWord(): Boolean {
return if (currentWordCount < MAX_NO_OF_WORDS) {
getNextWord()
true
} else false
}

對話方塊

在範例程式碼中,即使已遊玩 10 個字詞,遊戲也不會結束。請修改應用程式,在使用者遊玩 10 個字詞後結束遊戲,並顯示含有最終分數的對話方塊。使用者還可以選擇重新遊玩或離開遊戲。

這是您初次在應用程式中新增對話方塊。對話方塊是一個小視窗 (畫面),可提示使用者做出決定或輸入額外資訊。一般而言,如果對話方塊未填滿整個畫面,則使用者必須執行操作才能繼續操作。Android 提供不同類型的對話方塊。在本程式碼研究室中,您將瞭解「快訊對話方塊」。

快訊對話方塊剖析

  1. 快訊對話方塊
  2. 標題 (選填)
  3. 訊息
  4. 文字按鈕

實作最終分數對話方塊

使用質感設計元件庫中的 MaterialAlertDialog,在應用程式中加入符合質感設計指南的對話方塊。由於對話方塊與 UI 相關,因此 GameFragment 將負責建立並顯示最終分數對話方塊。

  1. 首先,在 score 變數中新增幕後屬性。在 GameViewModel 中,將 score 變數宣告變更為以下內容。
1
2
3
private var _score = 0
val score: Int
get() = _score
  1. GameFragment 中,新增名為 showFinalScoreDialog() 的私人函式。如要建立 MaterialAlertDialog,請使用 MaterialAlertDialogBuilder 類別逐步建立對話方塊的內容。使用 fragment 的 requireContext() 方法呼叫傳遞內容的 MaterialAlertDialogBuilder 建構函式。requireContext() 方法會傳回非空值的 Context
1
2
3
4
5
6
/*
* Creates and shows an AlertDialog with the final score.
*/
private fun showFinalScoreDialog() {
MaterialAlertDialogBuilder(requireContext())
}
  • 顧名思義,Context 是指應用程式、activity 或 fragment 的結構定義或目前狀態。其包含與 activity、fragment 或應用程式相關的資訊。其通常用於存取資源、資料庫和其他系統服務。在這個步驟中,您必須傳遞 fragment 結構定義,以建立快訊對話方塊。
  • 如果 Android Studio 顯示提示,請 import com.google.android.material.dialog.MaterialAlertDialogBuilder
  1. 加入程式碼以設定快訊對話方塊的標題,請使用 strings.xml 的字串資源。
1
2
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.congratulations))
  1. 設定訊息以顯示最終分數,並使用先前新增的分數變數 (viewModel.score) 唯讀版本。
1
.setMessage(getString(R.string.you_scored, viewModel.score))
  1. 使用 setCancelable() 方法並傳遞 false,使快訊對話方塊在按下返回鍵時無法取消。

  2. 使用 setNegativeButton()setPositiveButton() 方法新增「離開」和「再玩一次」兩個文字按鈕。從 lambda 分別呼叫 exitGame()restartGame()

1
2
3
4
5
6
.setNegativeButton(getString(R.string.exit)) { _, _ ->
exitGame()
}
.setPositiveButton(getString(R.string.play_again)) { _, _ ->
restartGame()
}
  • 這個語法對您來說可能較陌生,但其為 setNegativeButton(getString(R.string.exit), { _, _ -> exitGame()}) 的簡寫,其中 setNegativeButton() 方法會納入兩個參數:String 及可用 lambda 表示的 DialogInterface.OnClickListener() 函式。如果傳入的最後一個引數是函式,您可以將 lambda 運算式放在括號外。這就是所謂的結尾 lambda 語法。系統接受這兩種程式碼編寫方式 (lambda 位於括號內或外)。這同樣適用於 setPositiveButton 函式。
  1. 最後加入 show(),即可建立並顯示快訊對話方塊。
1
.show()
  1. 以下是完整的 showFinalScoreDialog() 方法,供您參考。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* Creates and shows an AlertDialog with the final score.
*/
private fun showFinalScoreDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.congratulations))
.setMessage(getString(R.string.you_scored, viewModel.score))
.setCancelable(false)
.setNegativeButton(getString(R.string.exit)) { _, _ ->
exitGame()
}
.setPositiveButton(getString(R.string.play_again)) { _, _ ->
restartGame()
}
.show()
}

實作「Submit」按鈕的 OnClickListener

在這項工作中,您要使用 ViewModel 和新增的快訊對話方塊,實作「Submit」按鈕點擊事件監聽器的遊戲邏輯。

顯示打散的字詞

  1. 如果您尚未在 GameFragment 中刪除 onSubmitWord() 內的程式碼 (輕觸「Submit」按鈕時會呼叫此程式碼),請先完成這項操作。
  2. 請在 viewModel.nextWord() 方法的傳回值新增檢查。如果為 true,則可以使用其他字詞,因此請使用 updateNextWordOnScreen() 更新畫面上打散的字詞。否則遊戲將會結束,並顯示含有最終分數的快訊對話方塊。
1
2
3
4
5
6
7
private fun onSubmitWord() {
if (viewModel.nextWord()) {
updateNextWordOnScreen()
} else {
showFinalScoreDialog()
}
}
  1. 執行應用程式!使用一些字詞進行遊戲。別忘了,您尚未實作「Skip」按鈕,因此無法略過該字詞。

  2. 請注意,文字欄位不會更新,因此玩家必須手動刪除上一個字詞。快訊對話方塊中的最終分數永遠為零。您將在後續步驟中修正這些錯誤。

新增 Helper 方法以驗證玩家字詞

  1. GameViewModel 中,新增名為 increaseScore() 的新私人方法,且不含參數和傳回值。透過 SCORE_INCREASEscore 變數提高。
1
2
3
private fun increaseScore() {
_score += SCORE_INCREASE
}
  1. GameViewModel 中,新增名為 isUserWordCorrect() 的 Helper 方法,其會傳回 Boolean 並將玩家字詞 String 做為參數。

  2. isUserWordCorrect() 中驗證玩家的字詞,如果答案正確無誤,則增加分數。這會更新快訊對話方塊中的最終分數。

更新文字欄位

顯示文字欄位中的錯誤
針對 Material 文字欄位,TextInputLayout 內建能顯示錯誤訊息的功能。舉例來說,在下列文字欄位中,標籤顏色有所變更、顯示錯誤圖示、顯示錯誤訊息等。

如要在文字欄位中顯示錯誤,您可以在程式碼中以動態方式設定錯誤訊息,或在版面配置檔案中以靜態方式設定錯誤訊息。設定及重設程式碼中錯誤的範例如下:

1
2
3
4
5
// Set error text
passwordLayout.error = getString(R.string.error)

// Clear error text
passwordLayout.error = null

在範例程式碼中,您會發現已定義 setErrorTextField(error: Boolean) Helper 方法,以協助您設定及重設文字欄位中的錯誤。根據是否要在文字欄位中顯示錯誤,使用 truefalse 做為輸入參數,呼叫此方法。

範例程式碼中的程式碼片段

1
2
3
4
5
6
7
8
9
private fun setErrorTextField(error: Boolean) {
if (error) {
binding.textField.isErrorEnabled = true
binding.textField.error = getString(R.string.try_again)
} else {
binding.textField.isErrorEnabled = false
binding.textInputEditText.text = null
}
}

在這項工作中,您會實作 onSubmitWord() 方法。使用者提交字詞時,您可透過檢查原始字詞,驗證使用者的答案。如果字詞正確無誤,請前往下一個字詞 (如果遊戲已結束,則顯示對話方塊)。如果字詞有誤,請在文字欄位中顯示錯誤,並繼續使用目前的字詞。

  1. GameFragment 中(onSubmitWord() 開始的部分),建立名為 playerWordval。從 binding 變數的文字欄位中擷取文字,將玩家的字詞儲存在其中。
1
2
3
4
private fun onSubmitWord() {
val playerWord = binding.textInputEditText.text.toString()
...
}
  1. onSubmitWord() 中的 playerWord 宣告下方,驗證玩家的字詞。新增 if 陳述式,以使用 isUserWordCorrect() 方法檢查玩家的字詞,並傳入 playerWord

  2. 在 if 區塊中,重設文字欄位,呼叫 setErrorTextField 傳入 false

  3. 將現有程式碼移至 if 區塊中。

1
2
3
4
5
6
7
8
9
10
11
12
private fun onSubmitWord() {
val playerWord = binding.textInputEditText.text.toString()

if (viewModel.isUserWordCorrect(playerWord)) {
setErrorTextField(false)
if (viewModel.nextWord()) {
updateNextWordOnScreen()
} else {
showFinalScoreDialog()
}
}
}
  1. 如果使用者字詞不正確,請在文字欄位中顯示錯誤訊息。將 else 區塊新增至上述 if 區塊,並呼叫 setErrorTextField() 傳入 true。已完成的 onSubmitWord() 方法應如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private fun onSubmitWord() {
val playerWord = binding.textInputEditText.text.toString()

if (viewModel.isUserWordCorrect(playerWord)) {
setErrorTextField(false)
if (viewModel.nextWord()) {
updateNextWordOnScreen()
} else {
showFinalScoreDialog()
}
} else {
setErrorTextField(true)
}
}
  1. 執行應用程式,並透過一些字詞進行遊戲。如果玩家的字詞正確無誤,按一下「Submit」按鈕即可清除字詞,否則系統會顯示「Try again!」的訊息。請注意,「略過」按鈕目前仍未運作。您將在下一個工作中加入此實作。

實作略過按鈕

在這項工作中,您要新增 onSkipWord() 實作,用於處理使用者輕觸「Skip」按鈕時的情況。

  1. onSubmitWord() 類似,請在 onSkipWord() 方法中新增條件。如為 true,請在畫面上顯示文字並重設文字欄位。如為 false,且這回合沒有其他字詞,則顯示含有最終分數的快訊對話方塊。
1
2
3
4
5
6
7
8
9
10
11
/*
* Skips the current word without changing the score.
*/
private fun onSkipWord() {
if (viewModel.nextWord()) {
setErrorTextField(false)
updateNextWordOnScreen()
} else {
showFinalScoreDialog()
}
}
  1. 執行您的應用程式。遊玩遊戲。請注意,「略過」和「提交」按鈕可正常運作。非常好!

確認 ViewModel 將會保留資料

在這項工作中,於 GameFragment 中新增 log,以觀察在設定變更期間,您的應用程式資料是否會保留在 ViewModel 中。如要存取 GameFragment 中的 currentWordCount,您必須使用幕後屬性公開唯讀版本。

  1. GameViewModel 中,在 currentWordCount 變數上按一下滑鼠右鍵,然後選取「Refactor」>「Rename…」。在新名稱前加上底線 _currentWordCount

  2. 新增支援欄位。

1
2
3
private var _currentWordCount = 0
val currentWordCount: Int
get() = _currentWordCount
  1. GameFragment 內的 onCreateView() 中,在回傳敘述上方新增另一個 log,以列印應用程式資料、字詞、分數和字詞計數。
1
2
Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
"Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
  1. 在 Android Studio 中,開啟「Logcat」,並篩選 GameFragment。執行應用程式,使用一些字詞進行遊戲。變更裝置的螢幕方向。fragment (UI controller) 會刪除並重新建立。觀察記錄。您現在可以看到分數和字詞計數增加!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
com.example.android.unscramble D/GameFragment: Word: oimfnru Score: 0 WordCount: 1
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: ofx Score: 80 WordCount: 5
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: ofx Score: 80 WordCount: 5
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: nvoiil Score: 160 WordCount: 9
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: nvoiil Score: 160 WordCount: 9
  • 請注意,螢幕方向變更時,應用程式資料會保存在 ViewModel 中。您將在後續的程式碼研究室中使用 LiveData 和 data biniding,更新 UI 上的分數值和字詞計數。

更新遊戲重新啟動邏輯

  1. 再次執行應用程式,使用所有字詞進行遊戲。在「Congratulations!」快訊對話方塊中,按一下「PLAY AGAIN」。由於字詞計數現已達到 MAX_NO_OF_WORDS 值,因此應用程式無法讓您再玩一次。您必須將字詞計數重設為 0,才能再次從頭開始遊戲。

  2. 如要重設應用程式資料,請在 GameViewModel 中新增名為 reinitializeData() 的方法。將分數和字詞計數設為 0。清除字詞清單並呼叫 getNextWord() 方法。

1
2
3
4
5
6
7
8
9
/*
* Re-initializes the game data to restart the game.
*/
fun reinitializeData() {
_score = 0
_currentWordCount = 0
wordsList.clear()
getNextWord()
}
  1. GameFragment 頂端的方法 restartGame() 中,呼叫新建立的方法 reinitializeData()
1
2
3
4
5
private fun restartGame() {
viewModel.reinitializeData()
setErrorTextField(false)
updateNextWordOnScreen()
}
  1. 再次執行應用程式。開始遊戲。看到祝賀對話方塊時,請按一下「Play Again」。現在,您應該可以成功再次遊玩遊戲!

應用程式最終畫面應如下所示。這個遊戲會顯示十個隨機打散的字詞,讓玩家進行重組。您可選擇「略過」字詞,或猜測字詞,然後輕觸「提交」。如果答案正確,分數將會增加。答案不正確會在文字欄位中顯示錯誤狀態。隨著每個新字詞的進行,字詞計數也會增加。

請注意,畫面上顯示的分數和字詞計數尚未更新。但這些資訊仍會儲存在檢視模型中,並在設定變更 (例如裝置旋轉) 期間保留。您將在後續的程式碼研究室中,更新畫面上的分數和字詞計數。

遊戲將在 10 個字詞後結束,畫面上會出現快訊對話方塊,顯示最終分數和結束遊戲或再玩一次的選項。

恭喜!您已建立第一個 ViewModel,並成功儲存資料!


總結

  • Android 應用程式架構指南建議將具有不同責任的類別分離,並透過模型使用 UI。
  • UI Controller 是一種 UI 類別,例如 ActivityFragment。UI 控制器只能包含處理 UI 和作業系統互動的邏輯;其不應做為在 UI 中顯示的資料來源。將該資料和任何相關的邏輯存放在 ViewModel 中。
  • ViewModel 類別會儲存和管理 UI 相關資料。ViewModel 類別可在螢幕旋轉等變更時保留資料。
  • ViewModel 是建議使用的 Android 架構元件之一。