1. 事前準備
在先前的程式碼研究室中,您已瞭解活動的生命週期以及設定變更的相關生命週期問題。發生設定變更時,您可以透過不同的方式儲存應用程式的資料,例如使用 rememberSaveable
或儲存執行個體狀態。不過,這些選項可能會造成問題。在多數情況下,您可以使用 rememberSaveable
,但這可能表示要將邏輯保留在可組合函式之中或附近。隨著應用程式業務拓展,建議您將資料和邏輯移出可組合項。在本程式碼研究室中,您將瞭解如何利用 Android Jetpack 程式庫、ViewModel
和 Android 應用程式架構指南,在設定變更期間設計應用程式及保留應用程式資料。
Android Jetpack 程式庫是一系列的程式庫,可協助您以更輕鬆的方式開發優質 Android 應用程式。這些程式庫可協助您遵循最佳做法、無需編寫樣板程式碼並簡化複雜的工作,讓您可以專心處理應用程式邏輯等的重要程式碼。
「應用程式架構」是一組應用程式的設計規則,就像房屋的藍圖一樣,架構是應用程式的結構。良好的應用程式架構可讓程式碼在未來數年內保持穩定、具有彈性、可擴充、可測試且便於維護。如需應用程式架構和建議最佳做法,請參閱「應用程式架構指南」。
在本程式碼研究室中,您將瞭解如何使用 ViewModel
,這是 Android Jetpack 程式庫的架構元件之一,可儲存您的應用程式資料。如果在設定變更或其他事件期間刪除架構並重新建立活動,儲存的資料不會遺失。不過,如果活動因程序終止而刪除,資料就會遺失。ViewModel
只會透過快速活動重新建立來快取資料。
必要條件
- 對 Kotlin 的瞭解,包括函式、lambda 和無狀態可組合項。
- 對如何在 Jetpack Compose 中建構版面配置有基本瞭解
- 對 Material Design 有基本瞭解
課程內容
- Android 應用程式架構簡介
- 如何在應用程式中使用
ViewModel
類別 - 如何使用
ViewModel
,透過裝置設定變更保留 UI 資料
建構項目
- Unscramble 遊戲應用程式,可讓使用者猜測打散的字詞
軟硬體需求
- 最新版 Android Studio
- 網際網路連線,可下載範例程式碼
2. 應用程式總覽
遊戲總覽
Unscramble 應用程式為字詞重組單人遊戲。應用程式會顯示打散的字詞,玩家必須利用所有顯示的字母正確猜出字詞。只要字詞正確無誤,玩家即可得分。如果猜錯,玩家可以重新再來一次,沒有次數限制。應用程式也有略過目前字詞的選項。應用程式右上角會顯示字詞計數,也就是目前遊戲中已猜過的打散字詞數。每場遊戲有 10 個打散的字詞。
取得範例程式碼
如要開始使用,請先下載範例程式碼:
或者,您也可以複製 GitHub 存放區的程式碼:
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git $ cd basic-android-kotlin-compose-training-unscramble $ git checkout starter
您可以瀏覽 Unscramble
GitHub 存放區中的範例程式碼。
3. 範例應用程式總覽
若要瞭解範例程式碼,請完成下列步驟:
- 在 Android Studio 中開啟有範例程式碼的專案。
- 在 Android 裝置或模擬器中執行應用程式。
- 輕觸「Submit」和「Skip」按鈕,開始測試應用程式。
您會發現應用程式中有些錯誤。比如打散的字詞並未顯示,卻硬式編碼為「scrambleun」,輕觸按鈕也沒有任何反應。
在本程式碼研究室中,您將使用 Android 應用程式架構實作遊戲功能。
範例程式碼逐步操作說明
範例程式碼中有預先設計的遊戲畫面版面配置。在本課程中,您將實作遊戲邏輯。您將要使用架構元件實作建議的應用程式架構,解決上述問題。以下是部分檔案的簡要逐步操作說明,可協助您快速上手。
WordsData.kt
此檔案包含遊戲中使用的字詞清單、每場遊戲的字詞數目上限常數,以及玩家每次答對字詞可獲得的分數。
package com.example.android.unscramble.data
const val MAX_NO_OF_WORDS = 10
const val SCORE_INCREASE = 20
// Set with all the words for the Game
val allWords: Set<String> =
setOf(
"animal",
"auto",
"anecdote",
"alphabet",
"all",
"awesome",
"arise",
"balloon",
"basket",
"bench",
// ...
"zoology",
"zone",
"zeal"
)
MainActivity.kt
此檔案主要包含範本產生的程式碼。您會在 setContent{}
區塊中顯示 GameScreen
可組合函式。
GameScreen.kt
所有 UI 可組合項都會在 GameScreen.kt
檔案中定義。以下各節將提供幾種可組合函式的逐步操作說明。
GameStatus
GameStatus
是可組合函式,會在畫面底部顯示遊戲分數。可組合函式包含 Card
中的文字可組合函式。目前分數是硬式編碼為 0
。
// No need to copy, this is included in the starter code.
@Composable
fun GameStatus(score: Int, modifier: Modifier = Modifier) {
Card(
modifier = modifier
) {
Text(
text = stringResource(R.string.score, score),
style = typography.headlineMedium,
modifier = Modifier.padding(8.dp)
)
}
}
GameLayout
GameLayout
是可組合函式,可顯示主要遊戲功能,包括打散的字詞、遊戲操作說明,以及供使用者在猜字時輸入的文字欄位。
請注意,下方的 GameLayout
程式碼在 Card
中有一個資料欄,內含三個子項元素:打散字詞文字、操作說明文字,以及供使用者輸入字詞的文字欄位 OutlinedTextField
。目前,打散的字詞是硬式編碼為 scrambleun
。稍後在程式碼研究室中,您將實作從 WordsData.kt
檔案顯示字詞的功能。
// No need to copy, this is included in the starter code.
@Composable
fun GameLayout(modifier: Modifier = Modifier) {
val mediumPadding = dimensionResource(R.dimen.padding_medium)
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(mediumPadding),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(mediumPadding)
) {
Text(
modifier = Modifier
.clip(shapes.medium)
.background(colorScheme.surfaceTint)
.padding(horizontal = 10.dp, vertical = 4.dp)
.align(alignment = Alignment.End),
text = stringResource(R.string.word_count, 0),
style = typography.titleMedium,
color = colorScheme.onPrimary
)
Text(
text = "scrambleun",
style = typography.displayMedium
)
Text(
text = stringResource(R.string.instructions),
textAlign = TextAlign.Center,
style = typography.titleMedium
)
OutlinedTextField(
value = "",
singleLine = true,
shape = shapes.large,
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.textFieldColors(containerColor = colorScheme.surface),
onValueChange = { },
label = { Text(stringResource(R.string.enter_your_word)) },
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { }
)
)
}
}
}
OutlinedTextField
可組合函式類似於之前程式碼研究室中的應用程式 TextField
可組合函式。
文字欄位分為兩種類型:
- 已填寫的文字欄位
- 有外框的文字欄位
相對於實心的文字欄位,有外框的文字欄位較不具有視覺強調效果。如果在表單等位置中顯示,而且又同時有多個文字欄位,減少的視覺強調效果有助於簡化版面配置。
在範例程式碼中,當使用者輸入猜測的字詞時,OutlinedTextField
不會更新。您將在程式碼研究室中更新此功能。
GameScreen
GameScreen
可組合函式包含 GameStatus
和 GameLayout
可組合函式、遊戲名稱、字詞計數,以及「Submit」和「Skip」按鈕的可組合函式。
@Composable
fun GameScreen() {
val mediumPadding = dimensionResource(R.dimen.padding_medium)
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(mediumPadding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.app_name),
style = typography.titleLarge,
)
GameLayout(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(mediumPadding)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(mediumPadding),
verticalArrangement = Arrangement.spacedBy(mediumPadding),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { }
) {
Text(
text = stringResource(R.string.submit),
fontSize = 16.sp
)
}
OutlinedButton(
onClick = { },
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.skip),
fontSize = 16.sp
)
}
}
GameStatus(score = 0, modifier = Modifier.padding(20.dp))
}
}
範例程式碼中並未實作按鈕點選事件。在本程式碼研究室中,您將實作這些事件。
FinalScoreDialog
FinalScoreDialog
可組合項會顯示對話方塊,也就是通知使用者並提供遊戲中「Play Again」或「Exit」選項的小型視窗。稍後在本程式碼研究室中,您將實作在遊戲結束時顯示此對話方塊的邏輯。
// No need to copy, this is included in the starter code.
@Composable
private fun FinalScoreDialog(
score: Int,
onPlayAgain: () -> Unit,
modifier: Modifier = Modifier
) {
val activity = (LocalContext.current as Activity)
AlertDialog(
onDismissRequest = {
// Dismiss the dialog when the user clicks outside the dialog or on the back
// button. If you want to disable that functionality, simply use an empty
// onDismissRequest.
},
title = { Text(text = stringResource(R.string.congratulations)) },
text = { Text(text = stringResource(R.string.you_scored, score)) },
modifier = modifier,
dismissButton = {
TextButton(
onClick = {
activity.finish()
}
) {
Text(text = stringResource(R.string.exit))
}
},
confirmButton = {
TextButton(onClick = onPlayAgain) {
Text(text = stringResource(R.string.play_again))
}
}
)
}
4. 瞭解應用程式架構
您可以參考應用程式架構提供的指引,在各類別之間分配應用程式的責任。設計良好的應用程式架構有助於擴大應用程式規模,並提供更多功能。此外,架構也能簡化團隊協作程序。
最常見的架構原則是關注點分離,以及透過模型使用 UI。
關注點分離
關注點分離的設計原則是應用程式區分為函式類別,每個類別具有不同責任。
透過模型使用 UI
透過模型使用 UI 這項原則是透過模型 (最好是永久模型) 使用 UI。模型是負責處理應用程式資料的元件。模型與應用程式中的 UI 元素和應用程式元件無關,因此不受應用程式的生命週期和相關的關注點影響。
建議的應用程式架構
以上一節提到的一般架構原則為考量,每個應用程式至少應有兩層:
- UI 層:可在畫面中顯示應用程式資料的層,而且獨立於資料之外。
- 資料層:可儲存、擷取和公開應用程式資料的層。
您可以新增另一層,並將其命名為網域層,以簡化和重複使用 UI 和資料層之間的互動。此層並非必要,而且也不在本課程的範圍之內。
UI 層
UI 層 (或展示層) 是用於在畫面中顯示應用程式資料。只要資料因使用者互動而變更 (例如按下按鈕),UI 就應更新以反映此變更。
UI 層是由以下元件組成:
- UI 元件:在畫面中轉譯資料的元件。您可以使用 Jetpack Compose 建構這些元素。
- 狀態持有物件:保留資料、向 UI 公開資料及處理應用程式邏輯的元件。ViewModel 就是狀態容器的例子。
ViewModel
ViewModel
元件會保留並公開 UI 耗用的狀態。UI 狀態是由 ViewModel
轉換的應用程式資料。ViewModel
可讓應用程式遵循透過模型使用 UI 的架構原則。
ViewModel
會儲存 Android 架構刪除並重新建立活動時沒有刪除的應用程式相關資料。ViewModel
物件與活動例項不同,不會遭到刪除。應用程式會在設定變更期間自動保留 ViewModel
物件,使其保留的資料可在重組完成後立即提供使用。
如要在應用程式中實作 ViewModel
,請擴充架構元件庫中的 ViewModel
類別,並將應用程式資料儲存在該類別中。
UI 狀態
UI 是使用者可以看到的內容,UI 狀態則是應用程式決定讓使用者看到的內容。UI 是 UI 狀態的視覺化呈現結果。任何對 UI 狀態所做的變更都會立即反映至 UI。
UI 是螢幕上 UI 元素與 UI 狀態繫結的結果。
// Example of UI state definition, do not copy over
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
不變性
上例中的 UI 狀態定義為不可變更。不可變更的物件可保證多個來源不會即時變更應用程式的狀態。這種保護機制可使 UI 專注於單一角色:讀取狀態,並據此更新 UI 元素。因此,除非 UI 本身是其資料的唯一來源,否則切勿直接在 UI 中修改 UI 狀態。違反此原則會導致同一項目有多個可靠資料來源,進而造成資料不一致和細微錯誤。
5. 新增 ViewModel
在此工作中,您要將 ViewModel
新增至應用程式,方便儲存遊戲 UI 狀態 (打散的字詞、字詞計數和分數)。如要解決上一節在範例程式碼中發現的問題,就需要在 ViewModel
中儲存遊戲資料。
- 開啟
build.gradle.kts (Module :app)
並捲動至dependencies
區塊,為ViewModel
新增下列依附元件。這個依附元件是用於在 Compose 應用程式中加入可感知生命週期的 ViewModel。
dependencies {
// other dependencies
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
//...
}
- 在
ui
套件中建立名為GameViewModel
的 Kotlin 類別/檔案,並以ViewModel
類別擴充該類別/檔案。
import androidx.lifecycle.ViewModel
class GameViewModel : ViewModel() {
}
- 在
ui
套件中,新增名為GameUiState
的狀態 UI 模型類別,然後將其設為資料類別,並新增目前打散字詞的變數。
data class GameUiState(
val currentScrambledWord: String = ""
)
StateFlow
StateFlow
是資料持有物件的可觀察資料流,可發出目前和最新狀態更新。其 value
屬性可反映目前的狀態值。如要更新狀態並將狀態傳送至資料流程,請將新的值指派給 MutableStateFlow
類別的值屬性。
在 Android 中,StateFlow
很適合必須維持可觀察且不可變動狀態的類別。
您可以從 GameUiState
公開 StateFlow
,這樣可組合函式就能監聽 UI 狀態更新,在設定變更時延續畫面狀態。
在 GameViewModel
類別中,新增下列 _uiState
屬性。
import kotlinx.coroutines.flow.MutableStateFlow
// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
備份屬性
備份屬性可讓您從 getter 傳回確切物件以外的項目。
Kotlin 架構會為每個 var
屬性產生 getter 和 setter。
如為 getter 和 setter 方法,您可以覆寫其中一種或兩種方法,並提供您的自訂行為。如要實作備份屬性,請覆寫 getter 方法以傳回資料的唯讀版本。以下範例說明備份屬性:
//Example code, no need to copy over
// 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
類別中存取及編輯。
在 ViewModel
類別外:
- Kotlin 中的預設瀏覽權限修飾符為
public
,因此count
是公開狀態,且可從 UI 控制器等其他類別存取。val
類型不得含有 setter。此資訊不可變動且處於唯讀狀態,因此您只能覆寫get()
方法。外部類別存取這個屬性時,系統會傳回_count
的值,且該值無法修改。此幕後屬性可保護ViewModel
內的應用程式資料,避免外部類別做出非必要和不安全的變更,但可讓外部呼叫端以安全的方式存取其值。
- 在
GameViewModel.kt
檔案中,將幕後屬性新增至名為_uiState
的uiState
。將屬性命名為uiState
,且類型為StateFlow<GameUiState>
。
目前 _uiState
只能在 GameViewModel
中存取和編輯。UI 可以使用唯讀屬性 uiState
讀取其值。您可以在下一個步驟中修正初始化錯誤。
import kotlinx.coroutines.flow.StateFlow
// Game UI state
// Backing property to avoid state updates from other classes
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState>
- 將
uiState
設為_uiState.asStateFlow()
。
asStateFlow()
會將此可變動狀態資料流設為「唯讀」狀態資料流。
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()
顯示隨機打散字詞
在這項工作中,您將新增輔助方法,從 WordsData.kt
中隨機挑選並打散字詞。
- 在
GameViewModel
中,新增類型為String
的currentWord
屬性,儲存目前的打散字詞。
private lateinit var currentWord: String
- 加入輔助方法,以從清單中隨機選取字詞並重組。將其命名為
pickRandomWordAndShuffle()
(不含輸入參數),然後使其傳回String
。
import com.example.unscramble.data.allWords
private fun pickRandomWordAndShuffle(): String {
// Continue picking up a new random word until you get one that hasn't been used before
currentWord = allWords.random()
if (usedWords.contains(currentWord)) {
return pickRandomWordAndShuffle()
} else {
usedWords.add(currentWord)
return shuffleCurrentWord(currentWord)
}
}
Android Studio 會標記未定義變數及函式的錯誤。
- 在
GameViewModel
中,於currentWord
屬性後加上下列屬性,做為可變動的集合,儲存遊戲中使用的字詞。
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
- 新增另一種名為
shuffleCurrentWord()
的輔助方法以重組目前字詞,此方法會採用String
,並傳回已重組的String
。
private fun shuffleCurrentWord(word: String): String {
val tempWord = word.toCharArray()
// Scramble the word
tempWord.shuffle()
while (String(tempWord).equals(word)) {
tempWord.shuffle()
}
return String(tempWord)
}
- 新增名為
resetGame()
的輔助函式以初始化遊戲。稍後您可以使用此函式啟動及重新啟動遊戲。在此函式中,清除usedWords
組合中的所有字詞,初始化_uiState
。使用pickRandomWordAndShuffle()
為currentScrambledWord
挑選新字詞。
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
- 將
init
區塊新增至GameViewModel
,然後從中呼叫resetGame()
。
init {
resetGame()
}
現在建構應用程式時,UI 仍然不會有任何變動,因為您並未從 ViewModel
傳遞資料至 GameScreen
中的可組合函式。
6. 建立 Compose UI 架構
在 Compose 中,更新 UI 的唯一方法是變更應用程式狀態。您可以控管的是 UI 狀態。每次 UI 狀態變更時,Compose 都會重新建立 UI 樹狀結構中變更的部分。可組合函式可接受狀態並公開事件。舉例來說,TextField
/OutlinedTextField
會接受值並公開回呼 onValueChange
,要求回呼處理常式變更值。
//Example code no need to copy over
var name by remember { mutableStateOf("") }
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)
由於可組合函式接受狀態並公開事件,因此單向資料流程模式適用於 Jetpack Compose。本節著重說明如何在 Compose 中實作單向資料流模式、如何實作事件和狀態持有物件,以及如何在 Compose 中使用 ViewModel
。
單向資料流
「單向資料流程」(UDF) 是一種設計模式,其中狀態會向下流動,事件則向上流動。藉由跟隨單向資料流,您可以從應用程式中儲存及變更狀態的部分,分離出在 UI 中顯示狀態的可組合項。
使用單向資料流的應用程式 UI 更新迴圈如下所示:
- 事件:部分 UI 產生事件並向上傳遞,例如傳遞至 ViewModel 來處理的按鈕點選事件;或是從應用程式的其他層傳遞的事件,例如表示使用者工作階段已過期的指標。
- 更新狀態:事件處理常式可能會變更狀態。
- 顯示狀態:狀態容器向下傳遞狀態,然後在 UI 中顯示。
使用應用程式架構的 UDF 模式會有下列影響:
ViewModel
會保留並公開 UI 耗用的狀態。- UI 狀態是由
ViewModel
轉換的應用程式資料。 - UI 會就使用者事件通知
ViewModel
。 ViewModel
會處理使用者動作並更新狀態。- 系統會將更新狀態傳回 UI 以進行算繪。
- 任何造成狀態變化的事件都會重複此程序。
傳遞資料
傳遞 ViewModel 例項至 UI,也就是從 GameViewModel
傳遞至 GameScreen.kt
檔案中的 GameScreen()
。在 GameScreen()
中使用 ViewModel 例項,透過 collectAsState()
存取 uiState
。
collectAsState()
函式會從此 StateFlow
收集值,然後透過 State
表示最新的值。系統會使用 StateFlow.value
做為初始值。只要可能有新值發布至 StateFlow
,傳回的 State
就會更新,導致每次 State.value
使用重新組合。
- 在
GameScreen
函式中,傳遞類型為GameViewModel
且含有預設值viewModel()
的第二個引數。
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun GameScreen(
gameViewModel: GameViewModel = viewModel()
) {
// ...
}
- 在
GameScreen()
函式中新增名為gameUiState
的新變數。請使用by
在uiState
委派和呼叫collectAsState()
。
此方法可確保只要 uiState
值有變更,使用 gameUiState
值的可組合函式就會進行重新組合。
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@Composable
fun GameScreen(
// ...
) {
val gameUiState by gameViewModel.uiState.collectAsState()
// ...
}
- 傳遞
gameUiState.currentScrambledWord
至GameLayout()
可組合項。您可在之後的步驟中新增引數,因此目前請先忽略此錯誤。
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(mediumPadding)
)
- 將
currentScrambledWord
新增為GameLayout()
可組合函式中的另一個參數。
@Composable
fun GameLayout(
currentScrambledWord: String,
modifier: Modifier = Modifier
) {
}
- 更新
GameLayout()
可組合函式以顯示currentScrambledWord
。將欄中第一個文字欄位的text
參數設為currentScrambledWord
。
@Composable
fun GameLayout(
// ...
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = currentScrambledWord,
fontSize = 45.sp,
modifier = modifier.align(Alignment.CenterHorizontally)
)
//...
}
}
- 執行並建構應用程式。此時應顯示打散的字詞。
顯示猜測字詞
在 GameLayout()
可組合函式中,更新使用者的猜測字詞是從 GameScreen
向上流至 ViewModel
的事件回呼之一。資料 gameViewModel.userGuess
會從 ViewModel
向下流至 GameScreen
。
- 在
GameScreen.kt
檔案中的GameLayout()
可組合函式,將onValueChange
設為onUserGuessChanged
,並將onKeyboardDone()
設為onDone
鍵盤動作。您將在下一個步驟中修正錯誤。
OutlinedTextField(
value = "",
singleLine = true,
modifier = Modifier.fillMaxWidth(),
onValueChange = onUserGuessChanged,
label = { Text(stringResource(R.string.enter_your_word)) },
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onKeyboardDone() }
),
- 在
GameLayout()
可組合函式中,再新增兩個引數:使用String
引數且不會傳回任何內容的onUserGuessChanged
lambda,以及不使用也不會傳回任何內容的onKeyboardDone
。
@Composable
fun GameLayout(
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
currentScrambledWord: String,
modifier: Modifier = Modifier,
) {
}
- 在
GameLayout()
函式呼叫中,新增onUserGuessChanged
和onKeyboardDone
的 lambda 引數。
GameLayout(
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { },
currentScrambledWord = gameUiState.currentScrambledWord,
)
您很快就會在 GameViewModel
中定義 updateUserGuess
方法。
- 在
GameViewModel.kt
檔案中,新增使用String
引數 (使用者的猜測字詞) 且名為updateUserGuess()
的方法。在函式中,使用guessedWord
中傳遞的內容更新userGuess
。
fun updateUserGuess(guessedWord: String){
userGuess = guessedWord
}
接下來您要在 ViewModel 中新增 userGuess
。
- 在
GameViewModel.kt
檔案中,新增名為userGuess
的 var 屬性。使用mutableStateOf()
,讓 Compose 觀察這此值並將初始值設為""
。
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
var userGuess by mutableStateOf("")
private set
- 在
GameScreen.kt
檔案的GameLayout()
中,新增另一個userGuess
的String
參數。將OutlinedTextField
的value
參數設為userGuess
。
fun GameLayout(
currentScrambledWord: String,
userGuess: String,
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
//...
OutlinedTextField(
value = userGuess,
//..
)
}
}
- 在
GameScreen
函式中更新GameLayout()
函式呼叫以加入userGuess
參數。
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { },
//...
)
- 建構並執行應用程式。
- 請嘗試猜測然後輸入一個字詞。文字欄位可顯示使用者猜測的字詞。
7. 驗證猜測字詞和更新分數
在這項工作中,您要實作一種方法以驗證使用者猜測的字詞,然後更新遊戲分數或顯示錯誤。稍後您將更新遊戲狀態 UI,並顯示新分數和新字詞。
- 在
GameViewModel
中,新增另一種名為checkUserGuess()
的方法。 - 在
checkUserGuess()
函式中新增if else
區塊,驗證使用者猜測的字詞是否與currentWord
相同。將userGuess
重設為空白字串。
fun checkUserGuess() {
if (userGuess.equals(currentWord, ignoreCase = true)) {
} else {
}
// Reset user guess
updateUserGuess("")
}
- 如果使用者猜錯,則將
isGuessedWordWrong
設為true
。MutableStateFlow<T>.
update()
會使用指定的值更新MutableStateFlow.value
。
import kotlinx.coroutines.flow.update
if (userGuess.equals(currentWord, ignoreCase = true)) {
} else {
// User's guess is wrong, show an error
_uiState.update { currentState ->
currentState.copy(isGuessedWordWrong = true)
}
}
- 在
GameUiState
類別中,新增名為isGuessedWordWrong
的Boolean
,並將其初始化為false
。
data class GameUiState(
val currentScrambledWord: String = "",
val isGuessedWordWrong: Boolean = false,
)
接著,在使用者點選「Submit」按鈕或按下鍵盤中的完成鍵時,從 GameScreen
向上傳遞事件回呼 checkUserGuess()
至 ViewModel
。從 ViewModel
向下傳遞資料 gameUiState.isGuessedWordWrong
至 GameScreen
,即可設定文字欄位中的錯誤。
- 在
GameScreen.kt
檔案中GameScreen()
可組合函式的結尾處,從「Submit」按鈕的onClick
lambda 運算式中呼叫gameViewModel.checkUserGuess()
。
Button(
modifier = modifier
.fillMaxWidth()
.weight(1f)
.padding(start = 8.dp),
onClick = { gameViewModel.checkUserGuess() }
) {
Text(stringResource(R.string.submit))
}
- 在
GameScreen()
可組合函式中,更新GameLayout()
函式呼叫以在onKeyboardDone
lambda 運算式中傳遞gameViewModel.checkUserGuess()
。
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { gameViewModel.checkUserGuess() }
)
- 在
GameLayout()
可組合函式中,新增Boolean
的函式參數,isGuessWrong
。將OutlinedTextField
的isError
參數設為isGuessWrong
,即可在使用者猜錯時於文字欄位中顯示錯誤。
fun GameLayout(
currentScrambledWord: String,
isGuessWrong: Boolean,
userGuess: String,
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
// ,...
OutlinedTextField(
// ...
isError = isGuessWrong,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onKeyboardDone() }
),
)
}
}
- 在
GameScreen()
可組合函式中,更新GameLayout()
函式呼叫以傳遞isGuessWrong
。
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { gameViewModel.checkUserGuess() },
isGuessWrong = gameUiState.isGuessedWordWrong,
// ...
)
- 建構並執行應用程式。
- 輸入錯誤的猜測字詞,然後點選「Submit」。請留意文字欄位會變成紅色,表示答案有誤。
請注意,文字欄位標籤仍會顯示「Enter your word」。為方便使用者瞭解,您需要加入一些錯誤文字來表示字詞錯誤。
- 在
GameScreen.kt
檔案的GameLayout()
可組合函式中,根據isGuessWrong
更新文字欄位的標籤參數,如下所示:
OutlinedTextField(
// ...
label = {
if (isGuessWrong) {
Text(stringResource(R.string.wrong_guess))
} else {
Text(stringResource(R.string.enter_your_word))
}
},
// ...
)
- 在
strings.xml
檔案中,新增字串至錯誤標籤。
<string name="wrong_guess">Wrong Guess!</string>
- 再次建構並執行應用程式。
- 輸入錯誤的猜測字詞,然後點選「Submit」。請注意錯誤標籤。
8. 更新分數和字詞計數
在這項工作中,您要更新使用者玩遊戲時的分數和字詞計數。分數必須是 _ uiState
的一部分。
- 在
GameUiState
中新增score
變數,並將初始值設為零。
data class GameUiState(
val currentScrambledWord: String = "",
val isGuessedWordWrong: Boolean = false,
val score: Int = 0
)
- 如要更新分數值,請在
GameViewModel
的checkUserGuess()
函式中,將if
條件設為在使用者猜對時提高score
值。
import com.example.unscramble.data.SCORE_INCREASE
fun checkUserGuess() {
if (userGuess.equals(currentWord, ignoreCase = true)) {
// User's guess is correct, increase the score
val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
} else {
//...
}
}
- 在
GameViewModel
中,新增另一個名為updateGameState
的方法,以更新分數、遞增目前的字詞計數,並從WordsData.kt
檔案中選擇新字詞。新增名為updatedScore
的Int
做為參數。請更新遊戲狀態 UI 變數,如下所示︰
private fun updateGameState(updatedScore: Int) {
_uiState.update { currentState ->
currentState.copy(
isGuessedWordWrong = false,
currentScrambledWord = pickRandomWordAndShuffle(),
score = updatedScore
)
}
}
- 在
checkUserGuess()
函式中,如果使用者猜對,請使用最新的分數呼叫updateGameState
,為遊戲的下一回合做好準備。
fun checkUserGuess() {
if (userGuess.equals(currentWord, ignoreCase = true)) {
// User's guess is correct, increase the score
// and call updateGameState() to prepare the game for next round
val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
updateGameState(updatedScore)
} else {
//...
}
}
已完成的 checkUserGuess()
應如下所示:
fun checkUserGuess() {
if (userGuess.equals(currentWord, ignoreCase = true)) {
// User's guess is correct, increase the score
// and call updateGameState() to prepare the game for next round
val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
updateGameState(updatedScore)
} else {
// User's guess is wrong, show an error
_uiState.update { currentState ->
currentState.copy(isGuessedWordWrong = true)
}
}
// Reset user guess
updateUserGuess("")
}
然後您必須更新字詞計數,與更新分數的方法類似。
- 在
GameUiState
中針對計數新增另一個變數,命名為currentWordCount
,並將初始值設為1
。
data class GameUiState(
val currentScrambledWord: String = "",
val currentWordCount: Int = 1,
val score: Int = 0,
val isGuessedWordWrong: Boolean = false,
)
- 在
GameViewModel.kt
檔案的updateGameState()
函式中提高字詞計數,如下所示。系統會呼叫updateGameState()
函式,為遊戲的下一回合做好準備。
private fun updateGameState(updatedScore: Int) {
_uiState.update { currentState ->
currentState.copy(
//...
currentWordCount = currentState.currentWordCount.inc(),
)
}
}
傳遞分數和字詞計數
請完成下列步驟,將分數和字詞計數資料從 ViewModel
向下傳遞至 GameScreen
。
- 在
GameScreen.kt
檔案的GameLayout()
可組合函式中,將字詞計數新增為引數,並將wordCount
格式引數傳遞至文字元素。
fun GameLayout(
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
wordCount: Int,
//...
) {
//...
Card(
//...
) {
Column(
// ...
) {
Text(
//..
text = stringResource(R.string.word_count, wordCount),
style = typography.titleMedium,
color = colorScheme.onPrimary
)
// ...
}
- 更新
GameLayout()
函式呼叫,納入字詞計數。
GameLayout(
userGuess = gameViewModel.userGuess,
wordCount = gameUiState.currentWordCount,
//...
)
- 在
GameScreen()
可組合函式中更新GameStatus()
函式呼叫,納入score
參數。從gameUiState
傳遞分數。
GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))
- 建構並執行應用程式。
- 輸入猜測字詞,然後按一下「Submit」。此時分數和字詞計數會更新。
- 按一下「Skip」,您會發現沒有任何反應。
如要實作略過功能,您必須將略過事件回呼傳遞至 GameViewModel
。
- 在
GameScreen.kt
檔案的GameScreen()
可組合函式中,從onClick
lambda 運算式呼叫gameViewModel.skipWord()
。
Android Studio 會顯示錯誤訊息,這是因為您尚未實作此函式。您可以新增 skipWord()
方法,在下一步驟中修正這個錯誤。使用者略過字詞時,您必須更新遊戲變化版本,並為遊戲的下一回合做好準備。
OutlinedButton(
onClick = { gameViewModel.skipWord() },
modifier = Modifier.fillMaxWidth()
) {
//...
}
- 在
GameViewModel
中,新增方法skipWord()
。 - 在
skipWord()
函式中呼叫updateGameState()
,以傳遞分數並重設使用者猜測字詞。
fun skipWord() {
updateGameState(_uiState.value.score)
// Reset user guess
updateUserGuess("")
}
- 執行應用程式,然後進行遊戲。現在您應可略過字詞。
即使猜完 10 個字詞,您仍可繼續玩遊戲。在接下來的工作中,您將處理遊戲的最後一回合。
9. 處理遊戲的最後回合
在目前的實作項目中,使用者可略過或猜測超過 10 個字詞。在這項工作中,您要加入結束遊戲的邏輯。
如要實作遊戲結束邏輯,您需要先檢查使用者是否已達到字詞數量上限。
- 在
GameViewModel
中,新增if-else
區塊,並將現有的函式主體移至else
區塊。 - 新增
if
條件以檢查usedWords
大小是否等於MAX_NO_OF_WORDS
。
import com.example.android.unscramble.data.MAX_NO_OF_WORDS
private fun updateGameState(updatedScore: Int) {
if (usedWords.size == MAX_NO_OF_WORDS){
//Last round in the game
} else{
// Normal round in the game
_uiState.update { currentState ->
currentState.copy(
isGuessedWordWrong = false,
currentScrambledWord = pickRandomWordAndShuffle(),
currentWordCount = currentState.currentWordCount.inc(),
score = updatedScore
)
}
}
}
- 在
if
區塊中新增Boolean
標記isGameOver
,然後將標記設為true
,表示遊戲已結束。 - 更新
score
,然後重設if
區塊中的isGuessedWordWrong
。下列程式碼顯示函式的編寫方式︰
private fun updateGameState(updatedScore: Int) {
if (usedWords.size == MAX_NO_OF_WORDS){
//Last round in the game, update isGameOver to true, don't pick a new word
_uiState.update { currentState ->
currentState.copy(
isGuessedWordWrong = false,
score = updatedScore,
isGameOver = true
)
}
} else{
// Normal round in the game
_uiState.update { currentState ->
currentState.copy(
isGuessedWordWrong = false,
currentScrambledWord = pickRandomWordAndShuffle(),
currentWordCount = currentState.currentWordCount.inc(),
score = updatedScore
)
}
}
}
- 在
GameUiState
中,新增Boolean
變數isGameOver
並設為false
。
data class GameUiState(
val currentScrambledWord: String = "",
val currentWordCount: Int = 1,
val score: Int = 0,
val isGuessedWordWrong: Boolean = false,
val isGameOver: Boolean = false
)
- 執行應用程式,然後進行遊戲。超過 10 個字詞後即無法繼續玩遊戲。
遊戲結束後,建議您告知使用者遊戲已結束,並詢問是否要再玩一次。您將在下一個工作中實作此功能。
顯示遊戲結束對話方塊
在這項工作中,您要將 isGameOver
資料從 ViewModel 向下傳遞至 GameScreen
,然後使用該資料顯示快訊對話方塊,其中含有結束遊戲或重新啟動遊戲的選項。
對話方塊是一個小型視窗,可提示使用者做出決定或輸入其他資訊。一般而言,對話方塊不會填滿整個畫面,而且使用者必須執行動作才能繼續。Android 提供不同類型的對話方塊。在本程式碼研究室中,您將瞭解「快訊對話方塊」。
快訊對話方塊圖解
- 容器
- 圖示 (選用)
- 標題 (選用)
- 補充文字
- 分隔線 (選用)
- 動作
範例程式碼中的 GameScreen.kt
檔案已有可顯示快訊對話方塊的函式,並提供退出或重新啟動遊戲的選項。
@Composable
private fun FinalScoreDialog(
onPlayAgain: () -> Unit,
modifier: Modifier = Modifier
) {
val activity = (LocalContext.current as Activity)
AlertDialog(
onDismissRequest = {
// Dismiss the dialog when the user clicks outside the dialog or on the back
// button. If you want to disable that functionality, simply use an empty
// onDismissRequest.
},
title = { Text(stringResource(R.string.congratulations)) },
text = { Text(stringResource(R.string.you_scored, 0)) },
modifier = modifier,
dismissButton = {
TextButton(
onClick = {
activity.finish()
}
) {
Text(text = stringResource(R.string.exit))
}
},
confirmButton = {
TextButton(
onClick = {
onPlayAgain()
}
) {
Text(text = stringResource(R.string.play_again))
}
}
)
}
在此函式中,title
和 text
參數會在快訊對話方塊中顯示標題和說明文字。dismissButton
和 confirmButton
是文字按鈕。在 dismissButton
參數中,顯示「退出」文字,然後完成活動以終止應用程式。在 confirmButton
參數中,重新啟動遊戲並顯示「Play Again」文字。
- 在
GameScreen.kt
檔案的FinalScoreDialog()
函式中,請留意用來在快訊對話方塊中顯示遊戲分數的參數。
@Composable
private fun FinalScoreDialog(
score: Int,
onPlayAgain: () -> Unit,
modifier: Modifier = Modifier
) {
- 在
FinalScoreDialog()
函式中,請注意text
參數 lambda 運算式的用法,使用score
做為對話方塊文字的格式引數。
text = { Text(stringResource(R.string.you_scored, score)) }
- 在
GameScreen.kt
檔案中,於GameScreen()
可組合函式結尾處的Column
區塊後方,加入if
條件以檢查gameUiState.isGameOver
。 - 在
if
區塊中,顯示快訊對話方塊。呼叫score
和gameViewModel.resetGame()
中傳遞的FinalScoreDialog()
以取得onPlayAgain
事件回呼。
if (gameUiState.isGameOver) {
FinalScoreDialog(
score = gameUiState.score,
onPlayAgain = { gameViewModel.resetGame() }
)
}
resetGame()
是從 GameScreen
向上傳遞至 ViewModel
的事件回呼。
- 在
GameViewModel.kt
檔案中,重新呼叫resetGame()
函式,初始化_uiState
,然後選擇一個新字詞。
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
- 建構並執行應用程式。
- 進行遊戲直到遊戲結束,然後觀察快訊對話方塊以及「退出」遊戲或「再玩一次」的選項。請嘗試使用快訊對話方塊中顯示的選項。
10. 裝置旋轉狀態
在先前的程式碼研究室中,您已瞭解 Android 中的設定變更。發生設定變更時,Android 會從頭開始重新啟動活動,並執行所有生命週期起始回呼。
ViewModel
會儲存 Android 架構刪除並重新建立活動時沒有刪除的應用程式相關資料。系統會自動保留 ViewModel
物件,且這些物件在設定變更期間不會像活動執行個體一樣遭到刪除。重組後保留的資料會立即提供使用。
在這項工作中,您要檢查應用程式是否在設定變更期間保留狀態 UI。
- 執行應用程式並玩幾個字詞。將裝置的設定從直向變更為橫向,或從橫向變更為直向。
- 您可以發現,
ViewModel
狀態 UI 中儲存的資料在設定變更期間會保留下來。
11. 取得解決方案程式碼
完成程式碼研究室後,如要下載當中用到的程式碼,您可以使用這些 git 指令:
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git $ cd basic-android-kotlin-compose-training-unscramble $ git checkout viewmodel
另外,您也可以下載存放區為 ZIP 檔案,然後解壓縮並在 Android Studio 中開啟。
如要查看本程式碼研究室的解決方案程式碼,請前往 GitHub 查看。
12. 結語
恭喜!您已完成程式碼研究室。現在您已經瞭解 Android 應用程式架構指南建議將具有不同責任的類別分離,並透過模型使用 UI。
記得使用 #AndroidBasics,在社群媒體分享您的作品!