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
。呼叫GameViewModel
init
區塊中的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 架構元件之一。