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 数据
您将构建什么
- 一个 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",并且点按按钮没有任何反应。
在此 Codelab 中,您将使用 Android 应用架构实现游戏功能。
入门代码 walkthrough
入门代码为您提供了预先设计的游戏画面布局。在本途径中,您将实现游戏逻辑。您将使用架构组件来实现推荐的应用架构并解决上述问题。以下是一些文件 brief walkthrough,帮助您入门。
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
文件中。以下部分将 walkthrough 一些可组合函数。
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 构建这些元素。
- 状态 holder:保存数据、将其公开给 UI 并处理应用逻辑的组件。一个示例状态 holder 是 ViewModel。
ViewModel
ViewModel
组件持有并公开 UI 消耗的状态。UI 状态是 ViewModel
转换后的应用数据。ViewModel
使您的应用能够遵循从模型驱动 UI 的架构原则。
ViewModel
存储与应用相关的数据,这些数据在 Android 框架销毁并重新创建 Activity 时不会被销毁。与 Activity 实例不同,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
依赖项。此依赖项用于将感知生命周期的 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
是一个数据 holder observable flow,它发出当前和新的状态更新。其 value
属性反映当前状态值。要更新状态并将其发送到 flow,请为 MutableStateFlow
类的 value 属性赋新值。
在 Android 中,StateFlow
非常适合需要维护 observable 不可变状态的类。
可以从 GameUiState
中公开 StateFlow
,以便可组合项可以监听 UI 状态更新,并使屏幕状态在配置变更期间得以保留。
在 GameViewModel
类中,添加以下 _uiState
属性。
import kotlinx.coroutines.flow.MutableStateFlow
// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
支持属性 (Backing property)
支持属性让您可以从 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()
显示随机打乱顺序的单词
在此任务中,您将添加 helper 方法以从 WordsData.kt
中选取一个随机单词并打乱该单词。
- 在
GameViewModel
中,添加一个类型为String
的属性,名为currentWord
,用于保存当前打乱顺序的单词。
private lateinit var currentWord: String
- 添加一个 helper 方法,从列表中选取一个随机单词并将其打乱。将其命名为
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()
- 添加另一个 helper 方法,用于打乱当前单词,名为
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)
}
- 添加一个 helper 函数来初始化游戏,名为
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 处理——或者从应用的其他层传递的事件,例如指示用户会话已过期。
- 更新状态:事件处理程序可能会更改状态。
- 显示状态:状态 holder 向下传递状态,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
块内部,添加布尔标志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
参数中,您显示文本退出并通过完成 Activity 终止应用。在 confirmButton
参数中,您重新开始游戏并显示文本再玩一次。
- 在
GameScreen.kt
文件中,在FinalScoreDialog()
函数中,注意 score 参数,以在警告对话框中显示游戏得分。
@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 会从头开始重新启动 Activity,运行所有生命周期启动回调。
ViewModel
存储与应用相关的数据,这些数据在 Android 框架销毁并重新创建 Activity 时不会被销毁。ViewModel
对象在配置变更期间会自动保留,不会像 Activity 实例那样被销毁。它们持有的数据在重组后立即可用。
在此任务中,您将检查应用在配置变更期间是否保留 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 标签!