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. 点按 SubmitSkip 按钮以测试应用。

您会发现,应用中存在一些 bug。乱序词未显示,但已硬编码为“scrambleun”,而且当您点按按钮时,也没有任何反应。

在此 Codelab 中,您将使用 Android 应用架构来实现游戏功能。

起始代码演示

起始代码已为您预先设计了游戏屏幕布局。在本在线课程中,您将实现游戏逻辑。您将使用架构组件来实现推荐的应用架构并解决上述问题。下面简要介绍了一些文件,以帮助您上手。

WordsData.kt

此文件包含游戏中所用单词的列表,以及表示每局游戏最多单词数和玩家每猜对一个单词所得分数的常量。

package com.example.android.unscramble.data

const val MAX_NO_OF_WORDS = 10
const val SCORE_INCREASE = 20

// Set with all the words for the Game
val allWords: Set<String> =
   setOf(
       "animal",
       "auto",
       "anecdote",
       "alphabet",
       "all",
       "awesome",
       "arise",
       "balloon",
       "basket",
       "bench",
      // ...
       "zoology",
       "zone",
       "zeal"
)

MainActivity.kt

此文件主要包含模板生成的代码。您可以在 setContent{} 代码块中显示 GameScreen 可组合函数。

GameScreen.kt

所有界面可组合函数都在 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。您稍后将在此 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 可组合函数、游戏名称、单词数,以及 SubmitSkip 按钮的可组合函数。

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 可组合函数用于显示一个对话框(即提示用户执行操作的小窗口),其中包含 Play AgainExit 游戏选项。在此 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. 了解应用架构

应用架构提供了在应用中的类之间分配责任时应遵循的准则。精心设计的应用架构可以帮助您扩缩应用,以及用更多功能对应用进行扩展。架构还可以简化团队协作。

最常用的架构原则包括:分离关注点通过模型驱动界面

分离关注点

分离关注点设计原则指出,应将应用分为函数类,每个类都有各自的责任。

通过模型驱动界面

通过模型驱动界面原则指出,应该通过模型驱动界面,最好是通过持久性模型。模型是负责处理应用数据的组件。它们独立于应用中的界面元素和应用组件,因此不受应用的生命周期以及相关的关注点的影响。

基于上一部分提到的常用架构原则,每个应用应至少有两个层:

  • 界面层:在屏幕上显示应用数据但独立于数据的层。
  • 数据层:用于存储、检索和提供应用数据的层。

您可以另外添加一个名为“网域层”的架构层,以简化和重复使用界面层与数据层之间的交互。该层是可选的,不在本课程的范围之内。

a4da6fa5c1c9fed5.png

界面层

界面层(或表示层)的作用是在屏幕上显示应用数据。每当数据因用户互动(例如按了某个按钮)而发生变化时,界面都应随之更新,以反映这些变化。

界面层由以下组件组成:

  • 界面元素:用于在屏幕上呈现数据的组件。您将使用 Jetpack Compose 构建这些元素。
  • 状态容器:用于保存数据、向界面提供数据以及处理应用逻辑的组件。状态容器的一个示例为 ViewModel

6eaee5b38ec247ae.png

ViewModel

ViewModel 组件用于存储和公开界面所使用的状态。界面状态是经过 ViewModel 转换的应用数据。ViewModel 可让您的应用遵循通过模型驱动界面的架构原则。

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

如需在应用中实现 ViewModel,请扩展架构组件库中提供的 ViewModel 类,并将应用数据存储在该类中。

界面状态

界面是相对用户而言的,而界面状态是相对应用而言的。界面是界面状态的直观呈现。对界面状态所做的任何更改都会立即反映在界面中。

9cfedef1750ddd2c.png

界面是将屏幕上的界面元素与界面状态绑定在一起的结果。

// Example of UI state definition, do not copy over

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

不可变性

以上示例中的界面状态定义是不可变的。不可变对象可保证多个来源不会即时更改应用的状态。这种保护机制让界面可以专注于发挥单一作用:读取状态并相应地更新其界面元素。因此,切勿直接在界面中修改界面状态,除非界面本身是其数据的唯一来源。违反这个原则会导致同一条信息有多个可信来源,从而导致数据不一致和轻微的 bug。

5. 添加 ViewModel

在此任务中,您将向应用添加 ViewModel,以存储游戏界面状态(乱序词、单词数和得分)。如要解决您在上一部分中注意到的起始代码中的问题,您需要将游戏数据保存在 ViewModel 中。

  1. 打开 build.gradle.kts (Module :app),滚动到 dependencies 代码块,然后为 ViewModel 添加以下依赖项。此依赖项用于向 Compose 应用添加生命周期感知型 ViewModel。
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 软件包中,为状态界面添加一个名为 GameUiState 的模型类。将其设为数据类,并为当前的乱序词添加变量。
data class GameUiState(
   val currentScrambledWord: String = ""
)

StateFlow

StateFlow 是一个数据容器式可观察数据流,可发出当前状态更新和新状态更新。其 value 属性反映了当前状态值。如需更新状态并将其发送到数据流,请为 MutableStateFlow 类的 value 属性分配一个新值。

在 Android 中,StateFlow 适用于必须维护可观察的不可变状态的类。

可以从 GameUiState 公开 StateFlow,以便可组合函数能够监听界面状态更新,并使屏幕状态在配置更改后继续有效。

GameViewModel 类中,添加以下 _uiState 属性。

import kotlinx.coroutines.flow.MutableStateFlow

// Game UI state
private val _uiState = MutableStateFlow(GameUiState())

后备属性

使用后备属性,可以从 getter 返回确切对象之外的某些其他内容。

Kotlin 框架会为 var 属性生成 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 是公共属性,可从界面控制器等其他类对其进行访问。val 类型不能包含 setter。它不可变且处于只读状态,因此您只能替换 get() 方法。当外部类访问此属性时,它会返回 _count 的值且其值无法修改。此后备属性可以防止外部类擅自对 ViewModel 内的应用数据进行不安全的更改,但允许外部调用方安全地访问该应用数据的值。
  1. GameViewModel.kt 文件中,向名为 _uiStateuiState 添加后备属性。将该属性命名为 uiState,其类型为 StateFlow<GameUiState>

现在,只能在 GameViewModel 中对 _uiState 进行访问和修改。界面可以使用只读属性 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()
}

现在,在构建应用时,您仍然不会看到界面有任何变化。您没有将 ViewModel 中的数据传递给 GameScreen 中的可组合函数。

6. 构建 Compose 界面

在 Compose 中,更新界面的唯一方式是更改应用的状态。您可以控制的是界面状态。每当界面的状态发生变化时,Compose 都会重新创建界面树中已更改的部分。可组合函数可以接受状态并公开事件。例如,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) 是一种设计模式,在该模式下,状态向下流动,事件向上流动。通过遵循单向数据流,您可以将在界面中显示状态的可组合函数与应用中存储和更改状态的部分解耦。

使用单向数据流的应用的界面更新循环如下所示:

  • 事件:界面的某一部分生成一个事件,并将其向上传递,例如将按钮点击传递给 ViewModel 进行处理;或者从应用的其他层传递事件,如指示用户会话已过期。
  • 更新状态:事件处理脚本可能会更改状态。
  • 显示状态:状态容器向下传递状态,而界面会显示此状态。

61eb7bcdcff42227.png

在应用架构中使用 UDF 模式会产生以下影响:

  • ViewModel 会存储并公开界面所使用的状态。
  • 界面状态是经过 ViewModel 转换的应用数据。
  • 界面会向 ViewModel 发送用户事件通知。
  • ViewModel 会处理用户操作并更新状态。
  • 更新后的状态将反馈给界面以进行呈现。
  • 系统会对导致状态更改的所有事件重复此流程。

传递数据

将 ViewModel 实例传递给界面,即从 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

事件回调键盘完成按键操作,用户猜测更改从界面传递到视图模型

  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. 验证猜出的单词并更新得分

在此任务中,您将实现一种方法来验证用户猜出的单词,然后更新游戏得分或显示错误。之后,您将用新得分和新单词来更新游戏状态界面。

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

接下来,在用户点击 Submit 按钮或按键盘中的“Done”键时,您将事件回调 checkUserGuess()GameScreen 向上传递至 ViewModel。将数据 gameUiState.isGuessedWordWrongViewModel 向下传递至 GameScreen,以设置文本字段中显示的错误。

7f05d04164aa4646.png

  1. GameScreen.kt 文件中的 GameScreen() 可组合函数末尾,在 Submit 按钮的 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. 输入错误的猜测,然后点击 Submit。请注意,文本字段会变为红色,指明猜测有误。

a1bc55781d627b38.png

请注意,文本字段标签仍然显示“Enter your word”。为使应用简单易用,您需要添加一些错误文本,指明所猜的单词不对。

  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. 输入错误的猜测,然后点击 Submit。请注意错误标签。

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 作为参数。更新游戏状态界面变量,如下所示:
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. 输入猜出的单词,然后点击 Submit。请注意,得分和单词数更新了。
  3. 点击 Skip,您会发现没有任何反应。

如要实现跳过功能,您需要将跳过事件回调传递给 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 个以上的单词,或者在猜完 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 参数中,您显示文本 Exit,并通过结束 activity 来终止应用。在 confirmButton 参数中,您重玩游戏并显示文本 Play Again

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(),从而传入 onPlayAgain 事件回调的 scoregameViewModel.resetGame()
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. 玩游戏,直到游戏结束,可以看到包含用于退出游戏或重玩游戏的 ExitPlay Again 选项的提醒对话框。尝试提醒对话框中显示的选项。

c6727347fe0db265.png

10. 设备旋转状态

在之前的 Codelab 中,您已了解了 Android 中的配置更改。发生配置更改时,Android 会从头开始重启相应 activity,同时运行所有生命周期启动回调。

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

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

  1. 运行应用并玩一下游戏,猜几个单词。将设备配置从竖屏模式更改为横屏模式,或者从横屏模式更改为竖屏模式。
  2. 可以看到,ViewModel 的状态界面中保存的数据在配置更改期间会保留。

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 应用架构准则如何建议将具有不同责任的类分离并通过模型驱动界面。

别忘了使用 #AndroidBasics 标签在社交媒体上分享您的作品!

了解详情