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 设备或模拟器上运行应用程序。
- 点击提交和跳过按钮以测试应用程序。
您会注意到应用程序中的错误。乱序的单词不会显示,但它被硬编码为“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
可组合函数、游戏标题、单词计数以及提交和跳过按钮的可组合项。
@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
可组合项显示一个对话框(即一个小窗口,它会提示用户),其中包含重新开始游戏或退出游戏的选项。在本代码实验室的后面,您将实现逻辑以在游戏结束时显示此对话框。
// 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<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
中,添加一个名为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
更新文本字段的标签参数,如下所示
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 提供了不同类型的对话框。在此 Codelab 中,您将学习有关警告对话框的信息。
警告对话框的结构
- 容器
- 图标(可选)
- 标题(可选)
- 辅助文本
- 分隔线(可选)
- 操作
启动代码中的 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. 设备旋转中的状态
在之前的 Codelab 中,您学习了有关 Android 中配置更改的信息。当发生配置更改时,Android 会从头开始重新启动活动,运行所有生命周期启动回调。
ViewModel
存储与应用程序相关的不会在 Android 框架销毁和重新创建活动时被销毁的数据。 ViewModel
对象会自动保留,并且不会像活动实例一样在配置更改期间被销毁。它们保存的数据在重新组合后会立即可用。
在此任务中,您将检查应用程序在配置更改期间是否保留状态 UI。
- 运行应用程序并玩一些词。将设备的配置从纵向更改为横向,反之亦然。
- 观察在
ViewModel
的状态 UI 中保存的数据在配置更改期间是否保留。
11. 获取解决方案代码
要下载完成 Codelab 的代码,可以使用以下 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 中打开它。
如果您想查看此 Codelab 的解决方案代码,请在 GitHub 上查看。
12. 结论
恭喜!您已经完成了 Codelab。现在,您了解了 Android 应用程序架构指南如何建议分离具有不同职责的类并从模型驱动 UI。
别忘了在社交媒体上使用 #AndroidBasics 分享您的作品!