Compose 中的 ViewModel 和状态

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

您将学习什么

您将构建什么

  • 一个 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",并且点按按钮没有任何反应。

在此 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

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。在此 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 可组合项相似。

文本字段有两种类型

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

3df34220c3d177eb.png

轮廓式文本字段的视觉强调比填充式文本字段少。当它们出现在表单等需要放置许多文本字段的地方时,其强调的减少有助于简化布局。

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

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

入门代码中未实现按钮点击事件。您将在此 Codelab 中实现这些事件。

FinalScoreDialog

FinalScoreDialog 可组合项会显示一个对话框(即提示用户的小窗口),其中包含再玩一次退出游戏的选项。在此 Codelab 的稍后部分,您将实现逻辑以在游戏结束时显示此对话框。

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 构建这些元素。
  • 状态 holder:保存数据、将其公开给 UI 并处理应用逻辑的组件。一个示例状态 holder 是 ViewModel

6eaee5b38ec247ae.png

ViewModel

ViewModel 组件持有并公开 UI 消耗的状态。UI 状态是 ViewModel 转换后的应用数据。ViewModel 使您的应用能够遵循从模型驱动 UI 的架构原则。

ViewModel 存储与应用相关的数据,这些数据在 Android 框架销毁并重新创建 Activity 时不会被销毁。与 Activity 实例不同,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 依赖项。此依赖项用于将感知生命周期的 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 是一个数据 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 类内部

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

ViewModel 类外部

  • Kotlin 中的默认可见性修饰符是 public,所以 count 是公共的,可以从 UI 控制器等其他类访问。一个 val 类型不能有 setter。它是不可变的和只读的,所以您只能重写 get() 方法。当外部类访问此属性时,它返回 _count 的值,并且其值无法修改。这种支持属性保护了 ViewModel 内部的应用数据免受外部类不必要和不安全的变化,但它允许外部调用者安全地访问其值。
  1. GameViewModel.kt 文件中,为名为 _uiStateuiState 添加一个支持属性。将属性命名为 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()

显示随机打乱顺序的单词

在此任务中,您将添加 helper 方法以从 WordsData.kt 中选取一个随机单词并打乱该单词。

  1. GameViewModel 中,添加一个类型为 String 的属性,名为 currentWord,用于保存当前打乱顺序的单词。
private lateinit var currentWord: String
  1. 添加一个 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 会标记未定义变量和函数的错误。

  1. GameViewModel 中,在 currentWord 属性之后添加以下属性,作为用于在游戏中存储已用单词的可变集。
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
  1. 添加另一个 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)
}
  1. 添加一个 helper 函数来初始化游戏,名为 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 处理——或者从应用的其他层传递的事件,例如指示用户会话已过期。
  • 更新状态:事件处理程序可能会更改状态。
  • 显示状态:状态 holder 向下传递状态,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 更新文本字段的 label 参数,如下所示
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. 要更新得分值,请在 GameViewModelcheckUserGuess() 函数中,在用户猜测正确的 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 块内部,添加布尔标志 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 参数中,您显示文本退出并通过完成 Activity 终止应用。在 confirmButton 参数中,您重新开始游戏并显示文本再玩一次

a24f59b84a178d9b.png

  1. GameScreen.kt 文件中,在 FinalScoreDialog() 函数中,注意 score 参数,以在警告对话框中显示游戏得分。
@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 会从头开始重新启动 Activity,运行所有生命周期启动回调。

ViewModel 存储与应用相关的数据,这些数据在 Android 框架销毁并重新创建 Activity 时不会被销毁。ViewModel 对象在配置变更期间会自动保留,不会像 Activity 实例那样被销毁。它们持有的数据在重组后立即可用。

在此任务中,您将检查应用在配置变更期间是否保留 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 标签!

了解更多