將資料儲存於 ViewModel 中

1. 事前準備

在先前的程式碼研究室中,您已瞭解活動和片段的生命週期,以及設定變更的相關生命週期問題。如要儲存應用程式資料,儲存執行個體狀態是一個選擇,但有其限制。在本程式碼研究室中,您將瞭解如何使用 Android Jetpack 程式庫設計應用程式,並在設定變更時保留應用程式資料的完善方法。

Android Jetpack 內含一系列程式庫,可讓您更輕鬆地開發優質 Android 應用程式。這些程式庫可協助您遵循最佳做法、無需編寫樣板程式碼,並簡化複雜的工作,讓您專心處理應用程式邏輯等的重要程式碼。

Android 架構元件Android Jetpack 程式庫的一部分,旨在協助您設計具備優良架構的應用程式。架構元件可提供應用程式架構的相關指引,這也是建議您採用的最佳做法。

應用程式架構是一組設計規則。就像房屋的藍圖一樣,架構即為應用程式的結構。優良的應用程式架構可讓程式碼未來數年內保持穩定、具有彈性、可擴充且易於維護。

在本程式碼研究室中,您將瞭解如何使用 ViewModel 架構元件來儲存應用程式資料。如果在設定變更或其他事件期間,刪除架構並重新建立活動和片段,儲存的資料不會遺失。

必要條件

  • 如何從 GitHub 下載原始碼,並在 Android Studio 中開啟。
  • 如何使用活動和片段在 Kotlin 中建構並執行基本 Android 應用程式。
  • 瞭解 Material 文字欄位和常見的 UI 小工具,例如 TextViewButton
  • 如何在應用程式中使用檢視繫結。
  • 活動和片段生命週期的基本概念。
  • 如何將記錄資訊新增至應用程式,並在 Android Studio 中使用 Logcat 讀取記錄。

課程內容

建構項目

  • Unscramble 遊戲應用程式,可讓使用者猜測打散的字詞。

軟硬體需求

  • 已安裝 Android Studio 的電腦。
  • Unscramble 應用程式的範例程式碼

2. 範例應用程式總覽

遊戲總覽

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

8edd6191a40a57e1.png 992bf57f066caf49.png b82a9817b5ec4d11.png

下載範例程式碼

本程式碼研究室提供範例程式碼,可延伸至本程式碼研究室所教授的功能。範例程式碼含有來自先前程式碼研究室的程式碼,可能會讓您感到既熟悉又陌生。您將在後續的程式碼研究室中進一步瞭解陌生的程式碼。

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

  1. 前往專案所在的 GitHub 存放區頁面。
  2. 驗證分支版本名稱與程式碼研究室中指定的分支版本名稱相符。例如,在下列螢幕截圖中,分支版本名稱為「main」

1e4c0d2c081a8fd2.png

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

1debcf330fd04c7b.png

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

在 Android Studio 中開啟專案

  1. 啟動 Android Studio。
  2. 在「Welcome to Android Studio」視窗中,按一下「Open」

d8e9dbdeafe9038a.png

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

8d1fda7396afe8e5.png

  1. 在檔案瀏覽器中,前往已解壓縮的專案資料夾所在的位置 (可能位於「Downloads」資料夾中)。
  2. 按兩下該專案資料夾。
  3. 等待 Android Studio 開啟專案。
  4. 按一下「Run」按鈕 8de56cba7583251f.png 即可建構並執行應用程式,請確認應用程式的建構符合預期。

範例程式碼總覽

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

範例程式碼相關問題

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

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

應用程式的主要問題

設定變更時 (例如裝置螢幕方向變更),範例應用程式不會儲存及還原應用程式狀態和資料。

您可以使用 onSaveInstanceState() 回呼解決此問題。不過,使用 onSaveInstanceState() 方法時,您必須編寫額外的程式碼將狀態儲存在套件中,並實作邏輯以擷取該狀態。此外,可儲存的資料量極少。

您可以使用在本課程所學到的 Android 架構元件來解決這些問題。

範例程式碼逐步操作說明

您下載的範例程式碼包含已為您預先設計的遊戲畫面版面配置。本課程重點為實作遊戲邏輯。您需要使用架構元件來實作建議的應用程式架構,並解決上述問題。以下是部分檔案的簡要逐步操作說明,可協助您快速上手。

game_fragment.xml

  • 在「設計」檢視畫面中開啟 res/layout/game_fragment.xml
  • 這包含應用程式中唯一畫面的版面配置,也就是遊戲畫面。
  • 此版面配置包含玩家字詞的文字欄位,以及顯示分數和字詞計數的 TextViews。另外還提供說明、「Submit」按鈕和「Skip」按鈕,方便玩遊戲。

main_activity.xml

以單一遊戲片段定義主要活動版面配置。

res/values 資料夾

您已熟悉此資料夾中的資源檔案。

  • colors.xml 包含應用程式中使用的主題色彩
  • strings.xml 包含應用程式所需的所有字串
  • themesstyles 資料夾內含應用程式的 UI 自訂項目

MainActivity.kt

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

ListOfWords.kt

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

GameFragment.kt

這是應用程式中唯一的片段,也是大部分遊戲動作發生處:

  • 變數是根據目前打散的字詞 (currentScrambledWord)、字詞計數 (currentWordCount) 和分數 (score) 所定義。
  • 已定義可存取名為 bindinggame_fragment 檢視畫面的繫結物件執行個體。
  • onCreateView() 函式會使用繫結物件加載 game_fragment 版面配置 XML。
  • onViewCreated() 函式會設定按鈕點選監聽器,並更新 UI。
  • onSubmitWord() 是「提交」按鈕的點擊事件監聽器,此函式會顯示下一個打散的字詞、清除文字欄位,並在未驗證玩家字詞的情況下增加分數和字詞計數。
  • onSkipWord() 是「略過」按鈕的點擊事件監聽器,此函式會更新與 onSubmitWord() 類似的 UI (分數除外)。
  • getNextScrambledWord() 是一項輔助函式,其可從字詞清單中挑選隨機字詞,並隨機排序這些字母。
  • 系統會分別使用 restartGame()exitGame() 函式重新啟動及結束遊戲,您稍後將會使用這些函式。
  • setErrorTextField() 可清除文字欄位內容,並重設錯誤狀態。
  • updateNextWordOnScreen() 函式可顯示新的打散字詞。

3. 瞭解應用程式架構

架構可提供相關規範,協助您在應用程式內分配類別間的責任。設計良好的應用程式架構可協助您擴大應用程式,並於日後擴充其他功能。此外,也能讓團隊更輕鬆進行協作。

最常見的架構原則為:關注點分離,以及透過模型使用 UI。

關注點分離

關注點分離的設計原則為,應用程式應區分成不同類別,每個類別具有不同責任。

透過模型使用 UI

另一個重要原則是,您應透過模型 (建議為持續性模型) 使用 UI。模型是負責處理應用程式資料的元件。模型與應用程式中的 Views 和應用程式元件無關,因此不受應用程式的生命週期和相關關注點影響。

Android 架構中的主要類別或元件包括 UI 控制器 (活動/片段)、ViewModelLiveDataRoom。這些元件負責生命週期的部分複雜度,且可避免發生生命週期相關問題。您將在後續的程式碼研究室中學習 LiveDataRoom

下圖為架構的基本部分:

597074ed0d08947b.png

UI 控制器 (活動/片段)

活動和片段為 UI 控制器。UI 控制器可控制 UI,方法包括在畫面產生檢視畫面、擷取使用者事件,以及與使用者互動之 UI 相關的任何其他內容。應用程式中的資料或與這些資料相關的決策邏輯不應屬於 UI 控制器類別。

Android 系統可能會因為特定使用者互動或記憶體不足等系統情況,而隨時刪除 UI 控制器。由於這些事件不在您的控管之下,您不應在 UI 控制器中儲存任何應用程式資料或狀態。反之,應該在 ViewModel 中新增資料相關的決策邏輯。

舉例來說,Unscramble 應用程式中的打散字詞、分數和字詞計數會顯示於片段 (UI 控制器) 中。決策程式碼應位於 ViewModel 中,例如判斷下一個打散的字詞,以及分數和字詞計數的計算。

ViewModel

ViewModel 是檢視畫面中顯示的應用程式資料模型。模型是負責處理應用程式資料的元件。其可讓您的應用程式遵循透過模型使用 UI 的架構原則。

活動或片段遭到 Android 架構刪除並重新建立時,未刪除的應用程式相關資料會由 ViewModel 進行儲存。在設定變更期間,系統會自動保留 ViewModel 物件 (不會像活動或片段執行個體一般遭到刪除),讓處於保留狀態的資料立即用於下一個活動或片段執行個體。

如要在應用程式中實作 ViewModel,請擴充架構元件庫中的 ViewModel 類別,並將應用程式資料儲存在該類別中。

總結:

片段 / 活動 (UI 控制器) 責任

ViewModel 責任

活動和片段應負責在畫面中產生檢視畫面和資料,並回應使用者事件。

ViewModel 負責保留及處理 UI 所需的所有資料。其不得存取檢視區塊階層 (例如檢視繫結物件),或保留活動或片段的參照。

4. 新增 ViewModel

在這項工作中,您需將 ViewModel 新增至應用程式,以儲存應用程式資料 (打散的字詞、字詞計數和分數)。

您的應用程式架構如下。MainActivity 包含 GameFragment,而 GameFragment 會從 GameViewModel 存取遊戲的相關資訊。

2b29a13dde3481c3.png

  1. 在 Android Studio 中,「Android」 視窗的「Gradle Scripts」資料夾下,開啟 build.gradle(Module:Unscramble.app) 檔案。
  2. 如要在應用程式中使用 ViewModel,請確認 dependencies 區塊中具有 ViewModel 程式庫依附元件。此步驟已經完成。視程式庫的最新版本而定,所產生程式碼中的程式庫版本編號可能有所不同。
// ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'

儘管程式碼研究室提及各種版本,仍建議一律使用最新版本的程式庫。

  1. 建立名為 GameViewModel 的新 Kotlin 類別檔案。在「Android」視窗中,於「ui.game」資料夾上按一下滑鼠右鍵。選取「New」>「Kotlin File/Class」

d48361a4f73d4acb.png

  1. 輸入名稱 GameViewModel,然後從清單中選取「Class」
  2. GameViewModel 變更為 ViewModel 的子類別。ViewModel 為抽象類別,因此您必須將其擴充,才能在應用程式中使用。請參閱下方的 GameViewModel 類別定義。
class GameViewModel : ViewModel() {
}

將 ViewModel 附加至片段

如要建立 ViewModel 與 UI 控制器 (活動 / 片段) 的關聯,請在 UI 控制器內建立 ViewModel 的參照 (物件)。

在這個步驟中,您會在對應的 UI 控制器 (GameFragment) 中建立 GameViewModel 的物件執行個體。

  1. GameFragment 類別頂部新增 GameViewModel 類型的屬性。
  2. 使用 by viewModels() Kotlin 屬性委派功能將 GameViewModel 初始化。您將在下一節深入瞭解。
private val viewModel: GameViewModel by viewModels()
  1. 如果 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 類別。

  1. 將資料變數 scorecurrentWordCountcurrentScrambledWord 移至 GameViewModel 類別。
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 提供名為幕後屬性的功能。

幕後屬性

幕後屬性可讓您從 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

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

6. ViewModel 的生命週期

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

91227008b74bf4bb.png

瞭解 ViewModel 生命週期

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

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

   ...
}

Kotlin 會提供初始化器區塊 (也稱為 init 區塊),做為物件執行個體初始化期間,所需初始設定程式碼的位置。初始化器區塊的前面會加上 init 關鍵字,後為大括號 {}。這個程式碼區塊會在首次建立並初始化物件執行個體時執行。

  1. GameViewModel 類別中,覆寫 onCleared() 方法。在您卸離相關片段或活動完成後,系統會將 ViewModel 刪除。在 ViewModel 刪除之前,系統會呼叫 onCleared() 回呼。
  2. onCleared() 中新增記錄陳述式,以追蹤 GameViewModel 生命週期。
override fun onCleared() {
    super.onCleared()
    Log.d("GameFragment", "GameViewModel destroyed!")
}
  1. 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
}
  1. GameFragment 中覆寫 onDetach() 回呼方法,以在對應的活動和片段刪除時呼叫此方法。
override fun onDetach() {
    super.onDetach()
    Log.d("GameFragment", "GameFragment destroyed!")
}
  1. 在 Android Studio 中執行應用程式,開啟「Logcat」視窗,然後篩選 GameFragment。請注意,GameFragmentGameViewModel 已建立。
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
  1. 在裝置或模擬器上啟用自動旋轉設定,並變更螢幕方向數次。每次都會刪除並重新建立 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!
  1. 離開遊戲,或使用返回箭頭離開應用程式。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 類別中執行下列步驟:

  1. GameViewModel, 中,新增 MutableList<String> 類型的新類別變數 (名為 wordsList),以保留遊戲中使用的字詞清單,避免重複出現。
  2. 新增另一個名為 currentWord 的類別變數,以保留玩家嘗試重組的字詞。由於您稍後會初始化此屬性,請使用 lateinit 關鍵字。
private var wordsList: MutableList<String> = mutableListOf()
private lateinit var currentWord: String
  1. init 區塊上新增名為 getNextWord() 的新 private 方法,且無不會傳回任何內容的參數。
  2. allWordsList 取得隨機字詞,並將其指派給 currentWord
private fun getNextWord() {
   currentWord = allWordsList.random()
}
  1. getNextWord() 中,將 currentWord 字串轉換為字元陣列,並將其指派給名為 tempWord 的新 val。如要打散字詞,請使用 Kotlin 方法 shuffle() 隨機變換此陣列中的字元。
val tempWord = currentWord.toCharArray()
tempWord.shuffle()

ArrayMutableList 相似,但其初始化時有固定的大小。Array 無法展開或縮減大小 (您必須複製陣列才能調整大小),而 MutableList 具有 add()remove() 函式,因此可以調整大小。

  1. 有時,隨機變換後的字元順序會與原始字詞相同。在要隨機變換的呼叫周圍加上下列 while 迴圈,以在打散字詞不同於原始字詞前持續進行迴圈。
while (String(tempWord).equals(currentWord, false)) {
    tempWord.shuffle()
}
  1. 新增 if-else 區塊,以確認是否已使用字詞。如果 wordsList 包含 currentWord,請呼叫 getNextWord()。如果沒有,請以剛打散的字詞更新 _currentScrambledWord 值、增加字詞計數,並將新字詞新增至 wordsList
if (wordsList.contains(currentWord)) {
    getNextWord()
} else {
    _currentScrambledWord = String(tempWord)
    ++currentWordCount
    wordsList.add(currentWord)
}
  1. 以下是完整的 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」

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

8edd6191a40a57e1.png

新增 Helper 方法

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

  1. 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 個字詞後結束遊戲,並顯示含有最終分數的對話方塊。使用者還可以選擇重新遊玩或離開遊戲。

62aa368820ffbe31.png

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

快訊對話方塊剖析

f8650ca15e854fe4.png

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

實作最終分數對話方塊

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

  1. 首先,在 score 變數中新增幕後屬性。在 GameViewModel 中,將 score 變數宣告變更為以下內容。
private var _score = 0
val score: Int
   get() = _score
  1. 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

  1. 加入程式碼以設定快訊對話方塊的標題,請使用 strings.xml 的字串資源。
MaterialAlertDialogBuilder(requireContext())
   .setTitle(getString(R.string.congratulations))
  1. 設定訊息以顯示最終分數,並使用先前新增的分數變數 (viewModel.score) 唯讀版本。
   .setMessage(getString(R.string.you_scored, viewModel.score))
  1. 使用 setCancelable() 方法並傳遞 false,使快訊對話方塊在按下返回鍵時無法取消。
    .setCancelable(false)
  1. 使用 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 函式。

  1. 最後加入 show(),即可建立並顯示快訊對話方塊。
      .show()
  1. 以下是完整的 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」按鈕點擊事件監聽器的遊戲邏輯。

顯示打散的字詞

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

a4c660e212ce2c31.png 12a42987a0edd2c4.png

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

  1. GameViewModel 中,新增名為 increaseScore() 的新私人方法,且不含參數和傳回值。透過 SCORE_INCREASEscore 變數提高。
private fun increaseScore() {
   _score += SCORE_INCREASE
}
  1. GameViewModel 中,新增名為 isUserWordCorrect() 的 Helper 方法,其會傳回 Boolean 並將玩家字詞 String 做為參數。
  2. isUserWordCorrect() 中驗證玩家的字詞,如果答案正確無誤,則增加分數。這會更新快訊對話方塊中的最終分數。
fun isUserWordCorrect(playerWord: String): Boolean {
   if (playerWord.equals(currentWord, true)) {
       increaseScore()
       return true
   }
   return false
}

更新文字欄位

顯示文字欄位中的錯誤

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

520cc685ae1317ac.png

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

// Set error text
passwordLayout.error = getString(R.string.error)

// Clear error text
passwordLayout.error = null

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

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

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. onSubmitWord() 開頭的 GameFragment, 中,建立名為 playerWordval。從 binding 變數的文字欄位中擷取文字,將玩家的字詞儲存在其中。
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()
    ...
}
  1. onSubmitWord() 中的 playerWord 宣告下方,驗證玩家的字詞。新增 if 陳述式,以使用 isUserWordCorrect() 方法檢查玩家的字詞,並傳入 playerWord
  2. if 區塊中,重設文字欄位,呼叫 setErrorTextField 傳入 false
  3. 將現有程式碼移至 if 區塊中。
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() 方法應如下所示:
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!」的訊息。請注意,「略過」按鈕目前仍未運作。您將在下一個工作中加入此實作。

a10c7d77aa26b9db.png

10. 實作略過按鈕

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

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

11. 確認 ViewModel 將會保留資料

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

  1. GameViewModel 中,在 currentWordCount 變數上按一下滑鼠右鍵,然後選取「Refactor」>「Rename...」。在新名稱前加上底線 _currentWordCount
  2. 新增支援欄位。
private var _currentWordCount = 0
val currentWordCount: Int
   get() = _currentWordCount
  1. GameFragment 內的 onCreateView() 中,在回傳敘述上方新增另一個記錄,以列印應用程式資料、字詞、分數和字詞計數。
Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
       "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
  1. 在 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. 更新遊戲重新啟動邏輯

  1. 再次執行應用程式,使用所有字詞進行遊戲。在「Congratulations!」快訊對話方塊中,按一下「PLAY AGAIN」。由於字詞計數現已達到 MAX_NO_OF_WORDS 值,因此應用程式無法讓您再玩一次。您必須將字詞計數重設為 0,才能再次從頭開始遊戲。
  2. 如要重設應用程式資料,請在 GameViewModel 中新增名為 reinitializeData() 的方法。將分數和字詞計數設為 0。清除字詞清單並呼叫 getNextWord() 方法。
/*
* Re-initializes the game data to restart the game.
*/
fun reinitializeData() {
   _score = 0
   _currentWordCount = 0
   wordsList.clear()
   getNextWord()
}
  1. GameFragment 頂端的方法 restartGame() 中,呼叫新建立的方法 reinitializeData()
private fun restartGame() {
   viewModel.reinitializeData()
   setErrorTextField(false)
   updateNextWordOnScreen()
}
  1. 再次執行應用程式。開始遊戲。看到祝賀對話方塊時,請按一下「Play Again」。現在,您應該可以成功再次遊玩遊戲!

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

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

f332979d6f63d0e5.png 2803d4855f5d401f.png

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

d8e0111f5f160ead.png

恭喜!您已建立第一個 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 類別,例如 ActivityFragment。UI 控制器只能包含處理 UI 和作業系統互動的邏輯;其不應做為在 UI 中顯示的資料來源。將該資料和任何相關的邏輯存放在 ViewModel 中。
  • ViewModel 類別會儲存和管理 UI 相關資料。ViewModel 類別可在螢幕旋轉等變更時保留資料。
  • ViewModel 是建議使用的 Android 架構元件之一。

15. 瞭解詳情