1. 开始之前
在之前的 Codelab 中,您学习了 Activity 的生命周期以及与配置更改相关联的生命周期问题。当发生配置更改时,您可以通过不同的方式保存应用的数据,例如使用 rememberSaveable
或保存实例状态。但是,这些选项可能会导致问题。大多数情况下,您可以使用 rememberSaveable
,但这可能意味着需要在可组合函数中或其附近保留逻辑。当应用规模扩大时,您应该将数据和逻辑从可组合函数中移出。在本 Codelab 中,您将学习一种可靠的方法来设计您的应用并在配置更改期间保留应用数据,方法是利用 Android Jetpack 库 ViewModel
和 Android 应用架构指南。
Android Jetpack 库是一组库,可帮助您更轻松地开发出色的 Android 应用。这些库可以帮助您遵循最佳实践,免于编写样板代码,并简化复杂任务,以便您可以专注于自己关心的代码,例如应用逻辑。
应用架构是一组应用的设计规则。就像房子的蓝图一样,您的架构为您的应用提供了结构。良好的应用架构可以使您的代码健壮、灵活、可扩展、可测试和可维护,并能持续多年。该 应用架构指南 提供了有关应用架构和推荐最佳实践的建议。
在本 Codelab 中,您将学习如何使用 ViewModel
,这是 Android Jetpack 库中用于存储应用数据的架构组件之一。如果框架在配置更改或其他事件期间销毁并重新创建 Activity,则存储的数据不会丢失。但是,如果 Activity 由于进程死亡而被销毁,则数据会丢失。 ViewModel
仅在快速 Activity 重新创建过程中缓存数据。
先决条件
- 了解 Kotlin,包括函数、lambda 和无状态可组合函数
- 了解如何在 Jetpack Compose 中构建布局的基本知识
- 了解 Material Design 的基本知识
您将学到什么
- 介绍 Android 应用架构
- 如何在您的应用中使用
ViewModel
类 - 如何使用
ViewModel
在设备配置更改期间保留 UI 数据
您将构建什么
- 一个 解乱码 游戏应用,用户可以在其中猜测乱序的单词
您需要什么
- 最新版本的 Android Studio
- 下载初始代码所需的网络连接
2. 应用概述
游戏概述
解乱码应用是一款单人玩词语乱序游戏。应用会显示一个乱序的单词,玩家必须使用显示的所有字母来猜测该单词。如果单词正确,玩家将获得分数。否则,玩家可以尝试任意次数猜测单词。应用还提供跳过当前单词的选项。在右上角,应用显示单词计数,即当前游戏中玩过的乱序单词数量。每个游戏有 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 设备或模拟器上运行应用。
- 点击提交和跳过按钮以测试应用。
您会注意到应用中存在错误。乱序单词未显示,但硬编码为“scrambleun”,并且点击按钮时没有任何反应。
在本 Codelab 中,您将使用 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
。稍后在本 Codelab 中,您将实现从 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
可组合函数类似于之前 Codelab 中应用中的 TextField
可组合函数。
文本字段有两种类型
- 填充文本字段
- 带轮廓的文本字段
带轮廓的文本字段比填充文本字段的视觉强调效果更弱。当它们出现在表单等许多文本字段并排放置的地方时,它们降低的强调效果有助于简化布局。
在初始代码中,当用户输入猜测时,OutlinedTextField
不会更新。您将在 Codelab 中更新此功能。
GameScreen
GameScreen
可组合函数包含 GameStatus
和 GameLayout
可组合函数、游戏标题、单词计数以及提交和跳过按钮的可组合函数。
@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))
}
}
初始代码中未实现按钮点击事件。您将在 Codelab 中实现这些事件。
FinalScoreDialog
FinalScoreDialog
可组合函数显示一个对话框(即一个小窗口,提示用户)并提供重新开始或退出游戏的选项。稍后在本 Codelab 中,您将实现逻辑以在游戏结束时显示此对话框。
// 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 应用程序中。
dependencies {
// other dependencies
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
//...
}
- 在
ui
包中,创建一个名为GameViewModel
的 Kotlin 类/文件。将其从ViewModel
类扩展。
import androidx.lifecycle.ViewModel
class GameViewModel : ViewModel() {
}
- 在
ui
包中,为 UI 状态添加一个模型类,名为GameUiState
。将其设为数据类并添加一个用于当前乱序单词的变量。
data class GameUiState(
val currentScrambledWord: String = ""
)
StateFlow
StateFlow
是一个数据持有者可观察流,它会发出当前和新的状态更新。其value
属性反映了当前状态值。要更新状态并将其发送到流,请将 MutableStateFlow
类的 value
属性分配一个新值。
在 Android 中,StateFlow
与必须维护可观察不可变状态的类配合使用效果很好。
可以从 GameUiState
公开 StateFlow
,以便可组合项可以侦听 UI 状态更新并使屏幕状态在配置更改中保持存活。
在 GameViewModel
类中,添加以下 _uiState
属性。
import kotlinx.coroutines.flow.MutableStateFlow
// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
支持属性
支持属性允许您从 getter 返回的内容与确切的对象不同。
对于 var
属性,Kotlin 框架会生成 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
。
现在 _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
中,添加一个名为currentWord
的类型为String
的属性,以保存当前乱序单词。
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())
}
- 在
GameViewModel
中添加一个init
块,并从中调用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()
可组合函数中,添加另外两个参数:onUserGuessChanged
lambda 接受一个String
参数并返回空值,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
文件中,添加一个名为updateUserGuess()
的方法,该方法接受一个String
参数,即用户的猜测单词。在函数内部,使用传入的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,
)
接下来,当用户点击“提交”按钮或键盘上的完成键时,您将事件回调 checkUserGuess()
从 GameScreen
传递到 ViewModel
。将数据 gameUiState.isGuessedWordWrong
从 ViewModel
传递到 GameScreen
以设置文本字段中的错误。
- 在
GameScreen.kt
文件中,在GameScreen()
可组合函数的末尾,在“提交”按钮的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,
// ...
)
- 构建并运行您的应用。
- 输入错误的猜测并点击“提交”。观察文本字段变为红色,表示错误。
注意文本字段标签仍然显示“输入您的单词”。为了使其更友好,您需要添加一些错误文本以指示单词错误。
- 在
GameScreen.kt
文件中,在GameLayout()
可组合项中,根据isGuessWrong
更新文本字段的 label 参数,如下所示
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>
- 再次构建并运行您的应用。
- 输入错误的猜测并点击“提交”。注意错误标签。
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))
- 构建并运行应用程序。
- 输入猜测的单词并点击 提交。注意分数和单词计数的更新。
- 点击 跳过,注意没有任何反应。
要实现跳过功能,您需要将跳过事件回调传递到 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
以指示游戏结束。 - 在
if
块内更新score
并重置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
参数中,您重新开始游戏并显示文本 重新开始。
- 在
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
块中,显示警报对话框。调用FinalScoreDialog()
,传入score
和gameViewModel.resetGame()
作为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!