1. 事前準備
在先前的程式碼研究室中,您已瞭解活動和片段的生命週期,以及設定變更的相關生命週期問題。如要儲存應用程式資料,儲存執行個體狀態是一個選擇,但有其限制。在本程式碼研究室中,您將瞭解如何使用 Android Jetpack 程式庫設計應用程式,並在設定變更時保留應用程式資料的完善方法。
Android Jetpack 內含一系列程式庫,可讓您更輕鬆地開發優質 Android 應用程式。這些程式庫可協助您遵循最佳做法、無需編寫樣板程式碼,並簡化複雜的工作,讓您專心處理應用程式邏輯等的重要程式碼。
Android 架構元件是 Android Jetpack 程式庫的一部分,旨在協助您設計具備優良架構的應用程式。架構元件可提供應用程式架構的相關指引,這也是建議您採用的最佳做法。
應用程式架構是一組設計規則。就像房屋的藍圖一樣,架構即為應用程式的結構。優良的應用程式架構可讓程式碼未來數年內保持穩定、具有彈性、可擴充且易於維護。
在本程式碼研究室中,您將瞭解如何使用 ViewModel 架構元件來儲存應用程式資料。如果在設定變更或其他事件期間,刪除架構並重新建立活動和片段,儲存的資料不會遺失。
必要條件
- 如何從 GitHub 下載原始碼,並在 Android Studio 中開啟。
 - 如何使用活動和片段在 Kotlin 中建構並執行基本 Android 應用程式。
 - 瞭解 Material 文字欄位和常見的 UI 小工具,例如 
TextView和Button。 - 如何在應用程式中使用檢視繫結。
 - 活動和片段生命週期的基本概念。
 - 如何將記錄資訊新增至應用程式,並在 Android Studio 中使用 Logcat 讀取記錄。
 
課程內容
- Android 應用程式架構基本概念簡介。
 - 如何在應用程式中使用 
ViewModel類別。 - 如何使用 
ViewModel,透過裝置設定變更保留 UI 資料。 - Kotlin 的幕後屬性。
 - 如何使用質感設計元件庫中的 
MaterialAlertDialog。 
建構項目
- Unscramble 遊戲應用程式,可讓使用者猜測打散的字詞。
 
軟硬體需求
- 已安裝 Android Studio 的電腦。
 - Unscramble 應用程式的範例程式碼。
 
2. 範例應用程式總覽
遊戲總覽
Unscramble 應用程式為單人字詞重組遊戲。本應用程式一次會顯示一個打散的字詞,且玩家必須使用打散的所有字母猜出這個字詞。只要字詞正確無誤,玩家即可得分,否則玩家可任意進行嘗試。應用程式也具備略過目前字詞的選項。應用程式左上角會顯示字詞計數,也就是目前遊戲中已遊玩過的字詞數。每場遊戲共有 10 字。
 
 
下載範例程式碼
本程式碼研究室提供範例程式碼,可延伸至本程式碼研究室所教授的功能。範例程式碼含有來自先前程式碼研究室的程式碼,可能會讓您感到既熟悉又陌生。您將在後續的程式碼研究室中進一步瞭解陌生的程式碼。
如果您使用 GitHub 中的範例程式碼,請注意資料夾名稱是 android-basics-kotlin-unscramble-app-starter。在 Android Studio 中開啟專案時,請選取這個資料夾。
- 前往專案所在的 GitHub 存放區頁面。
 - 驗證分支版本名稱與程式碼研究室中指定的分支版本名稱相符。例如,在下列螢幕截圖中,分支版本名稱為「main」。
 

- 在專案的 GitHub 頁面中,按一下「Code」按鈕,畫面上會出現彈出式視窗。
 

- 在彈出式視窗中,按一下「Download ZIP」按鈕,將專案儲存至電腦。等待下載作業完成。
 - 在電腦中找到該檔案 (可能位於「下載」資料夾中)。
 - 按兩下解壓縮 ZIP 檔案。這項操作會建立含有專案檔案的新資料夾。
 
在 Android Studio 中開啟專案
- 啟動 Android Studio。
 - 在「Welcome to Android Studio」視窗中,按一下「Open」。
 

注意:如果 Android Studio 已開啟,請改為依序選取「File」>「Open」選單選項。

- 在檔案瀏覽器中,前往已解壓縮的專案資料夾所在的位置 (可能位於「Downloads」資料夾中)。
 - 按兩下該專案資料夾。
 - 等待 Android Studio 開啟專案。
 - 按一下「Run」按鈕 
 即可建構並執行應用程式,請確認應用程式的建構符合預期。 
範例程式碼總覽
- 在 Android Studio 中開啟含有範例程式碼的專案。
 - 在 Android 裝置或模擬器上執行應用程式。
 - 透過數個字詞進行遊戲,請輕觸「提交」和「略過」按鈕。 請注意,輕觸按鈕時會顯示下一個字詞,並增加字詞計數。
 - 請留意,分數只會在輕觸「Submit」按鈕時提升。
 
範例程式碼相關問題
玩遊戲時,您可能已注意到下列錯誤:
- 按一下「Submit」按鈕時,應用程式不會檢查玩家的字詞。玩家總是可以得分。
 - 無法結束遊戲。應用程式可讓您遊玩超過 10 個字詞。
 - 遊戲畫面會顯示打散的字詞、玩家分數和字詞計數。旋轉裝置或模擬器變更螢幕方向。請注意,目前的字詞、分數和字詞計數都會消失,遊戲也會重新開始。
 
應用程式的主要問題
設定變更時 (例如裝置螢幕方向變更),範例應用程式不會儲存及還原應用程式狀態和資料。
您可以使用 onSaveInstanceState() 回呼解決此問題。不過,使用 onSaveInstanceState() 方法時,您必須編寫額外的程式碼將狀態儲存在套件中,並實作邏輯以擷取該狀態。此外,可儲存的資料量極少。
您可以使用在本課程所學到的 Android 架構元件來解決這些問題。
範例程式碼逐步操作說明
您下載的範例程式碼包含已為您預先設計的遊戲畫面版面配置。本課程重點為實作遊戲邏輯。您需要使用架構元件來實作建議的應用程式架構,並解決上述問題。以下是部分檔案的簡要逐步操作說明,可協助您快速上手。
game_fragment.xml
- 在「設計」檢視畫面中開啟 
res/layout/game_fragment.xml。 - 這包含應用程式中唯一畫面的版面配置,也就是遊戲畫面。
 - 此版面配置包含玩家字詞的文字欄位,以及顯示分數和字詞計數的 
TextViews。另外還提供說明、「Submit」按鈕和「Skip」按鈕,方便玩遊戲。 
main_activity.xml
以單一遊戲片段定義主要活動版面配置。
res/values 資料夾
您已熟悉此資料夾中的資源檔案。
colors.xml包含應用程式中使用的主題色彩strings.xml包含應用程式所需的所有字串themes和styles資料夾內含應用程式的 UI 自訂項目
MainActivity.kt
包含預設範本產生的程式碼,可將活動的內容檢視畫面設為 main_activity.xml.
ListOfWords.kt
此檔案內含遊戲中使用的字詞清單、每場遊戲字詞數量上限,以及玩家針對每個正確字詞所得分數的常數。
GameFragment.kt
這是應用程式中唯一的片段,也是大部分遊戲動作發生處:
- 變數是根據目前打散的字詞 (
currentScrambledWord)、字詞計數 (currentWordCount) 和分數 (score) 所定義。 - 已定義可存取名為 
binding之game_fragment檢視畫面的繫結物件執行個體。 onCreateView()函式會使用繫結物件加載game_fragment版面配置 XML。onViewCreated()函式會設定按鈕點選監聽器,並更新 UI。onSubmitWord()是「提交」按鈕的點擊事件監聽器,此函式會顯示下一個打散的字詞、清除文字欄位,並在未驗證玩家字詞的情況下增加分數和字詞計數。onSkipWord()是「略過」按鈕的點擊事件監聽器,此函式會更新與onSubmitWord()類似的 UI (分數除外)。getNextScrambledWord()是一項輔助函式,其可從字詞清單中挑選隨機字詞,並隨機排序這些字母。- 系統會分別使用 
restartGame()和exitGame()函式重新啟動及結束遊戲,您稍後將會使用這些函式。 setErrorTextField()可清除文字欄位內容,並重設錯誤狀態。updateNextWordOnScreen()函式可顯示新的打散字詞。
3. 瞭解應用程式架構
架構可提供相關規範,協助您在應用程式內分配類別間的責任。設計良好的應用程式架構可協助您擴大應用程式,並於日後擴充其他功能。此外,也能讓團隊更輕鬆進行協作。
最常見的架構原則為:關注點分離,以及透過模型使用 UI。
關注點分離
關注點分離的設計原則為,應用程式應區分成不同類別,每個類別具有不同責任。
透過模型使用 UI
另一個重要原則是,您應透過模型 (建議為持續性模型) 使用 UI。模型是負責處理應用程式資料的元件。模型與應用程式中的 Views 和應用程式元件無關,因此不受應用程式的生命週期和相關關注點影響。
Android 架構中的主要類別或元件包括 UI 控制器 (活動/片段)、ViewModel、LiveData 和 Room。這些元件負責生命週期的部分複雜度,且可避免發生生命週期相關問題。您將在後續的程式碼研究室中學習 LiveData 和 Room。
下圖為架構的基本部分:
UI 控制器 (活動/片段)
活動和片段為 UI 控制器。UI 控制器可控制 UI,方法包括在畫面產生檢視畫面、擷取使用者事件,以及與使用者互動之 UI 相關的任何其他內容。應用程式中的資料或與這些資料相關的決策邏輯不應屬於 UI 控制器類別。
Android 系統可能會因為特定使用者互動或記憶體不足等系統情況,而隨時刪除 UI 控制器。由於這些事件不在您的控管之下,您不應在 UI 控制器中儲存任何應用程式資料或狀態。反之,應該在 ViewModel 中新增資料相關的決策邏輯。
舉例來說,Unscramble 應用程式中的打散字詞、分數和字詞計數會顯示於片段 (UI 控制器) 中。決策程式碼應位於 ViewModel 中,例如判斷下一個打散的字詞,以及分數和字詞計數的計算。
ViewModel
ViewModel 是檢視畫面中顯示的應用程式資料模型。模型是負責處理應用程式資料的元件。其可讓您的應用程式遵循透過模型使用 UI 的架構原則。
活動或片段遭到 Android 架構刪除並重新建立時,未刪除的應用程式相關資料會由 ViewModel 進行儲存。在設定變更期間,系統會自動保留 ViewModel 物件 (不會像活動或片段執行個體一般遭到刪除),讓處於保留狀態的資料立即用於下一個活動或片段執行個體。
如要在應用程式中實作 ViewModel,請擴充架構元件庫中的 ViewModel 類別,並將應用程式資料儲存在該類別中。
總結:
片段 / 活動 (UI 控制器) 責任  | 
  | 
活動和片段應負責在畫面中產生檢視畫面和資料,並回應使用者事件。  | 
  | 
4. 新增 ViewModel
在這項工作中,您需將 ViewModel 新增至應用程式,以儲存應用程式資料 (打散的字詞、字詞計數和分數)。
您的應用程式架構如下。MainActivity 包含 GameFragment,而 GameFragment 會從 GameViewModel 存取遊戲的相關資訊。

- 在 Android Studio 中,「Android」 視窗的「Gradle Scripts」資料夾下,開啟 
build.gradle(Module:Unscramble.app)檔案。 - 如要在應用程式中使用 
ViewModel,請確認dependencies區塊中具有 ViewModel 程式庫依附元件。此步驟已經完成。視程式庫的最新版本而定,所產生程式碼中的程式庫版本編號可能有所不同。 
// ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
儘管程式碼研究室提及各種版本,仍建議一律使用最新版本的程式庫。
- 建立名為 
GameViewModel的新 Kotlin 類別檔案。在「Android」視窗中,於「ui.game」資料夾上按一下滑鼠右鍵。選取「New」>「Kotlin File/Class」。 

- 輸入名稱 
GameViewModel,然後從清單中選取「Class」。 - 將 
GameViewModel變更為ViewModel的子類別。ViewModel為抽象類別,因此您必須將其擴充,才能在應用程式中使用。請參閱下方的GameViewModel類別定義。 
class GameViewModel : ViewModel() {
}
將 ViewModel 附加至片段
如要建立 ViewModel 與 UI 控制器 (活動 / 片段) 的關聯,請在 UI 控制器內建立 ViewModel 的參照 (物件)。
在這個步驟中,您會在對應的 UI 控制器 (GameFragment) 中建立 GameViewModel 的物件執行個體。
- 在 
GameFragment類別頂部新增GameViewModel類型的屬性。 - 使用 
by viewModels()Kotlin 屬性委派功能將GameViewModel初始化。您將在下一節深入瞭解。 
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 子句和委派類別執行個體來定義:
// Syntax for property delegation
var <property-name> : <property-type> by <delegate-class>()
在應用程式中,如使用預設的 GameViewModel 建構函式初始化檢視模型,則如下所示:
private val viewModel = GameViewModel()
裝置經過設定變更後,應用程式將失去 viewModel 參照的狀態。舉例來說,如果您旋轉裝置,系統就會刪除並重新建立活動,而您將再次擁有具備初始狀態的新檢視模型。
請改用屬性委派方法,並將 viewModel 物件的責任委派給另一個名為 viewModels 的類別。這代表當您存取 viewModel 物件時,該物件會由委派類別 viewModels 於內部進行處理。委派類別會在第一次存取時為您建立 viewModel 物件,並透過設定變更保留其值,並在要求時傳回該值。
5. 將資料移至 ViewModel
將應用程式的 UI 資料與 UI 控制器 (Activity / Fragment 類別) 分離,以便您充分遵循上述單一責任原則。您的活動和片段負責在畫面中產生檢視畫面和資料,ViewModel 則負責保留及處理 UI 所需的所有資料。
在這項工作中,您必須將資料變數從 GameFragment 移至 GameViewModel 類別。
- 將資料變數 
score、currentWordCount、currentScrambledWord移至GameViewModel類別。 
class GameViewModel : ViewModel() {
    private var score = 0
    private var currentWordCount = 0
    private var currentScrambledWord = "test"
...
- 請注意未解決的參照錯誤。這是因為屬性僅供 
ViewModel使用,且無法由 UI 控制器進行存取。您將在下一個步驟修正這些錯誤。 
如要解決這個問題,屬性的可見度修飾符不得為 public,資料不可由其他類別編輯。此操作具有風險,因為外部類別可能會以非預期的方式,變更未遵循檢視模式中指定遊戲規則的資料。舉例來說,外部類別可以將 score 變更為負值。
ViewModel 內的資料應可編輯,因此應為 private 和 var。在 ViewModel 外部,資料應可供讀取,但無法編輯,因此資料應以 public 和 val 的形式呈現。為了達成這個行為,Kotlin 提供名為幕後屬性的功能。
幕後屬性
幕後屬性可讓您從 getter 傳回確切物件以外的項目。
您已瞭解 Kotlin 架構會為每個屬性產生 getter 和 setter。
getter 和 setter 方法可覆寫此類方法 (一或兩種),並提供您自訂的行為。如要實作幕後屬性,您將會覆寫 getter 方法,以傳回唯讀資料版本。幕後屬性範例:
// 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 控制器等其他類別存取。由於只有get()方法遭到覆寫,因此這個屬性不可變動且為唯讀。外部類別存取這個屬性時,系統會傳回_count的值,且該值無法修改。這種做法可確保ViewModel中的應用程式資料不會受到外部類別非必要和不安全的變更,但可讓外部呼叫者安全地存取其值。 
將幕後屬性新增至 currentScrambledWord
- 在 
GameViewModel中,變更currentScrambledWord宣告以新增幕後屬性。目前您只能在GameViewModel中存取及編輯_currentScrambledWord。UI 控制器GameFragment可以使用唯讀屬性currentScrambledWord讀取其值。 
private var _currentScrambledWord = "test"
val currentScrambledWord: String
   get() = _currentScrambledWord
- 在 
GameFragment中,更新updateNextWordOnScreen()方法,以使用唯讀的viewModel屬性currentScrambledWord。 
private fun updateNextWordOnScreen() {
   binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
}
- 在 
GameFragment中,刪除onSubmitWord()和onSkipWord()方法中的程式碼。您將於稍後實作這些方法。您現在應該能夠正確編譯程式碼,而不會產生錯誤。 
6. ViewModel 的生命週期
只要活動或片段的範圍保持運作,該架構就會使 ViewModel 保持運作。如果 ViewModel 的擁有者因設定變更 (例如螢幕旋轉) 而遭到刪除,系統並不會將其刪除。擁有者的新執行個體會重新連線至現有的 ViewModel 執行個體,如下圖所示:
瞭解 ViewModel 生命週期
在 GameViewModel 和 GameFragment 中新增記錄功能,以進一步瞭解 ViewModel 的生命週期。
- 在 
GameViewModel.kt中,新增含有記錄陳述式的init區塊。 
class GameViewModel : ViewModel() {
   init {
       Log.d("GameFragment", "GameViewModel created!")
   }
   ...
}
Kotlin 會提供初始化器區塊 (也稱為 init 區塊),做為物件執行個體初始化期間,所需初始設定程式碼的位置。初始化器區塊的前面會加上 init 關鍵字,後為大括號 {}。這個程式碼區塊會在首次建立並初始化物件執行個體時執行。
- 在 
GameViewModel類別中,覆寫onCleared()方法。在您卸離相關片段或活動完成後,系統會將ViewModel刪除。在ViewModel刪除之前,系統會呼叫onCleared()回呼。 - 在 
onCleared()中新增記錄陳述式,以追蹤GameViewModel生命週期。 
override fun onCleared() {
    super.onCleared()
    Log.d("GameFragment", "GameViewModel destroyed!")
}
- 在 
onCreateView()的GameFragment中找到繫結物件參照後,請新增記錄陳述式以記錄片段的建立作業。初次建立及每次重新建立 (例如設定變更等事件) 片段時,系統會觸發onCreateView()回呼。 
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
}
- 在 
GameFragment中覆寫onDetach()回呼方法,以在對應的活動和片段刪除時呼叫此方法。 
override fun onDetach() {
    super.onDetach()
    Log.d("GameFragment", "GameFragment destroyed!")
}
- 在 Android Studio 中執行應用程式,開啟「Logcat」視窗,然後篩選 
GameFragment。請注意,GameFragment和GameViewModel已建立。 
com.example.android.unscramble D/GameFragment: GameFragment created/re-created! com.example.android.unscramble D/GameFragment: GameViewModel created!
- 在裝置或模擬器上啟用自動旋轉設定,並變更螢幕方向數次。每次都會刪除並重新建立 
GameFragment,但GameViewModel只會建立一次,而且不會在每次呼叫時重新建立或刪除。 
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!
- 離開遊戲,或使用返回箭頭離開應用程式。
GameViewModel已刪除,並呼叫onCleared()回呼。GameFragment已刪除。 
com.example.android.unscramble D/GameFragment: GameViewModel destroyed! com.example.android.unscramble D/GameFragment: GameFragment destroyed!
7. 填入 ViewModel
在這項工作中,您將使用輔助方法進一步填入 GameViewModel,以便取得下一個字詞、驗證玩家的字詞能否增加分數,並檢查字詞計數來結束遊戲。
延遲初始化
通常在宣告變數時,您必須先提供初始值。不過,如果您還沒準備好指派值,可以稍後再進行初始化。為了延遲在 Kotlin 中初始化屬性,您可以使用關鍵字 lateinit,表示延遲初始化。如果您確保在使用前先初始化屬性,可以使用 lateinit 宣告屬性。記憶體必須先初始化,才能分配給變數。如果您在初始化之前就嘗試存取變數,應用程式將會異常終止。
取得下一個字詞
在 GameViewModel 類別中建立 getNextWord() 方法,且具備下列功能:
- 從 
allWordsList取得隨機字詞,並將其指派給currentWord. - 將 
currentWord中的字母打散,以產生打散的字詞,並將其指派給currentScrambledWord - 處理打散與未打散字詞相同的情形。
 - 請確定您在遊戲期間不會重複出現相同的字詞。
 
請在 GameViewModel 類別中執行下列步驟:
- 於 
GameViewModel,中,新增MutableList<String>類型的新類別變數 (名為wordsList),以保留遊戲中使用的字詞清單,避免重複出現。 - 新增另一個名為 
currentWord的類別變數,以保留玩家嘗試重組的字詞。由於您稍後會初始化此屬性,請使用lateinit關鍵字。 
private var wordsList: MutableList<String> = mutableListOf()
private lateinit var currentWord: String
- 在 
init區塊上新增名為getNextWord()的新private方法,且無不會傳回任何內容的參數。 - 從 
allWordsList取得隨機字詞,並將其指派給currentWord。 
private fun getNextWord() {
   currentWord = allWordsList.random()
}
- 在 
getNextWord()中,將currentWord字串轉換為字元陣列,並將其指派給名為tempWord的新val。如要打散字詞,請使用 Kotlin 方法shuffle()隨機變換此陣列中的字元。 
val tempWord = currentWord.toCharArray()
tempWord.shuffle()
Array 與 MutableList 相似,但其初始化時有固定的大小。Array 無法展開或縮減大小 (您必須複製陣列才能調整大小),而 MutableList 具有 add() 和 remove() 函式,因此可以調整大小。
- 有時,隨機變換後的字元順序會與原始字詞相同。在要隨機變換的呼叫周圍加上下列 
while迴圈,以在打散字詞不同於原始字詞前持續進行迴圈。 
while (String(tempWord).equals(currentWord, false)) {
    tempWord.shuffle()
}
- 新增 
if-else區塊,以確認是否已使用字詞。如果wordsList包含currentWord,請呼叫getNextWord()。如果沒有,請以剛打散的字詞更新_currentScrambledWord值、增加字詞計數,並將新字詞新增至wordsList。 
if (wordsList.contains(currentWord)) {
    getNextWord()
} else {
    _currentScrambledWord = String(tempWord)
    ++currentWordCount
    wordsList.add(currentWord)
}
- 以下是完整的 
getNextWord()方法,供您參考。 
/*
* 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」。
- 執行應用程式。請注意,第一個字詞一律為「test」。
 - 如要在應用程式起始處顯示打散的字詞,請呼叫 
getNextWord()方法,藉此讓系統更新currentScrambledWord。呼叫GameViewModelinit區塊中的getNextWord()方法。 
init {
    Log.d("GameFragment", "GameViewModel created!")
    getNextWord()
}
- 在 
_currentScrambledWord屬性中加入lateinit修飾符。由於未提供初始值,請明確提及String資料類型。 
private lateinit var _currentScrambledWord: String
- 執行應用程式。請注意,應用程式啟動時會顯示新的打散字詞。太棒了!
 

新增 Helper 方法
接下來,請加入 Helper 方法來處理和修改 ViewModel 中的資料。您將在後續工作中使用此方法。
- 在 
GameViewModel類別中,新增另一個nextWord().方法。接著從清單中取得下一個字詞,並在字詞計數少於MAX_NO_OF_WORDS時傳回true。 
/*
* 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
}
8. 對話方塊
在範例程式碼中,即使已遊玩 10 個字詞,遊戲也不會結束。請修改應用程式,在使用者遊玩 10 個字詞後結束遊戲,並顯示含有最終分數的對話方塊。使用者還可以選擇重新遊玩或離開遊戲。

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

- 快訊對話方塊
 - 標題 (選填)
 - 訊息
 - 文字按鈕
 
實作最終分數對話方塊
使用質感設計元件庫中的 MaterialAlertDialog,在應用程式中加入符合質感設計指南的對話方塊。由於對話方塊與 UI 相關,因此 GameFragment 將負責建立並顯示最終分數對話方塊。
- 首先,在 
score變數中新增幕後屬性。在GameViewModel中,將score變數宣告變更為以下內容。 
private var _score = 0
val score: Int
   get() = _score
- 在 
GameFragment中,新增名為showFinalScoreDialog()的私人函式。如要建立MaterialAlertDialog,請使用MaterialAlertDialogBuilder類別逐步建立對話方塊的內容。使用片段的requireContext()方法呼叫傳遞內容的MaterialAlertDialogBuilder建構函式。requireContext()方法會傳回非空值的Context。 
/*
* Creates and shows an AlertDialog with the final score.
*/
private fun showFinalScoreDialog() {
   MaterialAlertDialogBuilder(requireContext())
}
顧名思義,Context 是指應用程式、活動或片段的結構定義或目前狀態。其包含與活動、片段或應用程式相關的資訊。其通常用於存取資源、資料庫和其他系統服務。在這個步驟中,您必須傳遞片段結構定義,以建立快訊對話方塊。
如果 Android Studio 顯示提示,請 import com.google.android.material.dialog.MaterialAlertDialogBuilder。
- 加入程式碼以設定快訊對話方塊的標題,請使用 
strings.xml的字串資源。 
MaterialAlertDialogBuilder(requireContext())
   .setTitle(getString(R.string.congratulations))
- 設定訊息以顯示最終分數,並使用先前新增的分數變數 (
viewModel.score) 唯讀版本。 
   .setMessage(getString(R.string.you_scored, viewModel.score))
- 使用 
setCancelable()方法並傳遞false,使快訊對話方塊在按下返回鍵時無法取消。 
    .setCancelable(false)
- 使用 
setNegativeButton()和setPositiveButton()方法新增「離開」和「再玩一次」兩個文字按鈕。從 lambda 分別呼叫exitGame()和restartGame()。 
    .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 函式。
- 最後加入 
show(),即可建立並顯示快訊對話方塊。 
      .show()
- 以下是完整的 
showFinalScoreDialog()方法,供您參考。 
/*
* 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()
}
9. 實作提交按鈕的 OnClickListener
在這項工作中,您要使用 ViewModel 和新增的快訊對話方塊,實作「Submit」按鈕點擊事件監聽器的遊戲邏輯。
顯示打散的字詞
- 如果您尚未在 
GameFragment中刪除onSubmitWord()內的程式碼 (輕觸「Submit」按鈕時會呼叫此程式碼),請先完成這項操作。 - 請在 
viewModel.nextWord()方法的傳回值新增檢查。如果為true,則可以使用其他字詞,因此請使用updateNextWordOnScreen()更新畫面上打散的字詞。否則遊戲將會結束,並顯示含有最終分數的快訊對話方塊。 
private fun onSubmitWord() {
    if (viewModel.nextWord()) {
        updateNextWordOnScreen()
    } else {
        showFinalScoreDialog()
    }
}
- 執行應用程式!使用一些字詞進行遊戲。別忘了,您尚未實作「Skip」按鈕,因此無法略過該字詞。
 - 請注意,文字欄位不會更新,因此玩家必須手動刪除上一個字詞。快訊對話方塊中的最終分數永遠為零。您將在後續步驟中修正這些錯誤。
 
 
新增 Helper 方法以驗證玩家字詞
- 在 
GameViewModel中,新增名為increaseScore()的新私人方法,且不含參數和傳回值。透過SCORE_INCREASE將score變數提高。 
private fun increaseScore() {
   _score += SCORE_INCREASE
}
- 在 
GameViewModel中,新增名為isUserWordCorrect()的 Helper 方法,其會傳回Boolean並將玩家字詞String做為參數。 - 在 
isUserWordCorrect()中驗證玩家的字詞,如果答案正確無誤,則增加分數。這會更新快訊對話方塊中的最終分數。 
fun isUserWordCorrect(playerWord: String): Boolean {
   if (playerWord.equals(currentWord, true)) {
       increaseScore()
       return true
   }
   return false
}
更新文字欄位
顯示文字欄位中的錯誤
針對 Material 文字欄位,TextInputLayout 內建能顯示錯誤訊息的功能。舉例來說,在下列文字欄位中,標籤顏色有所變更、顯示錯誤圖示、顯示錯誤訊息等。

如要在文字欄位中顯示錯誤,您可以在程式碼中以動態方式設定錯誤訊息,或在版面配置檔案中以靜態方式設定錯誤訊息。設定及重設程式碼中錯誤的範例如下:
// Set error text
passwordLayout.error = getString(R.string.error)
// Clear error text
passwordLayout.error = null
在範例程式碼中,您會發現已定義 setErrorTextField(error: Boolean) Helper 方法,以協助您設定及重設文字欄位中的錯誤。根據是否要在文字欄位中顯示錯誤,使用 true 或 false 做為輸入參數,呼叫此方法。
範例程式碼中的程式碼片段
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() 方法。使用者提交字詞時,您可透過檢查原始字詞,驗證使用者的答案。如果字詞正確無誤,請前往下一個字詞 (如果遊戲已結束,則顯示對話方塊)。如果字詞有誤,請在文字欄位中顯示錯誤,並繼續使用目前的字詞。
- 在 
onSubmitWord()開頭的GameFragment,中,建立名為playerWord的val。從binding變數的文字欄位中擷取文字,將玩家的字詞儲存在其中。 
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()
    ...
}
- 在 
onSubmitWord()中的playerWord宣告下方,驗證玩家的字詞。新增if陳述式,以使用isUserWordCorrect()方法檢查玩家的字詞,並傳入playerWord。 - 在 
if區塊中,重設文字欄位,呼叫setErrorTextField傳入false。 - 將現有程式碼移至 
if區塊中。 
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()
    if (viewModel.isUserWordCorrect(playerWord)) {
        setErrorTextField(false)
        if (viewModel.nextWord()) {
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    }
}
- 如果使用者字詞不正確,請在文字欄位中顯示錯誤訊息。將 
else區塊新增至上述if區塊,並呼叫setErrorTextField()傳入true。已完成的onSubmitWord()方法應如下所示: 
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()
    if (viewModel.isUserWordCorrect(playerWord)) {
        setErrorTextField(false)
        if (viewModel.nextWord()) {
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    } else {
        setErrorTextField(true)
    }
}
- 執行應用程式,並透過一些字詞進行遊戲。如果玩家的字詞正確無誤,按一下「Submit」按鈕即可清除字詞,否則系統會顯示「Try again!」的訊息。請注意,「略過」按鈕目前仍未運作。您將在下一個工作中加入此實作。
 

10. 實作略過按鈕
在這項工作中,您要新增 onSkipWord() 實作,用於處理使用者輕觸「Skip」按鈕時的情況。
- 與 
onSubmitWord()類似,請在onSkipWord()方法中新增條件。如為true,請在畫面上顯示文字並重設文字欄位。如為false,且這回合沒有其他字詞,則顯示含有最終分數的快訊對話方塊。 
/*
* Skips the current word without changing the score.
*/
private fun onSkipWord() {
    if (viewModel.nextWord()) {
        setErrorTextField(false)
        updateNextWordOnScreen()
    } else {
        showFinalScoreDialog()
    }
}
- 執行您的應用程式。遊玩遊戲。請注意,「略過」和「提交」按鈕可正常運作。非常好!
 
11. 確認 ViewModel 將會保留資料
在這項工作中,於 GameFragment 中新增記錄,以觀察在設定變更期間,您的應用程式資料是否會保留在 ViewModel 中。如要存取 GameFragment 中的 currentWordCount,您必須使用幕後屬性公開唯讀版本。
- 在 
GameViewModel中,在currentWordCount變數上按一下滑鼠右鍵,然後選取「Refactor」>「Rename...」。在新名稱前加上底線_currentWordCount。 - 新增支援欄位。
 
private var _currentWordCount = 0
val currentWordCount: Int
   get() = _currentWordCount
- 在 
GameFragment內的onCreateView()中,在回傳敘述上方新增另一個記錄,以列印應用程式資料、字詞、分數和字詞計數。 
Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
       "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
- 在 Android Studio 中,開啟「Logcat」,並篩選 
GameFragment。執行應用程式,使用一些字詞進行遊戲。變更裝置的螢幕方向。片段 (UI 控制器) 會刪除並重新建立。觀察記錄。您現在可以看到分數和字詞計數增加! 
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 和資料繫結,更新 UI 上的分數值和字詞計數。
12. 更新遊戲重新啟動邏輯
- 再次執行應用程式,使用所有字詞進行遊戲。在「Congratulations!」快訊對話方塊中,按一下「PLAY AGAIN」。由於字詞計數現已達到 
MAX_NO_OF_WORDS值,因此應用程式無法讓您再玩一次。您必須將字詞計數重設為 0,才能再次從頭開始遊戲。 - 如要重設應用程式資料,請在 
GameViewModel中新增名為reinitializeData()的方法。將分數和字詞計數設為0。清除字詞清單並呼叫getNextWord()方法。 
/*
* Re-initializes the game data to restart the game.
*/
fun reinitializeData() {
   _score = 0
   _currentWordCount = 0
   wordsList.clear()
   getNextWord()
}
- 在 
GameFragment頂端的方法restartGame()中,呼叫新建立的方法reinitializeData()。 
private fun restartGame() {
   viewModel.reinitializeData()
   setErrorTextField(false)
   updateNextWordOnScreen()
}
- 再次執行應用程式。開始遊戲。看到祝賀對話方塊時,請按一下「Play Again」。現在,您應該可以成功再次遊玩遊戲!
 
應用程式最終畫面應如下所示。這個遊戲會顯示十個隨機打散的字詞,讓玩家進行重組。您可選擇「略過」字詞,或猜測字詞,然後輕觸「提交」。如果答案正確,分數將會增加。答案不正確會在文字欄位中顯示錯誤狀態。隨著每個新字詞的進行,字詞計數也會增加。
請注意,畫面上顯示的分數和字詞計數尚未更新。但這些資訊仍會儲存在檢視模型中,並在設定變更 (例如裝置旋轉) 期間保留。您將在後續的程式碼研究室中,更新畫面上的分數和字詞計數。
   
遊戲將在 10 個字詞後結束,畫面上會出現快訊對話方塊,顯示最終分數和結束遊戲或再玩一次的選項。

恭喜!您已建立第一個 ViewModel,並成功儲存資料!
13. 解決方案程式碼
GameFragment.kt
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.example.android.unscramble.R
import com.example.android.unscramble.databinding.GameFragmentBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
/**
 * Fragment where the game is played, contains the game logic.
 */
class GameFragment : Fragment() {
    private val viewModel: GameViewModel by viewModels()
    // Binding object instance with access to the views in the game_fragment.xml layout
    private lateinit var binding: GameFragmentBinding
    // Create a ViewModel the first time the fragment is created.
    // If the fragment is re-created, it receives the same GameViewModel instance created by the
    // first fragment
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Inflate the layout XML file and return a binding object instance
        binding = GameFragmentBinding.inflate(inflater, container, false)
        Log.d("GameFragment", "GameFragment created/re-created!")
        Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
                "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
        return binding.root
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // Setup a click listener for the Submit and Skip buttons.
        binding.submit.setOnClickListener { onSubmitWord() }
        binding.skip.setOnClickListener { onSkipWord() }
        // Update the UI
        updateNextWordOnScreen()
        binding.score.text = getString(R.string.score, 0)
        binding.wordCount.text = getString(
            R.string.word_count, 0, MAX_NO_OF_WORDS)
    }
    /*
    * Checks the user's word, and updates the score accordingly.
    * Displays the next scrambled word.
    * After the last word, the user is shown a Dialog with the final score.
    */
    private fun onSubmitWord() {
        val playerWord = binding.textInputEditText.text.toString()
        if (viewModel.isUserWordCorrect(playerWord)) {
            setErrorTextField(false)
            if (viewModel.nextWord()) {
                updateNextWordOnScreen()
            } else {
                showFinalScoreDialog()
            }
        } else {
            setErrorTextField(true)
        }
    }
    /*
    * Skips the current word without changing the score.
    */
    private fun onSkipWord() {
        if (viewModel.nextWord()) {
            setErrorTextField(false)
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    }
    /*
     * Gets a random word for the list of words and shuffles the letters in it.
     */
    private fun getNextScrambledWord(): String {
        val tempWord = allWordsList.random().toCharArray()
        tempWord.shuffle()
        return String(tempWord)
    }
    /*
    * 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()
    }
    /*
     * Re-initializes the data in the ViewModel and updates the views with the new data, to
     * restart the game.
     */
    private fun restartGame() {
        viewModel.reinitializeData()
        setErrorTextField(false)
        updateNextWordOnScreen()
    }
    /*
     * Exits the game.
     */
    private fun exitGame() {
        activity?.finish()
    }
    override fun onDetach() {
        super.onDetach()
        Log.d("GameFragment", "GameFragment destroyed!")
    }
    /*
    * Sets and resets the text field error status.
    */
    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
        }
    }
    /*
     * Displays the next scrambled word on screen.
     */
    private fun updateNextWordOnScreen() {
        binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
    }
}
GameViewModel.kt
import android.util.Log
import androidx.lifecycle.ViewModel
/**
 * ViewModel containing the app data and methods to process the data
 */
class GameViewModel : ViewModel(){
    private var _score = 0
    val score: Int
        get() = _score
    private var _currentWordCount = 0
    val currentWordCount: Int
        get() = _currentWordCount
    private lateinit var _currentScrambledWord: String
    val currentScrambledWord: String
        get() = _currentScrambledWord
    // List of words used in the game
    private var wordsList: MutableList<String> = mutableListOf()
    private lateinit var currentWord: String
    init {
        Log.d("GameFragment", "GameViewModel created!")
        getNextWord()
    }
    override fun onCleared() {
        super.onCleared()
        Log.d("GameFragment", "GameViewModel destroyed!")
    }
    /*
    * 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)
        }
    }
    /*
    * Re-initializes the game data to restart the game.
    */
    fun reinitializeData() {
       _score = 0
       _currentWordCount = 0
       wordsList.clear()
       getNextWord()
    }
    /*
    * Increases the game score if the player's word is correct.
    */
    private fun increaseScore() {
        _score += SCORE_INCREASE
    }
    /*
    * Returns true if the player word is correct.
    * Increases the score accordingly.
    */
    fun isUserWordCorrect(playerWord: String): Boolean {
        if (playerWord.equals(currentWord, true)) {
            increaseScore()
            return true
        }
        return false
    }
    /*
    * Returns true if the current word count is less than MAX_NO_OF_WORDS
    */
    fun nextWord(): Boolean {
        return if (_currentWordCount < MAX_NO_OF_WORDS) {
            getNextWord()
            true
        } else false
    }
}
14. 摘要
- Android 應用程式架構指南建議將具有不同責任的類別分離,並透過模型使用 UI。
 - UI 控制器是一種 UI 類別,例如 
Activity或Fragment。UI 控制器只能包含處理 UI 和作業系統互動的邏輯;其不應做為在 UI 中顯示的資料來源。將該資料和任何相關的邏輯存放在ViewModel中。 ViewModel類別會儲存和管理 UI 相關資料。ViewModel類別可在螢幕旋轉等變更時保留資料。ViewModel是建議使用的 Android 架構元件之一。
  
