1. 开始之前
Android 架构组件 集合提供了关于应用架构的指导,以及用于生命周期管理和数据持久化等常见任务的库。使用架构组件可以帮助您以一种健壮、可测试和可维护的方式构建应用,同时减少样板代码。
架构组件库是 Android Jetpack 的一部分。
这是该 Codelab 的 Kotlin 版本。Java 编程语言版本可以 在这里找到。
如果您在完成此 Codelab 时遇到任何问题,例如代码错误、语法错误或内容混乱,请通过 Codelab 左下角的“报告错误”链接报告问题。
先决条件
您需要熟悉 Kotlin、面向对象设计概念和 Android 开发基础知识,尤其是
RecyclerView
和适配器- SQLite 数据库和 SQLite 查询语言
- 基本的协程(如果您不熟悉协程,可以从 在您的 Android 应用中使用 Kotlin 协程 开始)。
熟悉将数据与用户界面分离的软件架构模式,例如模型-视图-呈现器 (MVP) 或模型-视图-控制器 (MVC),也很有帮助。此 Codelab 实现了 应用架构指南 Android 开发者文档中定义的架构。
此 Codelab 重点介绍 Android 架构组件。将为您提供非主题概念和代码,您只需复制粘贴即可。
您将做什么
您将学习如何使用架构组件 Room、ViewModel 和 LiveData 设计和构建应用。您的应用将
- 使用 Android 架构组件实现 推荐的架构。
- 使用数据库获取和保存数据,并使用示例单词预填充数据库。
- 在
MainActivity
类中的RecyclerView
中显示所有单词。 - 当用户点击“+”按钮时,打开第二个 Activity。当用户输入单词时,该单词将添加到数据库并在
RecyclerView
列表中显示。
该应用没有花哨的功能,但足够复杂,您可以将其用作构建的基础模板。以下是预览
您需要什么
- Android Studio Arctic Fox 以及如何使用它的知识。确保 Android Studio 已更新,以及您的 SDK 和 Gradle。
- Android 设备或模拟器。
此 Codelab 提供了构建完整应用所需的所有代码。
2. 使用架构组件
以下是一个简短的图表,用于向您介绍架构组件以及它们如何协同工作。请注意,此 Codelab 侧重于组件的一个子集,即 LiveData、ViewModel 和 Room。在您在应用中使用每个组件时,都会对其进行详细说明。
LiveData:一个可以被 观察 的数据持有者类。始终保存/缓存最新版本的数据,并在数据发生更改时通知其观察者。LiveData
是生命周期感知的。UI 组件只需观察相关数据,无需停止或恢复观察。LiveData 自动管理所有这些操作,因为它在观察时会感知相关的生命周期状态更改。
ViewModel:充当存储库(数据)和 UI 之间的通信中心。UI 不再需要担心数据的来源。ViewModel 实例在 Activity/Fragment 重新创建后仍然存在。
存储库:您创建的一个主要用于管理多个数据源的类。
Room 数据库:简化数据库工作并充当底层 SQLite 数据库的访问点(隐藏 SQLiteOpenHelper)
。Room 数据库使用 DAO 向 SQLite 数据库发出查询。
SQLite 数据库:设备存储。Room 持久性库将为您创建和维护此数据库。
DAO:数据访问对象。SQL 查询到函数的映射。当您使用 DAO 时,您调用方法,Room 会处理其余部分。
RoomWordSample 架构概述
下图显示了应用的所有部分应该如何交互。每个矩形框(不是 SQLite 数据库)都表示您将创建的一个类。
3. 创建您的应用
- 打开 Android Studio 并点击启动新的 Android Studio 项目。
- 在“创建新项目”窗口中,选择空 Activity,然后点击下一步。
- 在下一个屏幕上,将应用命名为 RoomWordSample,然后点击完成。
4. 更新 Gradle 文件
接下来,您需要将组件库添加到您的 Gradle 文件中。
- 在 Android Studio 中,点击“项目”选项卡并展开“Gradle Scripts”文件夹。
打开 build.gradle
(模块:app)。
- 通过在
build.gradle
(模块:app)文件的顶部插件部分中添加它,应用kapt
注释处理器 Kotlin 插件。
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
}
- 您将使用的一些 API 需要 1.8
jvmTarget
,因此也将其添加到android
块中。
android {
// other configuration (buildTypes, defaultConfig, etc.)
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
kotlinOptions {
jvmTarget = "1.8"
}
}
- 将
dependencies
块替换为
dependencies {
implementation "androidx.appcompat:appcompat:$rootProject.appCompatVersion"
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
// Dependencies for working with Architecture components
// You'll probably have to update the version numbers in build.gradle (Project)
// Room components
implementation "androidx.room:room-ktx:$rootProject.roomVersion"
kapt "androidx.room:room-compiler:$rootProject.roomVersion"
androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"
// Lifecycle components
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$rootProject.lifecycleVersion"
// Kotlin components
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"
// UI
implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
implementation "com.google.android.material:material:$rootProject.materialVersion"
// Testing
testImplementation "junit:junit:$rootProject.junitVersion"
androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
androidTestImplementation ("androidx.test.espresso:espresso-core:$rootProject.espressoVersion", {
exclude group: 'com.android.support', module: 'support-annotations'
})
androidTestImplementation "androidx.test.ext:junit:$rootProject.androidxJunitVersion"
}
此时,Gradle 可能会抱怨缺少或未定义的版本。它们将在下一步中修复。
- 在您的
build.gradle
(项目:RoomWordsSample)文件中,进行以下更改:在 buildscript 块的顶部添加 kotlin_version,然后根据下面的代码示例,将版本号添加到文件的末尾。
buildscript {
ext.kotlin_version = '1.5.31'
...
}
ext {
activityVersion = '1.4.0'
appCompatVersion = '1.4.0'
constraintLayoutVersion = '2.1.2'
coreTestingVersion = '2.1.0'
coroutines = '1.5.2'
lifecycleVersion = '2.4.0'
materialVersion = '1.4.0'
roomVersion = '2.3.0'
// testing
junitVersion = '4.13.2'
espressoVersion = '3.4.0'
androidxJunitVersion = '1.1.3'
}
5. 创建实体
此应用的数据是单词,您需要一个简单的表来保存这些值
Room 允许您通过 实体 创建表。现在让我们来做这件事。
- 创建一个名为
Word
的新 Kotlin 类文件,其中包含Word
数据类。此类将描述单词的实体(表示 SQLite 表)。类中的每个属性都表示表中的一列。Room 最终将使用这些属性来创建表和从数据库中的行实例化对象。
以下是代码
data class Word(val word: String)
为了使 Word
类对 Room 数据库有意义,您需要使用 Kotlin 注释 在类和数据库之间创建关联。您将使用特定的注释来识别此类的每个部分如何与数据库中的条目相关联。Room 使用此额外信息生成代码。
如果您自己键入注释(而不是粘贴),Android Studio 将自动导入注释类。
- 使用以下代码所示的注释更新您的
Word
类
@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)
让我们看看这些注释的作用
@Entity(tableName =
"word_table"
)
每个@Entity
类都表示一个 SQLite 表。注释您的类声明以指示它是一个实体。如果您希望表名与类名不同,可以指定表名。这将表命名为“word_table”。@PrimaryKey
每个实体都需要一个主键。为了简单起见,每个单词都充当它自己的主键。@ColumnInfo(name =
"word"
)
如果您希望列名与成员变量名不同,则指定表中列的名称。这将列命名为“word”。- 存储在数据库中的每个属性都需要具有公共可见性,这是 Kotlin 的默认设置。
您可以在 Room 包摘要参考 中找到注释的完整列表。
提示:您可以通过以下方式注释主键来自动生成唯一键
@Entity(tableName = "word_table")
class Word(
@PrimaryKey(autoGenerate = true) val id: Int,
@ColumnInfo(name = "word") val word: String
)
6. 创建 DAO
什么是 DAO?
在DAO(数据访问对象)中,您指定 SQL 查询并将它们与方法调用相关联。编译器检查 SQL 并从常用查询的便捷注释(例如@Insert
)生成查询。Room 使用 DAO 为您的代码创建简洁的 API。
DAO 必须是接口或抽象类。
默认情况下,所有查询都必须在单独的线程上执行。
Room 支持Kotlin 协程。这允许您的查询使用suspend
修饰符进行注释,然后从协程或其他挂起函数中调用。
实现 DAO
让我们编写一个 DAO,为以下操作提供查询
- 按字母顺序获取所有单词
- 插入一个单词
- 删除所有单词
- 创建一个名为
WordDao
的新 Kotlin 类文件。 - 将以下代码复制并粘贴到
WordDao
中,并根据需要修复导入以使其编译。
@Dao
interface WordDao {
@Query("SELECT * FROM word_table ORDER BY word ASC")
fun getAlphabetizedWords(): List<Word>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(word: Word)
@Query("DELETE FROM word_table")
suspend fun deleteAll()
}
让我们逐步了解它
WordDao
是一个接口;DAO 必须是接口或抽象类。@Dao
注释将其标识为 Room 的 DAO 类。suspend fun insert(word: Word)
:声明一个挂起函数来插入一个单词。@Insert
注释是一个特殊的 DAO 方法注释,您无需提供任何 SQL!(还有@Delete
和@Update
注释用于删除和更新行,但您在此应用程序中未使用它们。)onConflict = OnConflictStrategy.IGNORE
:所选的 onConflict 策略会忽略与列表中已有的单词完全相同的新的单词。要了解有关可用冲突策略的更多信息,请查看文档。suspend fun deleteAll()
:声明一个挂起函数来删除所有单词。- 没有删除多个实体的便捷注释,因此它使用通用
@Query
进行注释。 @Query
("DELETE FROM word_table")
:@Query
要求您将 SQL 查询作为字符串参数提供给注释,从而允许进行复杂的读取查询和其他操作。fun getAlphabetizedWords(): List<Word>
:一个获取所有单词并将其返回为Word
的List
的方法。@Query(
"SELECT * FROM word_table ORDER BY word ASC"
)
:返回按升序排序的单词列表的查询。
7. 观察数据库更改
当数据更改时,您通常希望采取一些操作,例如在 UI 中显示更新后的数据。这意味着您必须观察数据,以便在数据更改时做出反应。
要观察数据更改,您将使用来自kotlinx-coroutines
的Flow。在您的方法描述中使用类型为Flow
的返回值,Room 会生成所有必要的代码以在更新数据库时更新Flow
。
在WordDao
中,更改getAlphabetizedWords()
方法签名,以便将返回的List<Word>
用Flow
包装。
@Query("SELECT * FROM word_table ORDER BY word ASC")
fun getAlphabetizedWords(): Flow<List<Word>>
稍后在这个 codelab 中,我们将把 Flow 转换为 ViewModel 中的 LiveData。但是,一旦我们开始实现这些组件,我们将详细介绍这些组件。
8. 添加 Room 数据库
什么是 Room 数据库**?**
- Room 是 SQLite 数据库之上的数据库层。
- Room 处理您过去使用
SQLiteOpenHelper
处理的日常任务。 - Room 使用 DAO 向其数据库发出查询。
- 默认情况下,为了避免 UI 性能低下,Room 不允许您在主线程上发出查询。当 Room 查询返回
Flow
时,查询会自动异步地在后台线程上运行。 - Room 提供 SQLite 语句的编译时检查。
实现 Room 数据库
您的 Room 数据库类必须是抽象的,并且扩展RoomDatabase
。通常,您只需要为整个应用程序创建一个 Room 数据库实例。
让我们现在创建一个。
- 创建一个名为
WordRoomDatabase
的 Kotlin 类文件,并将此代码添加到其中
// Annotates class to be a Room Database with a table (entity) of the Word class
@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
public abstract class WordRoomDatabase : RoomDatabase() {
abstract fun wordDao(): WordDao
companion object {
// Singleton prevents multiple instances of database opening at the
// same time.
@Volatile
private var INSTANCE: WordRoomDatabase? = null
fun getDatabase(context: Context): WordRoomDatabase {
// if the INSTANCE is not null, then return it,
// if it is, then create the database
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
WordRoomDatabase::class.java,
"word_database"
).build()
INSTANCE = instance
// return instance
instance
}
}
}
}
让我们逐步了解代码
- Room 的数据库类必须是
abstract
并扩展RoomDatabase。
- 您使用
@Database
注释该类以将其作为 Room 数据库,并使用注释参数声明属于数据库的实体并设置版本号。每个实体对应于将在数据库中创建的一个表。数据库迁移超出了本 codelab 的范围,因此此处exportSchema
已设置为 false,以避免构建警告。在实际应用中,请考虑为 Room 设置一个目录以用于导出模式,以便您可以将当前模式检入您的版本控制系统。 - 数据库通过每个 @Dao 的抽象“getter”方法公开 DAO。
- 您定义了一个单例,
WordRoomDatabase,
以防止同时打开数据库的多个实例。 getDatabase
返回单例。它将在第一次访问时创建数据库,使用 Room 的数据库构建器在应用程序上下文中从WordRoomDatabase
类创建一个RoomDatabase
对象,并将其命名为"word_database"
。
9. 创建存储库
什么是存储库?
存储库类抽象了对多个数据源的访问。存储库不是架构组件库的一部分,而是代码分离和架构的建议最佳实践。存储库类为应用程序的其余部分提供了一个简洁的数据访问 API。
为什么要使用存储库?
存储库管理查询并允许您使用多个后端。在最常见的示例中,存储库实现用于决定是从网络获取数据还是使用本地数据库中缓存的结果的逻辑。
实现存储库
创建一个名为WordRepository
的 Kotlin 类文件,并将以下代码粘贴到其中
// Declares the DAO as a private property in the constructor. Pass in the DAO
// instead of the whole database, because you only need access to the DAO
class WordRepository(private val wordDao: WordDao) {
// Room executes all queries on a separate thread.
// Observed Flow will notify the observer when the data has changed.
val allWords: Flow<List<Word>> = wordDao.getAlphabetizedWords()
// By default Room runs suspend queries off the main thread, therefore, we don't need to
// implement anything else to ensure we're not doing long running database work
// off the main thread.
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun insert(word: Word) {
wordDao.insert(word)
}
}
主要要点
- DAO 传递到存储库构造函数中,而不是整个数据库。这是因为它只需要访问 DAO,因为 DAO 包含数据库的所有读/写方法。无需将整个数据库公开给存储库。
- 单词列表是一个公共属性。它通过从 Room 获取单词的
Flow
列表进行初始化;您可以这样做,因为您在“观察数据库更改”步骤中将getAlphabetizedWords
方法定义为返回Flow
。Room 在单独的线程上执行所有查询。 suspend
修饰符告诉编译器这需要从协程或其他挂起函数中调用。- Room 在主线程之外执行挂起查询。
10. 创建 ViewModel
什么是 ViewModel?
ViewModel
的作用是向 UI 提供数据并在配置更改中存活。 ViewModel
充当存储库和 UI 之间的通信中心。您还可以使用ViewModel
在片段之间共享数据。ViewModel
是生命周期库的一部分。
有关此主题的入门指南,请参阅ViewModel 概述
或ViewModel:一个简单的示例博客文章。
为什么要使用 ViewModel?
ViewModel
以生命周期感知的方式保存应用程序的 UI 数据,并在配置更改中存活。将应用程序的 UI 数据与您的Activity
和Fragment
类分离,可以让您更好地遵循单一职责原则:您的活动和片段负责将数据绘制到屏幕上,而您的ViewModel
可以负责保存和处理 UI 所需的所有数据。
LiveData 和 ViewModel
LiveData是一个可观察的数据持有者 - 您可以每次数据更改时收到通知。与 Flow 不同,LiveData 具有生命周期感知能力,这意味着它将尊重其他组件(如 Activity 或 Fragment)的生命周期。LiveData 会根据侦听更改的组件的生命周期自动停止或恢复观察。这使得 LiveData 成为用于 UI 将使用或显示的可更改数据的完美组件。
ViewModel 将转换来自存储库的数据,从 Flow 到 LiveData,并将单词列表作为 LiveData 公开给 UI。这确保了每次数据库中的数据更改时,您的 UI 都会自动更新。
viewModelScope
在 Kotlin 中,所有协程都在CoroutineScope
内运行。作用域通过其作业控制协程的生命周期。当您取消作用域的作业时,它会取消在该作用域中启动的所有协程。
AndroidX 的 lifecycle-viewmodel-ktx
库添加了一个 viewModelScope
作为 ViewModel
类的扩展函数,使您可以使用作用域。
要了解有关在 ViewModel 中使用协程的更多信息,请查看 在 Android 应用中使用 Kotlin 协程 codelab 的步骤 5 或 Android 中轻松使用协程:viewModelScope 博客文章。
实现 ViewModel
为 WordViewModel
创建一个 Kotlin 类文件,并将此代码添加到其中
class WordViewModel(private val repository: WordRepository) : ViewModel() {
// Using LiveData and caching what allWords returns has several benefits:
// - We can put an observer on the data (instead of polling for changes) and only update the
// the UI when the data actually changes.
// - Repository is completely separated from the UI through the ViewModel.
val allWords: LiveData<List<Word>> = repository.allWords.asLiveData()
/**
* Launching a new coroutine to insert the data in a non-blocking way
*/
fun insert(word: Word) = viewModelScope.launch {
repository.insert(word)
}
}
class WordViewModelFactory(private val repository: WordRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(WordViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return WordViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
让我们分解一下这段代码。在这里,您有
- 创建了一个名为
WordViewModel
的类,它将WordRepository
作为参数并扩展了ViewModel
。Repository 是 ViewModel 唯一需要的依赖项。如果需要其他类,它们也会在构造函数中传递。 - 添加了一个公共的
LiveData
成员变量来缓存单词列表。 - 使用 Repository 中的
allWords
Flow 初始化LiveData
。然后通过调用asLiveData()
将 Flow 转换为 LiveData。 - 创建了一个包装器
insert()
方法,该方法调用 Repository 的insert()
方法。这样,insert()
的实现就会与 UI 隔离。我们正在启动一个新的协程并调用存储库的 insert,这是一个挂起函数。如前所述,ViewModel 具有基于其生命周期的协程作用域,称为viewModelScope
,您将在此处使用它。 - 创建了 ViewModel 并实现了一个
ViewModelProvider.Factory
,它将创建WordViewModel
所需的依赖项(WordRepository
)作为参数。
通过使用 viewModels
和 ViewModelProvider.Factory
,框架将负责 ViewModel 的生命周期。它将在配置更改甚至 Activity 重新创建时继续存在,并且您始终会获得 WordViewModel
类的正确实例。
11. 添加 XML 布局
接下来,您需要为列表和项目添加 XML 布局。
此 codelab 假设您熟悉在 XML 中创建布局,因此我们只为您提供代码。
通过将 AppTheme
父级设置为 Theme.MaterialComponents.Light.DarkActionBar
,使您的应用程序主题成为 Material 主题。在 values/styles.xml
中添加列表项的样式
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<!-- The default font for RecyclerView items is too small.
The margin is a simple delimiter between the words. -->
<style name="word_title">
<item name="android:layout_marginBottom">8dp</item>
<item name="android:paddingLeft">8dp</item>
<item name="android:background">@android:color/holo_orange_light</item>
<item name="android:textAppearance">@android:style/TextAppearance.Large</item>
</style>
</resources>
创建一个新的维度资源文件
- 在**项目**窗口中单击应用模块。
- 选择**文件 > 新建 > Android 资源文件**。
- 从可用限定符中选择**维度**。
- 将文件命名为:dimens
在 values/dimens.xml
中添加此维度资源
<dimen name="big_padding">16dp</dimen>
添加 layout/recyclerview_item.xml
布局
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView"
style="@style/word_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/holo_orange_light" />
</LinearLayout>
在 layout/activity_main.xml
中,将 TextView
替换为 RecyclerView
并添加一个浮动操作按钮 (FAB)。现在您的布局应如下所示
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="0dp"
android:layout_height="0dp"
tools:listitem="@layout/recyclerview_item"
android:padding="@dimen/big_padding"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/add_word"/>
</androidx.constraintlayout.widget.ConstraintLayout>
您的 FAB 的外观应与可用的操作相对应,因此您需要将图标替换为“+”符号。
首先,您需要添加一个新的矢量资源
- 选择**文件 > 新建 > 矢量资源**。
- 单击**剪贴画:**字段中的 Android 机器人图标。
- 搜索“添加”,然后选择“+”资源。单击**确定**。
- 在**资源工作室**窗口中,单击**下一步**。
- 确认图标路径为
main > drawable
,然后单击**完成**以添加资源。 - 仍在
layout/activity_main.xml
中,更新 FAB 以包含新的可绘制对象
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/add_word"
android:src="@drawable/ic_add_black_24dp"/>
12. 添加 RecyclerView
您将使用 RecyclerView
显示数据,这比将数据简单地放入 TextView
中要好一些。此 codelab 假设您知道如何使用 RecyclerView
、RecyclerView.ViewHolder
和 ListAdapter
。
您需要创建
- 扩展
ListAdapter
的WordListAdapter
类。 WordListAdapter
的嵌套DiffUtil.ItemCallback
类部分。- 将显示列表中每个单词的
ViewHolder
。
以下是代码
class WordListAdapter : ListAdapter<Word, WordViewHolder>(WordsComparator()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
return WordViewHolder.create(parent)
}
override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
val current = getItem(position)
holder.bind(current.word)
}
class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val wordItemView: TextView = itemView.findViewById(R.id.textView)
fun bind(text: String?) {
wordItemView.text = text
}
companion object {
fun create(parent: ViewGroup): WordViewHolder {
val view: View = LayoutInflater.from(parent.context)
.inflate(R.layout.recyclerview_item, parent, false)
return WordViewHolder(view)
}
}
}
class WordsComparator : DiffUtil.ItemCallback<Word>() {
override fun areItemsTheSame(oldItem: Word, newItem: Word): Boolean {
return oldItem === newItem
}
override fun areContentsTheSame(oldItem: Word, newItem: Word): Boolean {
return oldItem.word == newItem.word
}
}
}
这里您有
WordViewHolder
类,它允许我们将文本绑定到TextView
。该类公开了一个静态的create()
函数,该函数处理布局的填充。WordsComparator
定义了如何计算两个单词是否相同或内容是否相同。WordListAdapter
在onCreateViewHolder
中创建WordViewHolder
,并在onBindViewHolder
中绑定它。
在 MainActivity
的 onCreate()
方法中添加 RecyclerView
。
在 onCreate()
方法中,在 setContentView
之后
val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
val adapter = WordListAdapter()
recyclerView.adapter = adapter
recyclerView.layoutManager = LinearLayoutManager(this)
运行您的应用以确保一切正常。没有项目,因为您尚未连接数据。
13. 实例化存储库和数据库
您希望在应用中只拥有一个数据库实例和一个存储库实例。实现此目的的一种简单方法是将它们创建为 Application
类的成员。然后,每当需要它们时,它们将仅从 Application 中检索,而不是每次都构建。
创建一个名为WordsApplication
的新类,该类扩展了Application
。代码如下所示
class WordsApplication : Application() {
// Using by lazy so the database and the repository are only created when they're needed
// rather than when the application starts
val database by lazy { WordRoomDatabase.getDatabase(this) }
val repository by lazy { WordRepository(database.wordDao()) }
}
以下是您已完成的操作
- 创建了一个数据库实例。
- 基于数据库 DAO 创建了一个存储库实例。
- 由于这些对象应该只在第一次需要时创建,而不是在应用程序启动时创建,因此您正在使用 Kotlin 的属性委托:
by lazy
.
现在您已创建了 Application 类,请更新AndroidManifest
文件并将WordsApplication
设置为application
android:name
。
应用程序标签应如下所示
<application
android:name=".WordsApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
...
14.填充数据库
目前,数据库中没有数据。您将通过两种方式添加数据:在创建数据库时添加一些数据,并添加一个用于添加单词的Activity
。
为了在每次创建应用程序时删除所有内容并重新填充数据库,您将创建一个RoomDatabase.Callback
并覆盖onCreate()
。因为您不能在 UI 线程上执行 Room 数据库操作,所以onCreate()
在 IO 调度程序上启动了一个协程。
要启动协程,您需要一个CoroutineScope
。更新WordRoomDatabase
类的getDatabase
方法,以同时获取协程作用域作为参数
fun getDatabase(
context: Context,
scope: CoroutineScope
): WordRoomDatabase {
...
}
填充数据库与 UI 生命周期无关,因此您不应该使用像 viewModelScope 这样的 CoroutineScope。它与应用程序的生命周期有关。您将更新WordsApplication
以包含一个applicationScope
,然后将其传递给WordRoomDatabase.getDatabase
。
class WordsApplication : Application() {
// No need to cancel this scope as it'll be torn down with the process
val applicationScope = CoroutineScope(SupervisorJob())
// Using by lazy so the database and the repository are only created when they're needed
// rather than when the application starts
val database by lazy { WordRoomDatabase.getDatabase(this, applicationScope) }
val repository by lazy { WordRepository(database.wordDao()) }
}
在WordRoomDatabase
中,您将创建RoomDatabase.Callback()
的自定义实现,该实现还获取CoroutineScope
作为构造函数参数。然后,您覆盖onCreate
方法来填充数据库。
以下是在WordRoomDatabase
类中创建回调的代码
private class WordDatabaseCallback(
private val scope: CoroutineScope
) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
INSTANCE?.let { database ->
scope.launch {
populateDatabase(database.wordDao())
}
}
}
suspend fun populateDatabase(wordDao: WordDao) {
// Delete all content here.
wordDao.deleteAll()
// Add sample words.
var word = Word("Hello")
wordDao.insert(word)
word = Word("World!")
wordDao.insert(word)
// TODO: Add your own words!
}
}
最后,在Room.databaseBuilder()
上调用.build()
之前,将回调添加到数据库构建序列中
.addCallback(WordDatabaseCallback(scope))
最终代码应如下所示
@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
abstract class WordRoomDatabase : RoomDatabase() {
abstract fun wordDao(): WordDao
private class WordDatabaseCallback(
private val scope: CoroutineScope
) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
INSTANCE?.let { database ->
scope.launch {
var wordDao = database.wordDao()
// Delete all content here.
wordDao.deleteAll()
// Add sample words.
var word = Word("Hello")
wordDao.insert(word)
word = Word("World!")
wordDao.insert(word)
// TODO: Add your own words!
word = Word("TODO!")
wordDao.insert(word)
}
}
}
}
companion object {
@Volatile
private var INSTANCE: WordRoomDatabase? = null
fun getDatabase(
context: Context,
scope: CoroutineScope
): WordRoomDatabase {
// if the INSTANCE is not null, then return it,
// if it is, then create the database
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
WordRoomDatabase::class.java,
"word_database"
)
.addCallback(WordDatabaseCallback(scope))
.build()
INSTANCE = instance
// return instance
instance
}
}
}
}
15.添加 NewWordActivity
在values/strings.xml
中添加这些字符串资源
<string name="hint_word">Word...</string>
<string name="button_save">Save</string>
<string name="empty_not_saved">Word not saved because it is empty.</string>
<string name="add_word">Add word</string>
在value/colors.xml
中添加此颜色资源
<color name="buttonLabel">#FFFFFF</color>
在values/dimens.xml
中添加一个min_height
尺寸资源
<dimen name="min_height">48dp</dimen>
使用“空活动”模板创建一个新的空 Android Activity
- 选择**文件 > 新建 > 活动 > 空活动**。
- 为活动名称输入
NewWordActivity
。 - 验证新的活动是否已添加到 Android 清单中。
<activity android:name=".NewWordActivity"></activity>
使用以下代码更新布局文件夹中的activity_new_word.xml
文件
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:id="@+id/edit_word"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="@dimen/min_height"
android:fontFamily="sans-serif-light"
android:hint="@string/hint_word"
android:inputType="textAutoComplete"
android:layout_margin="@dimen/big_padding"
android:textSize="18sp" />
<Button
android:id="@+id/button_save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/colorPrimary"
android:text="@string/button_save"
android:layout_margin="@dimen/big_padding"
android:textColor="@color/buttonLabel" />
</LinearLayout>
更新活动的代码
class NewWordActivity : AppCompatActivity() {
private lateinit var editWordView: EditText
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_new_word)
editWordView = findViewById(R.id.edit_word)
val button = findViewById<Button>(R.id.button_save)
button.setOnClickListener {
val replyIntent = Intent()
if (TextUtils.isEmpty(editWordView.text)) {
setResult(Activity.RESULT_CANCELED, replyIntent)
} else {
val word = editWordView.text.toString()
replyIntent.putExtra(EXTRA_REPLY, word)
setResult(Activity.RESULT_OK, replyIntent)
}
finish()
}
}
companion object {
const val EXTRA_REPLY = "com.example.android.wordlistsql.REPLY"
}
}
16.连接数据
最后一步是通过保存用户输入的新单词并在RecyclerView
中显示单词数据库的当前内容,将 UI 连接到数据库。
要显示数据库的当前内容,请添加一个观察者来观察ViewModel
中的LiveData
。
每当数据更改时,都会调用onChanged()
回调,该回调会调用适配器的setWords()
方法来更新适配器的缓存数据并刷新显示的列表。
在MainActivity
中,创建ViewModel
private val wordViewModel: WordViewModel by viewModels {
WordViewModelFactory((application as WordsApplication).repository)
}
要创建 ViewModel,您使用了viewModels
委托,并传入我们WordViewModelFactory
的一个实例。这是根据从WordsApplication
检索到的存储库构建的。
同样在onCreate()
中,为WordViewModel
中的所有单词LiveData
属性添加一个观察者。
当观察到的数据发生变化且活动处于前台时,onChanged()
方法(我们 Lambda 的默认方法)就会触发
wordViewModel.allWords.observe(this, Observer { words ->
// Update the cached copy of the words in the adapter.
words?.let { adapter.submitList(it) }
})
您希望在点击 FAB 时打开NewWordActivity
,并且在返回MainActivity
后,要么将新单词插入数据库,要么显示一个Toast
。
为此,请首先定义一个请求代码
private val newWordActivityRequestCode = 1
在MainActivity
中,为NewWordActivity
添加onActivityResult()
代码。
如果活动以RESULT_OK
返回,则通过调用WordViewModel
的insert()
方法将返回的单词插入数据库
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
val word = Word(it)
wordViewModel.insert(word)
}
} else {
Toast.makeText(
applicationContext,
R.string.empty_not_saved,
Toast.LENGTH_LONG).show()
}
}
在MainActivity
中,当用户点击 FAB 时启动NewWordActivity
。在MainActivity
onCreate
中,找到 FAB 并添加一个带有此代码的onClickListener
val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
val intent = Intent(this@MainActivity, NewWordActivity::class.java)
startActivityForResult(intent, newWordActivityRequestCode)
}
您的完成代码应如下所示
class MainActivity : AppCompatActivity() {
private val newWordActivityRequestCode = 1
private val wordViewModel: WordViewModel by viewModels {
WordViewModelFactory((application as WordsApplication).repository)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
val adapter = WordListAdapter()
recyclerView.adapter = adapter
recyclerView.layoutManager = LinearLayoutManager(this)
// Add an observer on the LiveData returned by getAlphabetizedWords.
// The onChanged() method fires when the observed data changes and the activity is
// in the foreground.
wordViewModel.allWords.observe(owner = this) { words ->
// Update the cached copy of the words in the adapter.
words.let { adapter.submitList(it) }
}
val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
val intent = Intent(this@MainActivity, NewWordActivity::class.java)
startActivityForResult(intent, newWordActivityRequestCode)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, intentData: Intent?) {
super.onActivityResult(requestCode, resultCode, intentData)
if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
intentData?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let { reply ->
val word = Word(reply)
wordViewModel.insert(word)
}
} else {
Toast.makeText(
applicationContext,
R.string.empty_not_saved,
Toast.LENGTH_LONG
).show()
}
}
}
现在运行您的应用程序!当您在NewWordActivity
中将单词添加到数据库时,UI 将自动更新。
17.总结
现在您已经拥有了一个可工作的应用程序,让我们回顾一下您构建的内容。以下是应用程序结构的再次说明
应用程序的组件包括
MainActivity
:使用RecyclerView
和WordListAdapter
在列表中显示单词。在MainActivity
中,有一个Observer
观察数据库中的单词,并在它们发生更改时收到通知。NewWordActivity:
将新单词添加到列表中。WordViewModel
:提供访问数据层的方法,并返回 LiveData,以便 MainActivity 可以设置观察者关系。*LiveData<List<Word>>
:使 UI 组件中的自动更新成为可能。您可以通过调用flow.toLiveData()
将Flow
转换为LiveData
。Repository:
管理一个或多个数据源。Repository
公开了 ViewModel 与底层数据提供程序交互的方法。在此应用程序中,后端是 Room 数据库。Room
:是 SQLite 数据库的包装器并实现它。Room 为您完成了许多您过去必须自己完成的工作。- DAO:将方法调用映射到数据库查询,以便当 Repository 调用
getAlphabetizedWords()
等方法时,Room 可以执行SELECT * FROM word_table ORDER BY word ASC
。 - DAO 可以为一次性请求公开
suspend
查询,并公开Flow
查询 - 当您希望收到数据库更改的通知时。 Word
:是包含单个单词的实体类。Views
和Activities
(以及Fragments
)仅通过ViewModel
与数据交互。因此,数据来自哪里并不重要。
自动 UI 更新的数据流(反应式 UI)
自动更新之所以成为可能,是因为您使用了 LiveData。在MainActivity
中,有一个Observer
观察数据库中的 words LiveData,并在它们发生更改时收到通知。发生更改时,观察者的onChange()
方法会被执行并更新WordListAdapter
中的mWords
。
可以观察数据是因为它是LiveData
。观察到的内容是WordViewModel
allWords
属性返回的LiveData<List<Word>>
。
WordViewModel
隐藏了 UI 层对后端的所有信息。它提供访问数据层的方法,并返回LiveData
,以便MainActivity
可以设置观察者关系。Views
和Activities
(以及Fragments
)仅通过ViewModel
与数据交互。因此,数据来自哪里并不重要。
在这种情况下,数据来自Repository
。ViewModel
不需要知道该 Repository 与什么交互。它只需要知道如何与Repository
交互,即通过Repository
公开的方法。
Repository
管理一个或多个数据源。在WordListSample
应用程序中,后端是 Room 数据库。Room 是 SQLite 数据库的包装器并实现它。Room 为您完成了许多您过去必须自己完成的工作。例如,Room 完成了您过去使用SQLiteOpenHelper
类完成的所有工作。
DAO 将方法调用映射到数据库查询,以便当 Repository 调用getAllWords()
等方法时,Room 可以执行SELECT * FROM word_table ORDER BY word ASC
.
因为从查询返回的结果是观察到的LiveData
,所以每次 Room 中的数据发生更改时,Observer
接口的onChanged()
方法都会被执行,并且 UI 会更新。
18.恭喜!
[可选]下载解决方案代码
如果您还没有,可以查看 codelab 的解决方案代码。您可以查看github 存储库或在此处下载代码
解压下载的 zip 文件。这将解压一个根文件夹,android-room-with-a-view-kotlin
,其中包含完整的应用程序。