将数据存储在 ViewModel 中

1. 准备工作

在之前的 Codelab 中,您已了解了 activity 和 fragment 的生命周期以及与配置更改相关的生命周期问题。想要保存应用数据,有一种选择是保存实例状态,但这种选择有其自身的局限性。在此 Codelab 中,您将了解一种利用 Android Jetpack 库设计应用并在配置更改期间保留应用数据的可靠方式。

Android Jetpack 库是一系列库的集合,可以让您更轻松地开发出优质的 Android 应用。这些库能帮助您遵循最佳做法、省去编写样板代码的工作并简化复杂任务,以便集中精力编写重要的代码(例如应用逻辑)。

Android 架构组件Android Jetpack 库的一部分,可帮助您设计具有良好架构的应用。架构组件提供关于应用架构的指南,是推荐采用的最佳做法。

应用架构是一组设计规则。就像房屋的蓝图一样,架构为应用提供了结构。良好的应用架构能让代码在未来几年内始终保持可靠性、灵活性、可伸缩性和可维护性。

在此 Codelab 中,您将学习如何使用 ViewModel(其中一个架构组件)存储应用数据。当框架在配置更改或其他事件期间销毁并重新创建 activity 和 fragment 时,存储的数据不会丢失。

前提条件

  • 如何从 GitHub 下载源代码并在 Android Studio 中将其打开?
  • 如何使用 activity 和 fragment 在 Kotlin 中创建和运行基本 Android 应用?
  • 有关 Material 文本字段和常见界面 widget(例如 TextViewButton)的知识。
  • 如何在应用中使用视图绑定?
  • activity 和 fragment 生命周期的基础知识。
  • 如何在 Android Studio 中使用 Logcat 向应用添加日志记录信息和读取日志?

学习内容

构建内容

  • 一个让用户猜乱序词(打乱字母顺序的词)的 Unscramble 游戏应用。

所需条件

  • 一台安装了 Android Studio 的计算机。
  • Unscramble 应用的起始代码

2. 起始应用概览

游戏概览

Unscramble 应用是一款猜乱序词的单人游戏。应用一次显示一个乱序词,玩家必须根据乱序词中的所有字母猜出这个单词。如果猜出的单词正确,玩家就会得分,否则玩家可以重新猜,次数不限。此外,应用还提供了一个跳过当前单词的选项。应用会在左上角显示单词数,即当前这一局游戏中猜过的单词数量。每一局游戏有 10 个单词。

8edd6191a40a57e1.png 992bf57f066caf49.png b82a9817b5ec4d11.png

下载起始代码

此 Codelab 提供了起始代码,供您使用此 Codelab 中所教的功能对其进行扩展。起始代码可能既包含您在之前的 Codelab 中已熟悉的代码,也包含您不熟悉的代码。这些您尚不熟悉的代码将在后续 Codelab 中进行更详细的介绍。

如果您使用 GitHub 中的起始代码,请注意文件夹名称为 android-basics-kotlin-unscramble-app-starter。在 Android Studio 中打开项目时,请选择此文件夹。

  1. 进入为此项目提供的 GitHub 代码库页面。
  2. 验证分支名称是否与此 Codelab 中指定的分支名称一致。例如,在以下屏幕截图中,分支名称为 main

1e4c0d2c081a8fd2.png

  1. 在项目的 GitHub 页面上,点击 Code 按钮,以打开一个弹出式窗口。

1debcf330fd04c7b.png

  1. 在弹出式窗口中,点击 Download ZIP 按钮,将项目保存到计算机上。等待下载完成。
  2. 在计算机上找到该文件(很可能在 Downloads 文件夹中)。
  3. 双击 ZIP 文件进行解压缩。系统将创建一个包含项目文件的新文件夹。

在 Android Studio 中打开项目

  1. 启动 Android Studio。
  2. Welcome to Android Studio 窗口中,点击 Open

d8e9dbdeafe9038a.png

注意:如果 Android Studio 已经打开,则改为依次选择 File > Open 菜单选项。

8d1fda7396afe8e5.png

  1. 在文件浏览器中,前往解压缩的项目文件夹所在的位置(很可能在 Downloads 文件夹中)。
  2. 双击该项目文件夹。
  3. 等待 Android Studio 打开项目。
  4. 点击 Run 按钮 8de56cba7583251f.png 以构建并运行应用。请确保该应用按预期构建。

起始代码概览

  1. 在 Android Studio 中打开包含起始代码的项目。
  2. 在 Android 设备或模拟器上运行应用。
  3. 玩一下游戏,猜几个单词,点按 SubmitSkip 按钮。请注意,点按按钮后会显示下一个单词,并且单词数会增加。
  4. 请注意,只有在点按 Submit 按钮后,得分才会增加。

起始代码存在的问题

您在玩游戏时,可能已经发现了下列错误:

  1. 点击 Submit 按钮后,应用不会检查玩家的单词。玩家总是会得分。
  2. 无法结束游戏。猜完 10 个单词之后,应用也会让您玩下去。
  3. 游戏画面显示乱序词、玩家得分和单词数。此时通过旋转设备或模拟器更改屏幕方向,请注意,当前单词、得分和单词数都会丢失,游戏将重新开始。

应用中的主要问题

在配置更改期间(例如设备的屏幕方向发生变化时),起始应用不会保存和恢复应用状态和数据。

使用 onSaveInstanceState() 回调可以解决此问题。不过,使用 onSaveInstanceState() 方法需要编写额外的代码将状态保存在一个软件包中,并实现相应逻辑来检索该状态。而且,可以存储的数据量极少。

您可以使用本在线课程中所学的 Android 架构组件来解决这些问题。

起始代码演示

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

game_fragment.xml

  • Design 视图中,打开 res/layout/game_fragment.xml
  • 此文件包含应用中唯一画面(即游戏画面)的布局。
  • 此布局包含一个供玩家输入单词的文本字段,以及用于显示得分和单词数的 TextViews。此外,此布局还包含游戏说明和用于玩游戏的按钮(SubmitSkip)。

main_activity.xml

此文件定义了只有一个游戏 fragment 的主 activity 布局。

res/values 文件夹

此文件夹中是一些您所熟悉的资源文件。

  • colors.xml 包含应用中使用的主题颜色
  • strings.xml 包含应用所需的全部字符串
  • themesstyles 文件夹包含为应用完成的界面自定义

MainActivity.kt

此文件包含默认模板生成的代码,用于将 activity 的内容视图设为 main_activity.xml.

ListOfWords.kt

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

GameFragment.kt

这是应用中唯一的 fragment,游戏中的大多数操作都在其中发生:

  • 为当前乱序词 (currentScrambledWord)、单词数 (currentWordCount) 和得分 (score) 定义了变量。
  • 定义了有权访问 game_fragment 视图的名为 binding 的绑定对象实例。
  • onCreateView() 函数使用绑定对象膨胀 game_fragment 布局 XML 文件。
  • onViewCreated() 函数用于设置按钮点击监听器并更新界面。
  • onSubmitWord()Submit 按钮的点击监听器,此函数用于显示下一个乱序词,清空文本字段,并增加得分和单词数(无需确认玩家猜出的单词是否正确)。
  • onSkipWord()Skip 按钮的点击监听器,此函数像 onSubmitWord() 一样更新界面,只是不增加得分。
  • getNextScrambledWord() 是一个辅助函数,用于从单词列表中随机选择一个单词,并打乱单词中的字母。
  • restartGame()exitGame() 函数分别用于重新开始和结束游戏,稍后您会用到这些函数。
  • setErrorTextField() 用于清除文本字段内容并重置错误状态。
  • updateNextWordOnScreen() 函数用于显示新的乱序词。

3. 了解应用架构

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

最普适的架构原则是:分离关注点和通过模型驱动界面。

分离关注点

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

通过模型驱动界面

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

Android 架构中主要的类或组件是界面控制器 (activity/fragment)、ViewModelLiveDataRoom。这些组件负责处理生命周期的某些复杂情况,并帮助您避免与生命周期相关的问题。后续 Codelab 中将介绍 LiveDataRoom

下图所示为架构的基本组成部分:

597074ed0d08947b.png

界面控制器 (activity/fragment)

activity 和 fragment 是界面控制器。界面控制器通过在屏幕上绘制视图、捕获用户事件以及与用户与之互动的界面相关的所有其他操作来控制界面。应用中的数据或有关该数据的任何决策逻辑都不应放到界面控制器类中。

Android 系统可能会根据某些用户互动情况或因内存不足等系统条件而随时销毁界面控制器。由于这些事件不受您的控制,因此您不应将任何应用数据或状态存储到界面控制器中,而应将有关数据的决策逻辑添加到 ViewModel 中。

例如,在 Unscramble 应用中,乱序词、得分和单词数显示于 fragment(界面控制器)中,而决策代码(例如确定下一个乱序词)以及得分和单词数的计算则应位于 ViewModel 中。

ViewModel

ViewModel 是视图中显示的应用数据的模型。模型是负责处理应用数据的组件,能够让应用遵循架构原则,通过模型驱动界面。

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

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

总结:

fragment/activity(界面控制器)的责任

ViewModel 的责任

activity 和 fragment 负责将视图和数据绘制到屏幕上并响应用户事件。

ViewModel 负责存储和处理界面需要的所有数据。它绝不应访问视图层次结构(例如视图绑定对象)或存储对 activity 或 fragment 的引用。

4. 添加 ViewModel

在此任务中,您将向应用添加 ViewModel,用于存储应用数据(乱序词、单词数和得分)。

应用的架构设计如下。MainActivity 包含一个 GameFragment,该 GameFragment 将从 GameViewModel 中访问有关游戏的信息。

2b29a13dde3481c3.png

  1. 在 Android Studio 的 Android 窗口中,打开 Gradle Scripts 文件夹下的文件 build.gradle(Module:Unscramble.app)
  2. 为了在应用中使用 ViewModel,需要验证 dependencies 块内是否有 ViewModel 库依赖项。我们已代您完成此步骤。根据库的最新版本,所生成代码中的库版本号可能会有所不同。
// ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'

尽管此 Codelab 中提到了版本,但建议您始终使用该库的最新版本

  1. 新建一个名为 GameViewModel 的 Kotlin 类文件。在 Android 窗口中,右键点击 ui.game 文件夹。依次选择 New > Kotlin File/Class

d48361a4f73d4acb.png

  1. 将其命名为 GameViewModel,然后从列表中选择 Class
  2. GameViewModel 更改为由 ViewModel 派生的子类。ViewModel 是一个抽象类,因此您需要对它进行扩展才能在应用中使用它。请参阅下面的 GameViewModel 类定义。
class GameViewModel : ViewModel() {
}

将 ViewModel 附加到 fragment

为了将 ViewModel 与界面控制器(activity/fragment)相关联,请在界面控制器内创建对 ViewModel 的引用(对象)。

在此步骤中,您将在相应的界面控制器(即 GameFragment)内创建 GameViewModel 的对象实例。

  1. GameFragment 类的顶部,添加一个类型为 GameViewModel 的属性。
  2. 使用 by viewModels() Kotlin 属性委托初始化 GameViewModel。在下一个部分,您将对 Kotlin 属性委托作进一步了解。
private val viewModel: GameViewModel by viewModels()
  1. 如果 Android Studio 提示,请导入 androidx.fragment.app.viewModels

Kotlin 属性委托

在 Kotlin 中,每个可变 (var) 属性都具有自动为其生成的默认 getter 和 setter 函数。当您为该属性赋值或读取其值时,系统会调用 setter 和 getter 函数。

只读属性 (val) 与可变属性略有不同,默认情况下仅为其生成 getter 函数。当您读取只读属性的值时,系统会调用此 getter 函数。

Kotlin 中的属性委托可以帮助您将 getter-setter 的责任移交给另一个类。

此类(称为“委托类”)提供属性的 getter 和 setter 函数并处理其变更。

delegate 属性使用 by 子句和 delegate 类实例进行定义:

// Syntax for property delegation
var <property-name> : <property-type> by <delegate-class>()

在应用中,如果您如下使用默认的 GameViewModel 构造函数初始化视图模型:

private val viewModel = GameViewModel()

那么,当设备经过配置变更后,应用会丢失 viewModel 引用的状态。例如,如果您旋转设备,activity 就会被销毁并重新创建,而您将重新获得初始状态的新视图模型实例。

请改为使用属性委托方法,并将 viewModel 对象的责任委托给一个不同的类(名为 viewModels)。这意味着当您访问 viewModel 对象时,将由 delegate 类 viewModels 在内部对其进行处理。delegate 类会在第一次访问时为您创建 viewModel 对象并在配置更改时保留其值,然后在收到请求时返回该值。

5. 将数据移至 ViewModel

将应用的界面数据与界面控制器(Activity/Fragment 类)分离可以让您更好地遵循我们前面讨论的单一责任原则。activity 和 fragment 负责将视图和数据绘制到屏幕上,而 ViewModel 则负责存储并处理界面所需的所有数据。

在此任务中,您要将数据变量从 GameFragment 移至 GameViewModel 类。

  1. 将数据变量 scorecurrentWordCountcurrentScrambledWord 移至 GameViewModel 类。
class GameViewModel : ViewModel() {

    private var score = 0
    private var currentWordCount = 0
    private var currentScrambledWord = "test"
...
  1. 请注意有关未解析的引用的错误。这是因为这些属性仅对 ViewModel 可见,界面控制器无法对其进行访问。接下来,您将修复这些错误。

想要解决此问题,就不能将这些属性的可见性修饰符设为 public,不应该让数据可被其他类修改。这种做法存在风险,因为外部类可能会以不符合视图模型中指定的游戏规则的预料外方式对数据做出更改。例如,外部类可能会将 score 更改为负值。

ViewModel 之内,数据应可修改,因此数据应设为 privatevar。而在 ViewModel 之外,数据应可读取但无法修改,因此数据应作为 publicval 公开。为了实现此行为,Kotlin 提供了称为后备属性的功能。

后备属性

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

我们已经学过,Kotlin 框架会为每个属性生成 getter 和 setter。

对于 getter 和 setter 方法,您可以替换其中一个方法或同时替换两个方法,并提供您自己的自定义行为。为了实现后备属性,您需要替换 getter 方法以返回只读版本的数据。后备属性示例:

// 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 类中对其访问和修改。惯例是为 private 属性添加下划线前缀。

ViewModel 类之外:

  • Kotlin 中的默认可见性修饰符为 public,因此 count 是公共属性,可从界面控制器等其他类对其进行访问。由于只有 get() 方法会被替换,所以此属性不可变且为只读状态。当外部类访问此属性时,它会返回 _count 的值且其值无法修改。这可以防止外部类擅自对 ViewModel 内的应用数据进行不安全的更改,但允许外部调用方安全地访问该应用数据的值。

将后备属性添加到 currentScrambledWord

  1. GameViewModel 中,更改 currentScrambledWord 声明以添加一个后备属性。现在,只能在 GameViewModel 中对 _currentScrambledWord 进行访问和修改。界面控制器 GameFragment 可以使用只读属性 currentScrambledWord 读取其值。
private var _currentScrambledWord = "test"
val currentScrambledWord: String
   get() = _currentScrambledWord
  1. GameFragment 中,更新 updateNextWordOnScreen() 方法以使用只读的 viewModel 属性 currentScrambledWord
private fun updateNextWordOnScreen() {
   binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
}
  1. GameFragment 中,删除 onSubmitWord()onSkipWord() 方法内的代码。稍后您将实现这些方法。现在,您应该能够不出错误地编译代码了。

6. ViewModel 的生命周期

只要 activity 或 fragment 的范围处于有效状态,框架就会让 ViewModel 保持有效。即使所有者因配置更改(如屏幕旋转)而被销毁,ViewModel 也不会被销毁。所有者的新实例会重新连接到现有 ViewModel 实例,如下图所示:

91227008b74bf4bb.png

了解 ViewModel 生命周期

GameViewModelGameFragment 中添加日志记录,帮助您更好地了解 ViewModel 生命周期的情况。

  1. GameViewModel.kt 中,添加一个带有日志语句的 init 块。
class GameViewModel : ViewModel() {
   init {
       Log.d("GameFragment", "GameViewModel created!")
   }

   ...
}

Kotlin 提供了初始化式块(也称为 init 块),作为对象实例初始化期间所需的初始设置代码的位置。初始化式块带有前缀 init 关键字,后跟花括号 {}。此代码块将于首次创建和初始化对象实例时运行。

  1. GameViewModel 类中,替换 onCleared() 方法。当关联的 fragment 分离后或 activity 完成后,ViewModel 会被销毁。在 ViewModel 被销毁前会调用 onCleared() 回调。
  2. onCleared() 内添加日志语句,以跟踪 GameViewModel 生命周期。
override fun onCleared() {
    super.onCleared()
    Log.d("GameFragment", "GameViewModel destroyed!")
}
  1. GameFragment 中的 onCreateView() 内,获得对绑定对象的引用后,添加日志语句以记录 fragment 的创建。首次创建 fragment 时以及每次因任何事件(例如配置更改)而重新创建 fragment 时,都会触发 onCreateView() 回调。
override fun onCreateView(
   inflater: LayoutInflater, container: ViewGroup?,
   savedInstanceState: Bundle?
): View {
   binding = GameFragmentBinding.inflate(inflater, container, false)
   Log.d("GameFragment", "GameFragment created/re-created!")
   return binding.root
}
  1. GameFragment 中,替换 onDetach() 回调方法,相应的 activity 和 fragment 被销毁时会调用该回调方法。
override fun onDetach() {
    super.onDetach()
    Log.d("GameFragment", "GameFragment destroyed!")
}
  1. 在 Android Studio 中,运行应用,打开 Logcat 窗口并按 GameFragment 进行过滤。请注意,创建了 GameFragmentGameViewModel
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
  1. 在您的设备上或模拟器中启用自动屏幕旋转设置,并更改几次屏幕方向。GameFragment 每次都被销毁并重新创建,而 GameViewModel 只创建了一次,并不是每次调用都重新创建或销毁。
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
  1. 退出游戏或使用返回箭头退出应用。GameViewModel 会被销毁,onCleared() 回调会被调用。GameFragment 会被销毁。
com.example.android.unscramble D/GameFragment: GameViewModel destroyed!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!

7. 填充 ViewModel

在此任务中,您将使用辅助方法进一步填充 GameViewModel 以获取下一个单词,确认玩家猜出的单词是否正确以增加得分,并检查单词数以结束游戏。

延迟初始化

通常,在声明变量时,您会预先为其提供一个初始值。不过,如果您还没准备好赋值,也可以稍后再对其进行初始化。如需在 Kotlin 中对属性进行延迟初始化,请使用关键字 lateinit(意思是延迟初始化)。如果您能保证您会在使用前对属性进行初始化,就可以使用 lateinit 声明该属性。在对变量进行初始化之前,不会为其分配内存。如果您尝试在初始化变量之前对其进行访问,应用会崩溃。

获取下一个单词

GameViewModel 类中创建具有以下功能的 getNextWord() 方法:

  • allWordsList 中获取一个随机单词并将其赋值给 currentWord.
  • 打乱 currentWord 中的字母以创建一个乱序词并将其赋值给 currentScrambledWord
  • 处理乱序词与理顺词(理清字母顺序的单词)相同的情况。
  • 确保不会在游戏期间重复显示同一个单词。

GameViewModel 类中执行以下步骤:

  1. GameViewModel, 中,添加一个类型为 MutableList<String>、名为 wordsList 的新类变量,用于存储游戏中所用单词的列表,以避免重复。
  2. 添加名为 currentWord 的另一个类变量,用于存储玩家正在尝试理顺的单词。请使用 lateinit 关键字,因为您稍后才会初始化此属性。
private var wordsList: MutableList<String> = mutableListOf()
private lateinit var currentWord: String
  1. init 代码块上方添加一个名为 getNextWord() 的新 private 方法,该方法不带参数也不返回任何内容。
  2. allWordsList 中获取一个随机单词并将其赋值给 currentWord
private fun getNextWord() {
   currentWord = allWordsList.random()
}
  1. getNextWord() 中,将 currentWord 字符串转换为字符数组,并将其赋值给名为 tempWord 的新 val。为了打乱单词的字母顺序,请使用 Kotlin 方法 shuffle() 打乱此数组中的字符。
val tempWord = currentWord.toCharArray()
tempWord.shuffle()

ArrayMutableList 类似,但其大小在初始化时是固定的。Array 的大小无法扩缩(您需要复制数组才能调整其大小),而 MutableList 具有 add()remove() 函数,因此其大小可以增减。

  1. 有时,打乱的字符顺序与原单词相同。在 shuffle 调用前后添加以下 while 循环,以便继续该循环直至乱序词与原单词不同。
while (String(tempWord).equals(currentWord, false)) {
    tempWord.shuffle()
}
  1. 添加一个 if-else 块,用于检查某个单词是否已用过。如果 wordsList 包含 currentWord,就调用 getNextWord()。如果不包含,就使用新打乱字母顺序的单词更新 _currentScrambledWord 的值,增加单词数,然后将新单词添加到 wordsList 中。
if (wordsList.contains(currentWord)) {
    getNextWord()
} else {
    _currentScrambledWord = String(tempWord)
    ++currentWordCount
    wordsList.add(currentWord)
}
  1. 以下是完成后的 getNextWord() 方法,供您参考。
/*
* Updates currentWord and currentScrambledWord with the next word.
*/
private fun getNextWord() {
   currentWord = allWordsList.random()
   val tempWord = currentWord.toCharArray()
   tempWord.shuffle()

   while (String(tempWord).equals(currentWord, false)) {
       tempWord.shuffle()
   }
   if (wordsList.contains(currentWord)) {
       getNextWord()
   } else {
       _currentScrambledWord = String(tempWord)
       ++currentWordCount
       wordsList.add(currentWord)
   }
}

延迟初始化 currentScrambledWord

现在,您已创建了 getNextWord() 方法,用于获取下一个乱序词。您将在首次初始化 GameViewModel 时调用此方法。使用 init 块初始化当前单词等类中的 lateinit 属性。其结果是,屏幕上显示的第一个单词将是一个乱序词而不是 test

  1. 运行应用。请注意,第一个单词始终是“test”。
  2. 如需在应用启动时显示乱序词,需要调用 getNextWord() 方法,该方法会进而更新 currentScrambledWord。在 GameViewModelinit 块内,调用 getNextWord() 方法。
init {
    Log.d("GameFragment", "GameViewModel created!")
    getNextWord()
}
  1. lateinit 修饰符添加到 _currentScrambledWord 属性上。明确指定数据类型为 String,因为此时不会提供初始值。
private lateinit var _currentScrambledWord: String
  1. 运行应用。请注意,应用启动时显示的是一个新的乱序词。棒极了!

8edd6191a40a57e1.png

添加辅助方法

接下来,添加一个辅助方法,用于在 ViewModel 内处理和修改数据。您在后续任务中将用到此方法。

  1. GameViewModel 类中,添加名为 nextWord(). 的另一个方法。从列表中获取下一个单词,如果单词数少于 MAX_NO_OF_WORDS,就返回 true
/*
* Returns true if the current word count is less than MAX_NO_OF_WORDS.
* Updates the next word.
*/
fun nextWord(): Boolean {
    return if (currentWordCount < MAX_NO_OF_WORDS) {
        getNextWord()
        true
    } else false
}

8. 对话框

在起始代码中,游戏永远不会结束,即使猜完 10 个单词后仍会继续。修改应用,使游戏在用户猜完 10 个单词后结束,并显示包含最终得分的对话框。此外,您还要为用户提供一个用于选择再玩一次还是退出游戏的选项。

62aa368820ffbe31.png

这是您首次向应用中添加对话框。对话框是提示用户做出决定或输入更多信息的小窗口(画面)。通常,对话框不会填满整个屏幕,而且需要用户执行操作后才能继续。Android 提供不同类型的对话框。在此 Codelab 中,您将学习提醒对话框。

提醒对话框详解

f8650ca15e854fe4.png

  1. 提醒对话框
  2. 标题(可选)
  3. 消息
  4. 文本按钮

实现最终得分对话框

使用 Material Design 组件库中的 MaterialAlertDialog,向应用中添加遵循 Material 准则的对话框。由于对话框与界面相关,因此将由 GameFragment 负责创建和显示最终得分对话框。

  1. 首先,向 score 变量添加一个后备属性。在 GameViewModel 中,对 score 变量声明做以下更改。
private var _score = 0
val score: Int
   get() = _score
  1. GameFragment 中,添加一个名为 showFinalScoreDialog() 的私有函数。若要创建 MaterialAlertDialog,请使用 MaterialAlertDialogBuilder 类逐步构建对话框的各个部分。使用 fragment 的 requireContext() 方法调用传入内容的 MaterialAlertDialogBuilder 构造函数。requireContext() 方法会返回一个非 null Context
/*
* Creates and shows an AlertDialog with the final score.
*/
private fun showFinalScoreDialog() {
   MaterialAlertDialogBuilder(requireContext())
}

顾名思义,Context 是指应用、activity 或 fragment 的上下文或当前状态。它包含有关 activity、fragment 或应用的信息。通常,它用于获取对资源、数据库和其他系统服务的访问权限。在此步骤中,您将传递 fragment 上下文以创建提醒对话框。

如果 Android Studio 提示,请运行 import com.google.android.material.dialog.MaterialAlertDialogBuilder

  1. 添加代码以对提醒对话框设置标题,使用 strings.xml 中的字符串资源。
MaterialAlertDialogBuilder(requireContext())
   .setTitle(getString(R.string.congratulations))
  1. 设置消息以显示最终得分,使用您先前添加的只读版本得分变量 (viewModel.score)。
   .setMessage(getString(R.string.you_scored, viewModel.score))
  1. 使用 setCancelable() 方法并传递 false,使提醒对话框在用户按下返回键时无法取消。
    .setCancelable(false)
  1. 使用 setNegativeButton()setPositiveButton() 方法,添加两个文本按钮 EXITPLAY AGAIN。从 lambda 分别调用 exitGame()restartGame()
    .setNegativeButton(getString(R.string.exit)) { _, _ ->
        exitGame()
    }
    .setPositiveButton(getString(R.string.play_again)) { _, _ ->
        restartGame()
    }

您可能不熟悉此语法,但这是 setNegativeButton(getString(R.string.exit), { _, _ -> exitGame()}) 的简写形式,其中 setNegativeButton() 方法接受两个参数:一个 String 和一个函数 DialogInterface.OnClickListener(),后者可以用一个 lambda 来表示。当传入的最后一个参数是函数时,您可以将 lambda 表达式放在圆括号外。这称为尾随 lambda 语法这两种代码编写方式(将 lambda 放在圆括号内和圆括号外)均可接受。这一点同样适用于 setPositiveButton 函数。

  1. 最后,添加 show(),用于创建然后显示提醒对话框。
      .show()
  1. 以下是完整的 showFinalScoreDialog() 方法,供您参考。
/*
* Creates and shows an AlertDialog with the final score.
*/
private fun showFinalScoreDialog() {
   MaterialAlertDialogBuilder(requireContext())
       .setTitle(getString(R.string.congratulations))
       .setMessage(getString(R.string.you_scored, viewModel.score))
       .setCancelable(false)
       .setNegativeButton(getString(R.string.exit)) { _, _ ->
           exitGame()
       }
       .setPositiveButton(getString(R.string.play_again)) { _, _ ->
           restartGame()
       }
       .show()
}

9. 为“Submit”按钮实现 OnClickListener

在此任务中,您将使用 ViewModel 和您添加的提醒对话框,实现 Submit 按钮点击监听器的游戏逻辑。

显示乱序词

  1. GameFragment 中,删除点按 Submit 按钮时调用的 onSubmitWord() 内的代码(如果尚未删除)。
  2. 添加对 viewModel.nextWord() 方法返回值的检查。如为 true,表示还有其他单词可用,所以使用 updateNextWordOnScreen() 更新屏幕上的乱序词。否则,表示游戏已结束,所以显示包含最终得分的提醒对话框。
private fun onSubmitWord() {
    if (viewModel.nextWord()) {
        updateNextWordOnScreen()
    } else {
        showFinalScoreDialog()
    }
}
  1. 运行应用!玩一下游戏,猜几个单词。请注意,您尚未实现 Skip 按钮,因此无法跳过单词。
  2. 请注意,文本字段不会更新,因此玩家必须手动删除上一个单词。提醒对话框中的最终得分始终为零。您将在后续步骤中修复这些错误。

a4c660e212ce2c31.png 12a42987a0edd2c4.png

添加辅助方法以确认玩家猜出的单词是否正确

  1. GameViewModel 中添加一个名为 increaseScore() 的新私有方法,该方法不带参数也不返回任何值。将 score 变量增加 SCORE_INCREASE
private fun increaseScore() {
   _score += SCORE_INCREASE
}
  1. GameViewModel 中,添加一个名为 isUserWordCorrect() 的辅助方法,该方法返回 Boolean 并接受 String(玩家猜出的单词)作为参数。
  2. isUserWordCorrect() 中,确认玩家猜出的单词是否正确,如果正确就增加得分。这将更新提醒对话框中的最终得分。
fun isUserWordCorrect(playerWord: String): Boolean {
   if (playerWord.equals(currentWord, true)) {
       increaseScore()
       return true
   }
   return false
}

更新文本字段

在文本字段中显示错误

对于 Material 文本字段,TextInputLayout 具有一项显示错误消息的内置功能。例如,在以下文本字段中,标签的颜色发生了变化,显示了错误图标并显示了错误消息,等等。

520cc685ae1317ac.png

如需在文本字段中显示错误,可在代码中动态设置错误消息,也可在布局文件中静态设置错误消息。下面显示的是在代码中设置和重置错误的示例:

// Set error text
passwordLayout.error = getString(R.string.error)

// Clear error text
passwordLayout.error = null

您会发现,起始代码中已定义了辅助方法 setErrorTextField(error: Boolean) 来帮助您设置和重置文本字段中显示的错误。根据您是否要在文本字段中显示错误,以 truefalse 作为输入参数调用此方法。

起始代码中的代码段

private fun setErrorTextField(error: Boolean) {
   if (error) {
       binding.textField.isErrorEnabled = true
       binding.textField.error = getString(R.string.try_again)
   } else {
       binding.textField.isErrorEnabled = false
       binding.textInputEditText.text = null
   }
}

在此任务中,您将实现 onSubmitWord() 方法。单词提交后,对照原单词进行检查,确认用户猜出的单词是否正确。如果单词正确,就转到下一个单词(如果游戏结束,就显示对话框)。如果单词不正确,就在文本字段中显示错误,同时继续显示当前单词。

  1. GameFragment, 中的 onSubmitWord() 开头,创建一个名为 playerWordval。通过在 binding 变量中从文本字段提取玩家猜出的单词,将此单词存储在该值中。
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()
    ...
}
  1. onSubmitWord() 中的 playerWord 声明的下方,确认玩家猜出的单词是否正确。添加 if 语句,使用 isUserWordCorrect() 方法并传入 playerWord 检查玩家猜出的单词。
  2. if 块内,重置文本字段,调用 setErrorTextField 并传入 false
  3. 将现有代码移至 if 块内。
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()

    if (viewModel.isUserWordCorrect(playerWord)) {
        setErrorTextField(false)
        if (viewModel.nextWord()) {
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    }
}
  1. 如果用户猜出的单词不正确,就在文本字段中显示错误消息。将 else 块添加到上面的 if 块,然后调用 setErrorTextField() 并传入 true。完成后的 onSubmitWord() 方法应如下所示:
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()

    if (viewModel.isUserWordCorrect(playerWord)) {
        setErrorTextField(false)
        if (viewModel.nextWord()) {
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    } else {
        setErrorTextField(true)
    }
}
  1. 运行应用。玩一下游戏,猜几个单词。如果玩家猜出的单词正确,那么点击 Submit 按钮时单词会被清除,否则会显示“Try again!”消息。请注意,Skip 按钮仍然不起作用。您将在下一个任务中添加此实现。

a10c7d77aa26b9db.png

10. 实现“Skip”按钮

在此任务中,您将添加 onSkipWord() 的实现,用于处理点击 Skip 按钮的情况。

  1. onSubmitWord() 类似,在 onSkipWord() 方法中添加一个条件。如为 true,就在屏幕上显示单词并重置文本字段。如为 false,并且本局游戏中再没有剩下的单词,就显示包含最终得分的提醒对话框。
/*
* Skips the current word without changing the score.
*/
private fun onSkipWord() {
    if (viewModel.nextWord()) {
        setErrorTextField(false)
        updateNextWordOnScreen()
    } else {
        showFinalScoreDialog()
    }
}
  1. 运行应用。玩游戏。请注意,SkipSubmit 按钮运行正常。非常好!

11. 验证 ViewModel 能否保留数据

在此任务中,请在 GameFragment 中添加日志记录,以便监测配置更改期间应用数据是否在 ViewModel 中得以保留。为了访问 GameFragment 中的 currentWordCount,您需要使用后备属性公开只读版本。

  1. GameViewModel 中,右键点击变量 currentWordCount,然后依次选择 Refactor > Rename...。为新名称添加下划线前缀 (_currentWordCount)。
  2. 添加后备字段。
private var _currentWordCount = 0
val currentWordCount: Int
   get() = _currentWordCount
  1. GameFragment 中的 onCreateView() 内,在 return 语句上方添加另一个日志,用于输出应用数据,即单词、得分和单词数。
Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
       "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
  1. 在 Android Studio 中,打开 Logcat,按 GameFragment 进行过滤。运行应用并玩一下游戏,猜几个单词。更改设备的屏幕方向。fragment(界面控制器)会被销毁并重新创建。查看日志。现在,您可以看到得分和单词数增加!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
com.example.android.unscramble D/GameFragment: Word: oimfnru Score: 0 WordCount: 1
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: ofx Score: 80 WordCount: 5
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: ofx Score: 80 WordCount: 5
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: nvoiil Score: 160 WordCount: 9
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: nvoiil Score: 160 WordCount: 9

请注意,屏幕方向更改期间,应用数据在 ViewModel 中得以保留。在后续 Codelab 中,您将使用 LiveData 和数据绑定更新界面中的得分值和单词数。

12. 更新游戏重新开始逻辑

  1. 再次运行应用并玩游戏,这次猜完所有单词。在 Congratulations! 提醒对话框中,点击 PLAY AGAIN。应用不会让您再玩一次,因为单词数现在已达到 MAX_NO_OF_WORDS 的值。您需要将单词数重置为 0,才能从头再玩一次游戏。
  2. 为了重置应用数据,请在 GameViewModel 中添加一个名为 reinitializeData() 的方法。将得分和单词数设为 0。清空单词列表并调用 getNextWord() 方法。
/*
* Re-initializes the game data to restart the game.
*/
fun reinitializeData() {
   _score = 0
   _currentWordCount = 0
   wordsList.clear()
   getNextWord()
}
  1. GameFragment 中的 restartGame() 方法顶部,调用新创建的 reinitializeData() 方法。
private fun restartGame() {
   viewModel.reinitializeData()
   setErrorTextField(false)
   updateNextWordOnScreen()
}
  1. 再次运行应用。玩游戏。到达恭喜对话框时,点击 Play Again。现在,您应该能够成功地再玩一次游戏!

最终应用应表现出如下行为:游戏随机显示十个乱序词供玩家理顺。您可以点按 Skip 跳过单词,也可以猜出单词后点按 Submit。如果您猜对了,得分就会增加。如果猜错了,文本字段中就会显示一个错误状态。每猜一个新单词,单词数也会相应增加。

请注意,屏幕上显示的得分和单词数尚不会更新。不过,相应信息仍会存储在视图模型中,并在设备旋转等配置更改期间得以保留。在后续 Codelab 中,您将更新屏幕上的得分和单词数。

f332979d6f63d0e5.png 2803d4855f5d401f.png

猜完 10 个单词后,游戏结束,系统会弹出一个提醒对话框,其中会显示您的最终得分以及用于选择退出游戏还是再玩一次的选项。

d8e0111f5f160ead.png

祝贺您!您已创建了您的第一个 ViewModel 并保存了数据!

13. 解决方案代码

GameFragment.kt

import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.example.android.unscramble.R
import com.example.android.unscramble.databinding.GameFragmentBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder

/**
 * Fragment where the game is played, contains the game logic.
 */
class GameFragment : Fragment() {

    private val viewModel: GameViewModel by viewModels()

    // Binding object instance with access to the views in the game_fragment.xml layout
    private lateinit var binding: GameFragmentBinding

    // Create a ViewModel the first time the fragment is created.
    // If the fragment is re-created, it receives the same GameViewModel instance created by the
    // first fragment

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Inflate the layout XML file and return a binding object instance
        binding = GameFragmentBinding.inflate(inflater, container, false)
        Log.d("GameFragment", "GameFragment created/re-created!")
        Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
                "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Setup a click listener for the Submit and Skip buttons.
        binding.submit.setOnClickListener { onSubmitWord() }
        binding.skip.setOnClickListener { onSkipWord() }
        // Update the UI
        updateNextWordOnScreen()
        binding.score.text = getString(R.string.score, 0)
        binding.wordCount.text = getString(
            R.string.word_count, 0, MAX_NO_OF_WORDS)
    }

    /*
    * Checks the user's word, and updates the score accordingly.
    * Displays the next scrambled word.
    * After the last word, the user is shown a Dialog with the final score.
    */
    private fun onSubmitWord() {
        val playerWord = binding.textInputEditText.text.toString()

        if (viewModel.isUserWordCorrect(playerWord)) {
            setErrorTextField(false)
            if (viewModel.nextWord()) {
                updateNextWordOnScreen()
            } else {
                showFinalScoreDialog()
            }
        } else {
            setErrorTextField(true)
        }
    }

    /*
    * Skips the current word without changing the score.
    */
    private fun onSkipWord() {
        if (viewModel.nextWord()) {
            setErrorTextField(false)
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    }

    /*
     * Gets a random word for the list of words and shuffles the letters in it.
     */
    private fun getNextScrambledWord(): String {
        val tempWord = allWordsList.random().toCharArray()
        tempWord.shuffle()
        return String(tempWord)
    }

    /*
    * Creates and shows an AlertDialog with the final score.
    */
    private fun showFinalScoreDialog() {
        MaterialAlertDialogBuilder(requireContext())
            .setTitle(getString(R.string.congratulations))
            .setMessage(getString(R.string.you_scored, viewModel.score))
            .setCancelable(false)
            .setNegativeButton(getString(R.string.exit)) { _, _ ->
                exitGame()
            }
            .setPositiveButton(getString(R.string.play_again)) { _, _ ->
                restartGame()
            }
            .show()
    }

    /*
     * Re-initializes the data in the ViewModel and updates the views with the new data, to
     * restart the game.
     */
    private fun restartGame() {
        viewModel.reinitializeData()
        setErrorTextField(false)
        updateNextWordOnScreen()
    }

    /*
     * Exits the game.
     */
    private fun exitGame() {
        activity?.finish()
    }

    override fun onDetach() {
        super.onDetach()
        Log.d("GameFragment", "GameFragment destroyed!")
    }

    /*
    * Sets and resets the text field error status.
    */
    private fun setErrorTextField(error: Boolean) {
        if (error) {
            binding.textField.isErrorEnabled = true
            binding.textField.error = getString(R.string.try_again)
        } else {
            binding.textField.isErrorEnabled = false
            binding.textInputEditText.text = null
        }
    }

    /*
     * Displays the next scrambled word on screen.
     */
    private fun updateNextWordOnScreen() {
        binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
    }
}

GameViewModel.kt

import android.util.Log
import androidx.lifecycle.ViewModel

/**
 * ViewModel containing the app data and methods to process the data
 */
class GameViewModel : ViewModel(){
    private var _score = 0
    val score: Int
        get() = _score

    private var _currentWordCount = 0
    val currentWordCount: Int
        get() = _currentWordCount

    private lateinit var _currentScrambledWord: String
    val currentScrambledWord: String
        get() = _currentScrambledWord

    // List of words used in the game
    private var wordsList: MutableList<String> = mutableListOf()
    private lateinit var currentWord: String

    init {
        Log.d("GameFragment", "GameViewModel created!")
        getNextWord()
    }

    override fun onCleared() {
        super.onCleared()
        Log.d("GameFragment", "GameViewModel destroyed!")
    }

    /*
    * Updates currentWord and currentScrambledWord with the next word.
    */
    private fun getNextWord() {
        currentWord = allWordsList.random()
        val tempWord = currentWord.toCharArray()
        tempWord.shuffle()

        while (String(tempWord).equals(currentWord, false)) {
            tempWord.shuffle()
        }
        if (wordsList.contains(currentWord)) {
            getNextWord()
        } else {
            _currentScrambledWord = String(tempWord)
            ++_currentWordCount
            wordsList.add(currentWord)
        }
    }

    /*
    * Re-initializes the game data to restart the game.
    */
    fun reinitializeData() {
       _score = 0
       _currentWordCount = 0
       wordsList.clear()
       getNextWord()
    }

    /*
    * Increases the game score if the player's word is correct.
    */
    private fun increaseScore() {
        _score += SCORE_INCREASE
    }

    /*
    * Returns true if the player word is correct.
    * Increases the score accordingly.
    */
    fun isUserWordCorrect(playerWord: String): Boolean {
        if (playerWord.equals(currentWord, true)) {
            increaseScore()
            return true
        }
        return false
    }

    /*
    * Returns true if the current word count is less than MAX_NO_OF_WORDS
    */
    fun nextWord(): Boolean {
        return if (_currentWordCount < MAX_NO_OF_WORDS) {
            getNextWord()
            true
        } else false
    }
}

14. 总结

  • Android 应用架构准则建议将具有不同责任的类分离并通过模型驱动界面。
  • 界面控制器是基于界面的类,例如 ActivityFragment。界面控制器应仅包含处理界面和操作系统交互的逻辑;它们不应该是界面中要显示的数据的来源。请将那些数据以及任何相关逻辑都放入 ViewModel 中。
  • ViewModel 类负责存储和管理与界面相关的数据。ViewModel 类让数据可在发生屏幕旋转等配置更改后继续留存。
  • ViewModel 是推荐使用的 Android 架构组件之一。

15. 了解详情