Compose 中的 ViewModel 和状态

1. 在您开始之前

在之前的代码实验室中,您学习了活动的生命周期以及与配置更改相关的生命周期问题。当发生配置更改时,您可以通过多种方式保存应用程序数据,例如使用 rememberSaveable 或保存实例状态。但是,这些选项可能会导致问题。大多数情况下,您可以使用 rememberSaveable,但这可能意味着将逻辑保留在可组合项中或附近。当应用程序增长时,您应该将数据和逻辑从可组合项中移出。在这个代码实验室中,您将学习一种使用 Android Jetpack 库、ViewModel 和 Android 应用程序架构指南来设计应用程序并保留配置更改期间的应用程序数据的稳健方法。

Android Jetpack 库是一组库,可以帮助您更轻松地开发出色的 Android 应用程序。这些库可以帮助您遵循最佳实践,免去编写样板代码,简化复杂的任务,让您可以专注于您关心的代码,例如应用程序逻辑。

应用程序架构 是一组应用程序设计规则。就像房子的蓝图一样,您的架构为您的应用程序提供了结构。良好的应用程序架构可以使您的代码在未来几年内保持稳健、灵活、可扩展、可测试和可维护。该 应用程序架构指南 提供了有关应用程序架构和推荐最佳实践的建议。

在这个代码实验室中,您将学习如何使用 ViewModel,它是 Android Jetpack 库中的一种架构组件,可以存储您的应用程序数据。如果框架在配置更改或其他事件期间销毁并重新创建活动,则存储的数据不会丢失。但是,如果活动由于进程死亡而被销毁,则数据将丢失。该 ViewModel 仅通过快速活动重新创建来缓存数据。

先决条件

  • 熟悉 Kotlin,包括函数、lambda 表达式和无状态可组合项
  • 了解如何在 Jetpack Compose 中构建布局
  • 了解 Material Design

您将学到什么

您将构建什么

  • 一个 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. 入门应用程序概述

要熟悉入门代码,请完成以下步骤

  1. 在 Android Studio 中打开包含入门代码的项目。
  2. 在 Android 设备或模拟器上运行应用程序。
  3. 点击提交跳过按钮以测试应用程序。

您会注意到应用程序中的错误。乱序的单词不会显示,但它被硬编码为“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

1a7e4472a5638d61.png

// 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 是一个可组合函数,它显示主要游戏功能,包括乱序单词、游戏说明以及接受用户猜测的文本字段。

b6ddb1f07f10df0c.png

请注意,以下 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 可组合项类似。

文本字段有两种类型

  • 填充文本字段
  • 轮廓文本字段

3df34220c3d177eb.png

轮廓文本字段的视觉强调不如填充文本字段。当它们出现在表单等多个文本字段一起放置的位置时,它们减少的强调有助于简化布局。

在入门代码中,该 OutlinedTextField 在用户输入猜测时不会更新。您将在代码实验室中更新此功能。

GameScreen

GameScreen 可组合项包含 GameStatusGameLayout 可组合函数、游戏标题、单词计数以及提交跳过按钮的可组合项。

ac79bf1ed6375a27.png

@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 可组合项显示一个对话框(即一个小窗口,它会提示用户),其中包含重新开始游戏退出游戏的选项。在本代码实验室的后面,您将实现逻辑以在游戏结束时显示此对话框。

dba2d9ea62aaa982.png

// 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 层和数据层之间的交互。此层是可选的,超出了本课程的范围。

a4da6fa5c1c9fed5.png

UI 层

UI 层或表示层的职责是在屏幕上显示应用程序数据。只要数据因用户交互(例如按下按钮)而发生更改,UI 应更新以反映这些更改。

UI 层由以下组件组成

  • UI 元素:在屏幕上呈现数据的组件。您可以使用 Jetpack Compose 构建这些元素。
  • 状态持有者:保存数据、将其公开给 UI 并处理应用程序逻辑的组件。ViewModel 是状态持有者的一个示例。

6eaee5b38ec247ae.png

ViewModel

ViewModel 组件保存并公开 UI 使用的状态。UI 状态是由 ViewModel 转换的应用程序数据。ViewModel 使您的应用程序能够遵循从模型驱动 UI 的架构原则。

ViewModel 存储与应用程序相关的未在活动被 Android 框架销毁并重新创建时被销毁的数据。与活动实例不同,ViewModel 对象不会被销毁。应用程序会在配置更改期间自动保留 ViewModel 对象,以便它们持有的数据在重新组合后立即可用。

要在您的应用中实现 ViewModel,请扩展 ViewModel 类,该类来自架构组件库,并在该类中存储应用数据。

UI 状态

UI 是用户所看到的内容,而 UI 状态是应用告诉他们应该看到的内容。UI 是 UI 状态的视觉表示。对 UI 状态的任何更改都会立即反映在 UI 中。

9cfedef1750ddd2c.png

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 中。

  1. 打开 build.gradle.kts (Module :app),滚动到 dependencies 块,并为 ViewModel 添加以下依赖项。此依赖项用于将生命周期感知的视图模型添加到您的 Compose 应用中。
dependencies {
// other dependencies

    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
//...
}
  1. ui 包中,创建一个名为 GameViewModel 的 Kotlin 类/文件。从 ViewModel 类扩展它。
import androidx.lifecycle.ViewModel

class GameViewModel : ViewModel() {
}
  1. 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 类中

  • 属性 _countprivate 且可变的。因此,它只能在 ViewModel 类中访问和编辑。

ViewModel 类之外

  • Kotlin 中的默认可见性修饰符是 public,因此 count 是公共的,可以从其他类(如 UI 控制器)访问。val 类型不能具有 setter。它是不可变的且只读的,因此您只能覆盖 get() 方法。当外部类访问此属性时,它会返回 _count 的值,并且其值无法修改。此支持属性保护 ViewModel 中的应用数据,使其不会被外部类意外和不安全地更改,但它允许外部调用者安全地访问其值。
  1. 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> 
  1. 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 中选择一个随机词并将其打乱。

  1. GameViewModel 中,添加一个名为 currentWord 的属性,类型为 String,用于保存当前乱序词。
private lateinit var currentWord: String
  1. 添加一个辅助方法以从列表中选择一个随机词并将其洗牌。将其命名为 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 为未定义的变量和函数标记了一个错误。

  1. GameViewModel 中,在 currentWord 属性之后添加以下属性,作为可变集,用于存储游戏中使用的词。
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
  1. 添加另一个辅助方法以将当前词打乱,名为 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)
}
  1. 添加一个辅助函数以初始化游戏,名为 resetGame()。您将在稍后使用此函数启动和重启游戏。在此函数中,清除 usedWords 集中的所有词,初始化 _uiState。使用 pickRandomWordAndShuffle()currentScrambledWord 选择一个新词。
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. 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 显示该状态。

61eb7bcdcff42227.png

在应用架构中使用 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 使用的重新组合。

  1. GameScreen 函数中,传递类型为 GameViewModel 的第二个参数,其默认值为 viewModel()
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun GameScreen(
   gameViewModel: GameViewModel = viewModel()
) {
   // ...
}

de93b81a92416c23.png

  1. 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()
   // ...
}
  1. gameUiState.currentScrambledWord 传递给 GameLayout() 可组合项。您将在后面的步骤中添加参数,因此现在忽略错误。
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   modifier = Modifier
       .fillMaxWidth()
       .wrapContentHeight()
       .padding(mediumPadding)
)
  1. currentScrambledWord 作为另一个参数添加到 GameLayout() 可组合函数中。
@Composable
fun GameLayout(
   currentScrambledWord: String,
   modifier: Modifier = Modifier
) {
}
  1. 更新 GameLayout() 可组合函数以显示 currentScrambledWord。将列中第一个文本字段的 text 参数设置为 currentScrambledWord
@Composable
fun GameLayout(
   // ...
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       Text(
           text = currentScrambledWord,
           fontSize = 45.sp,
           modifier = modifier.align(Alignment.CenterHorizontally)
       )
    //... 
    }
}
  1. 运行并构建应用程序。您应该看到乱序的单词。

6d93a8e1ba5dad6f.png

显示猜测的单词

GameLayout() 可组合函数中,更新用户的猜测单词是事件回调之一,该回调从 GameScreen 向上流到 ViewModel。数据 gameViewModel.userGuess 将从 ViewModel 向下流到 GameScreen

the event callbacks keyboard done key press and user guess changes is passed from the UI to the view model

  1. 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() }
   ),
  1. GameLayout() 可组合函数中,添加另外两个参数:onUserGuessChanged lambda 接受一个 String 参数,不返回任何内容,onKeyboardDone 不接受任何参数,也不返回任何内容。
@Composable
fun GameLayout(
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   currentScrambledWord: String,
   modifier: Modifier = Modifier,
   ) {
}
  1. GameLayout() 函数调用中,为 onUserGuessChangedonKeyboardDone 添加 lambda 参数。
GameLayout(
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   currentScrambledWord = gameUiState.currentScrambledWord,
)

您将在 GameViewModel 中很快定义 updateUserGuess 方法。

  1. GameViewModel.kt 文件中,添加一个名为 updateUserGuess() 的方法,该方法接受一个 String 参数,即用户的猜测单词。在函数内部,使用传入的 guessedWord 更新 userGuess
  fun updateUserGuess(guessedWord: String){
     userGuess = guessedWord
  }

您将在下一步在 ViewModel 中添加 userGuess

  1. 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
  1. GameScreen.kt 文件中,在 GameLayout() 内部,为 userGuess 添加另一个 String 参数。将 OutlinedTextFieldvalue 参数设置为 userGuess
fun GameLayout(
   currentScrambledWord: String,
   userGuess: String,
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   modifier: Modifier = Modifier
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       //...
       OutlinedTextField(
           value = userGuess,
           //..
       )
   }
}
  1. GameScreen 函数中,更新 GameLayout() 函数调用以包含 userGuess 参数。
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   //...
)
  1. 构建并运行您的应用程序。
  2. 尝试猜测并输入一个单词。文本字段可以显示用户的猜测。

ed10c7f522495a.png

7. 验证猜测的单词并更新分数

在此任务中,您将实现一个方法来验证用户猜测的单词,然后更新游戏分数或显示错误。您将在稍后使用新的分数和新单词更新游戏状态 UI。

  1. GameViewModel 中,添加另一个名为 checkUserGuess() 的方法。
  2. checkUserGuess() 函数中,添加一个 if else 块来验证用户的猜测是否与 currentWord 相同。将 userGuess 重置为空字符串。
fun checkUserGuess() {
   
   if (userGuess.equals(currentWord, ignoreCase = true)) {
   } else {
   }
   // Reset user guess
   updateUserGuess("")
}
  1. 如果用户的猜测错误,则将 isGuessedWordWrong 设置为 trueMutableStateFlow<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)
       }
   }
  1. GameUiState 类中,添加一个名为 isGuessedWordWrongBoolean,并将其初始化为 false
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
)

接下来,您将在用户单击“提交”按钮或键盘中的完成键时,将事件回调 checkUserGuess()GameScreen 传递到 ViewModel。将数据 gameUiState.isGuessedWordWrongViewModel 传递到 GameScreen 以设置文本字段中的错误。

7f05d04164aa4646.png

  1. 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))
}
  1. GameScreen() 可组合函数中,更新 GameLayout() 函数调用以在 onKeyboardDone lambda 表达式中传递 gameViewModel.checkUserGuess()
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() }
)
  1. GameLayout() 可组合函数中,添加一个 Boolean 函数参数 isGuessWrong。将 OutlinedTextFieldisError 参数设置为 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() }
           ),
       )
}
}
  1. GameScreen() 可组合函数中,更新 GameLayout() 函数调用以传递 isGuessWrong
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() },
   isGuessWrong = gameUiState.isGuessedWordWrong,
   // ...
)
  1. 构建并运行您的应用程序。
  2. 输入一个错误的猜测并单击“提交”。观察文本字段变为红色,表示错误。

a1bc55781d627b38.png

请注意,文本字段标签仍然显示“输入您的单词”。为了使其更友好,您需要添加一些错误文本以指示单词错误。

  1. GameScreen.kt 文件中,在 GameLayout() 可组合函数中,根据 isGuessWrong 更新文本字段的标签参数,如下所示
OutlinedTextField(
   // ...
   label = {
       if (isGuessWrong) {
           Text(stringResource(R.string.wrong_guess))
       } else {
           Text(stringResource(R.string.enter_your_word))
       }
   },
   // ...
)
  1. strings.xml 文件中,为错误标签添加一个字符串。
<string name="wrong_guess">Wrong Guess!</string>
  1. 再次构建并运行您的应用程序。
  2. 输入一个错误的猜测并单击“提交”。请注意错误标签。

8c17eb61e9305d49.png

8. 更新分数和单词计数

在此任务中,您将在用户玩游戏时更新分数和单词计数。分数必须是 _ uiState 的一部分。

  1. GameUiState 中,添加一个名为 score 的变量,并将其初始化为零。
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
   val score: Int = 0
)
  1. 要更新分数值,在 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 {
       //...
   }
}
  1. GameViewModel 中,添加另一个名为 updateGameState 的方法,以更新分数,增加当前单词计数,并从 WordsData.kt 文件中选择一个新单词。添加一个名为 updatedScoreInt 作为参数。按如下方式更新游戏状态 UI 变量
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           isGuessedWordWrong = false,
           currentScrambledWord = pickRandomWordAndShuffle(),
           score = updatedScore
       )
   }
}
  1. 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("")
}

接下来,与分数更新类似,您需要更新单词计数。

  1. GameUiState 中添加另一个用于计数的变量。将其命名为 currentWordCount,并将其初始化为 1
data class GameUiState(
   val currentScrambledWord: String = "",
   val currentWordCount: Int = 1,
   val score: Int = 0,
   val isGuessedWordWrong: Boolean = false,
)
  1. GameViewModel.kt 文件中,在 updateGameState() 函数中,按如下所示增加单词计数。updateGameState() 函数被调用以准备下一轮游戏。
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           //...
           currentWordCount = currentState.currentWordCount.inc(),
           )
   }
}

传递分数和单词计数

完成以下步骤,将分数和单词计数数据从 ViewModel 传递到 GameScreen

546e101980380f80.png

  1. 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
           )


// ...

}
  1. 更新 GameLayout() 函数调用以包含单词计数。
GameLayout(
   userGuess = gameViewModel.userGuess,
   wordCount = gameUiState.currentWordCount,
   //...
)
  1. GameScreen() 可组合函数中,更新 GameStatus() 函数调用以包含 score 参数。从 gameUiState 传递分数。
GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))
  1. 构建并运行您的应用程序。
  2. 输入猜测的单词并单击“提交”。请注意分数和单词计数更新。
  3. 单击“跳过”,注意没有任何反应。

要实现跳过功能,您需要将跳过事件回调传递给 GameViewModel

  1. GameScreen.kt 文件中,在 GameScreen() 可组合函数中,在 onClick lambda 表达式中调用 gameViewModel.skipWord()

Android Studio 会显示错误,因为您尚未实现该函数。您将在下一步通过添加 skipWord() 方法来修复此错误。当用户跳过一个单词时,您需要更新游戏变量并为下一轮准备游戏。

OutlinedButton(
   onClick = { gameViewModel.skipWord() },
   modifier = Modifier.fillMaxWidth()
) {
   //...
}
  1. GameViewModel 中,添加 skipWord() 方法。
  2. skipWord() 函数内部,调用 updateGameState(),传递分数并重置用户的猜测。
fun skipWord() {
   updateGameState(_uiState.value.score)
   // Reset user guess
   updateUserGuess("")
}
  1. 运行您的应用程序并玩游戏。您现在应该能够跳过单词。

e87bd75ba1269e96.png

您仍然可以玩超过 10 个单词的游戏。在您的下一个任务中,您将处理游戏的最后一轮。

9. 处理游戏的最后一轮

在当前的实现中,用户可以跳过或玩超过 10 个单词的游戏。在此任务中,您将添加逻辑来结束游戏。

d3fd67d92c5d3c35.png

要实现游戏结束逻辑,您首先需要检查用户是否达到最大单词数。

  1. GameViewModel 中添加一个 if-else 块,并将现有的函数体移到 else 块中。
  2. 添加一个 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
           )
       }
   }
}
  1. if 块中,添加 Boolean 标志 isGameOver,并将标志设置为 true 以指示游戏结束。
  2. 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
           )
       }
   }
}
  1. 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
)
  1. 运行您的应用并玩游戏。您不能玩超过 10 个词。

ac8a12e66111f071.png

当游戏结束时,最好让用户知道并询问他们是否想再玩一次。您将在下一个任务中实现此功能。

显示游戏结束对话框

在此任务中,您将 isGameOver 数据从 ViewModel 传递到 GameScreen,并使用它来显示一个带选项的警告对话框,以便结束或重启游戏。

对话框是一个小型窗口,提示用户做出决定或输入更多信息。通常,对话框不会填满整个屏幕,并且需要用户采取操作才能继续。Android 提供了不同类型的对话框。在此 Codelab 中,您将学习有关警告对话框的信息。

警告对话框的结构

eb6edcdd0818b900.png

  1. 容器
  2. 图标(可选)
  3. 标题(可选)
  4. 辅助文本
  5. 分隔线(可选)
  6. 操作

启动代码中的 GameScreen.kt 文件已经提供了一个函数,该函数显示一个带选项的警告对话框,以便退出或重启游戏。

78d43c7aa01b414d.png

@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))
           }
       }
   )
}

在此函数中,titletext 参数在警告对话框中显示标题和辅助文本。 dismissButtonconfirmButton 是文本按钮。在 dismissButton 参数中,您显示文本 退出 并通过完成活动来终止应用程序。在 confirmButton 参数中,您重新启动游戏并显示文本 重新开始

a24f59b84a178d9b.png

  1. GameScreen.kt 文件中,在 FinalScoreDialog() 函数中,请注意用于显示游戏分数的得分参数。
@Composable
private fun FinalScoreDialog(
   score: Int,
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
  1. FinalScoreDialog() 函数中,请注意使用 text 参数 lambda 表达式来使用 score 作为对话框文本的格式参数。
text = { Text(stringResource(R.string.you_scored, score)) }
  1. GameScreen.kt 文件中,在 GameScreen() 可组合函数的末尾,在 Column 块之后,添加一个 if 条件来检查 gameUiState.isGameOver
  2. if 块中,显示警告对话框。调用 FinalScoreDialog(),传入 scoregameViewModel.resetGame(),作为 onPlayAgain 事件回调。
if (gameUiState.isGameOver) {
   FinalScoreDialog(
       score = gameUiState.score,
       onPlayAgain = { gameViewModel.resetGame() }
   )
}

resetGame() 是一个事件回调,它从 GameScreen 传递到 ViewModel

  1. GameViewModel.kt 文件中,请回忆 resetGame() 函数,它初始化 _uiState 并选择一个新词。
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. 构建并运行您的应用程序。
  2. 玩游戏直到结束,并观察带有选项的警告对话框,以便退出游戏或重新开始。尝试警告对话框中显示的选项。

c6727347fe0db265.png

10. 设备旋转中的状态

在之前的 Codelab 中,您学习了有关 Android 中配置更改的信息。当发生配置更改时,Android 会从头开始重新启动活动,运行所有生命周期启动回调。

ViewModel 存储与应用程序相关的不会在 Android 框架销毁和重新创建活动时被销毁的数据。 ViewModel 对象会自动保留,并且不会像活动实例一样在配置更改期间被销毁。它们保存的数据在重新组合后会立即可用。

在此任务中,您将检查应用程序在配置更改期间是否保留状态 UI。

  1. 运行应用程序并玩一些词。将设备的配置从纵向更改为横向,反之亦然。
  2. 观察在 ViewModel 的状态 UI 中保存的数据在配置更改期间是否保留。

4a63084643723724.png

4134470d435581dd.png

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 分享您的作品!

了解更多信息