带视图的 Android Room - Kotlin

1. 开始之前

Android 架构组件 集合提供有关应用程序架构的指导,并提供用于生命周期管理和数据持久性等常见任务的库。使用架构组件可以帮助您以稳固、可测试和可维护的方式构建应用程序,同时减少样板代码。

架构组件库是 Android Jetpack 的一部分。

这是该 codelab 的 Kotlin 版本。您可以在此处找到 Java 编程语言版本 这里

如果您在完成此 codelab 时遇到任何问题,例如代码错误、语法错误或内容难以理解,请通过 codelab 左下角的“报告错误”链接报告问题。

先决条件

您需要熟悉 Kotlin、面向对象设计概念和 Android 开发基础知识,尤其是

熟悉将数据与用户界面分开的软件架构模式(例如模型-视图-表示器 (MVP) 或模型-视图-控制器 (MVC))也很有帮助。此 codelab 实施了 应用程序架构指南 Android 开发者文档中定义的架构。

此 codelab 的重点是 Android 架构组件。非主题概念和代码仅供您复制和粘贴。

您将做什么

您将学习如何使用架构组件 Room、ViewModel 和 LiveData 设计和构建应用程序。您的应用程序将

  • 使用 Android 架构组件实现 推荐架构
  • 使用数据库获取和保存数据,并使用示例词预填充数据库。
  • MainActivity 类中的 RecyclerView 中显示所有单词。
  • 当用户点击 + 按钮时打开第二个活动。当用户输入单词时,该单词将添加到数据库并在 RecyclerView 列表中显示。

该应用程序没有花哨的功能,但它足够复杂,您可以将其用作构建其他应用程序的模板。这是一个预览

您需要什么

  • Android Studio Arctic Fox,以及如何使用它的知识。确保 Android Studio、SDK 和 Gradle 已更新。
  • Android 设备或模拟器。

此 codelab 提供了构建完整应用程序所需的所有代码。

2. 使用架构组件

这是一个简短的图表,介绍您使用架构组件以及它们如何协同工作。请注意,此 codelab 的重点是组件的子集,即 LiveData、ViewModel 和 Room。当您在应用程序中使用每个组件时,都会对其进行详细解释。

8e4b761713e3a76b.png

LiveData可以 观察 的数据持有者类。始终保存/缓存最新版本的数据,并在数据发生更改时通知其观察者。 LiveData 是生命周期感知的。UI 组件只需观察相关数据,而无需停止或恢复观察。由于 LiveData 了解相关生命周期状态更改,因此它会在观察时自动管理所有这些操作。

ViewModel充当存储库(数据)和 UI 之间的通信中心。UI 不再需要担心数据的来源。ViewModel 实例在 Activity/Fragment 重新创建后仍然存在。

存储库:您创建的用于管理多个数据源的类。

实体使用 Room 时描述数据库表的带注释的类。

Room 数据库简化数据库工作并充当底层 SQLite 数据库的访问点(隐藏 SQLiteOpenHelper)。Room 数据库使用 DAO 向 SQLite 数据库发出查询。

SQLite 数据库:设备存储。Room 持久性库将为您创建和维护此数据库。

DAO数据访问对象。将 SQL 查询映射到函数。使用 DAO 时,您调用方法,Room 会处理剩下的工作。

RoomWordSample 架构概述

下图显示了应用程序的所有部分应如何交互。每个矩形框(不是 SQLite 数据库)代表您将创建的类。

a70aca8d4b737712.png

3. 创建您的应用程序

  1. 打开 Android Studio 并点击“启动新 Android Studio 项目”。
  2. 在“创建新项目”窗口中,选择“空活动”,然后点击“下一步”。
  3. 在下一个屏幕上,将应用程序命名为 RoomWordSample,然后点击“完成”。

9b6cbaec81794071.png

4. 更新 Gradle 文件

接下来,您需要将组件库添加到您的 Gradle 文件中。

  1. 在 Android Studio 中,点击“项目”选项卡并展开“Gradle 脚本”文件夹。

打开 build.gradle模块:app)。

  1. 通过将其添加到 build.gradle模块:app)文件顶部的 插件部分 中来应用 kapt 注释处理器 Kotlin 插件。
plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
}
  1. 您将使用的一些 API 需要 1.8 jvmTarget,因此将其添加到 android 块中。
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }

}
  1. 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 可能会抱怨缺少或未定义的版本。这些问题将在下一步中解决。

  1. 在您的 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. 创建一个实体

此应用程序的数据是单词,您将需要一个简单的表格来保存这些值

3821ac1a6cb01278.png

Room 允许您通过 实体 创建表格。现在让我们开始吧。

  1. 创建一个名为 Word 的新 Kotlin 类文件,其中包含 Word 数据类。此类将描述您的单词的实体(表示 SQLite 表格)。类中的每个属性代表表格中的一列。Room 最终将使用这些属性来创建表格并从数据库中的行实例化对象。

以下是代码

data class Word(val word: String)

为了使 Word 类对 Room 数据库有意义,您需要使用 Kotlin 注释 在类和数据库之间创建关联。您将使用特定注释来标识此类中的每个部分与数据库中的条目之间的关联。Room 使用此额外信息来生成代码。

如果您自己输入注释(而不是粘贴),Android Studio 会自动导入注释类。

  1. 使用注释更新您的 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,它提供用于以下操作的查询

  • 按字母顺序获取所有单词
  • 插入一个单词
  • 删除所有单词
  1. 创建一个名为 WordDao 的新 Kotlin 类文件。
  2. 将以下代码复制粘贴到 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:所选的冲突策略会忽略与列表中已存在单词完全相同的新单词。要详细了解可用的冲突策略,请查看 文档
  • suspend fun deleteAll():声明一个挂起函数以删除所有单词。
  • 没有用于删除多个实体的便捷注解,因此它使用通用 @Query 进行注解。
  • @Query("DELETE FROM word_table")@Query 要求您向注解提供一个 SQL 查询字符串参数,从而允许进行复杂的读取查询和其他操作。
  • fun getAlphabetizedWords(): List<Word>:一种获取所有单词并使其返回一个 ListWords 的方法。
  • @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>>

稍后在本代码实验室中,我们将把 Flow 转换为 ViewModel 中的 LiveData。但是,在实现这些组件之前,我们将更多地了解这些组件。

8. 添加 Room 数据库

什么是 Room 数据库**?**

  • Room 是建立在 SQLite 数据库之上的数据库层。
  • Room 处理您过去使用 SQLiteOpenHelper 处理的平凡任务。
  • Room 使用 DAO 向其数据库发出查询。
  • 默认情况下,为了避免 UI 性能下降,Room 不允许您在主线程上发出查询。当 Room 查询返回 Flow 时,查询会自动在后台线程上异步运行。
  • Room 提供了 SQLite 语句的编译时检查。

实现 Room 数据库

您的 Room 数据库类必须是抽象类,并扩展 RoomDatabase。通常,您只需要一个 Room 数据库实例,用于整个应用程序。

现在让我们创建一个。

  1. 创建一个名为 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 数据库,并使用注解参数来声明属于数据库的实体并设置版本号。每个实体对应于将在数据库中创建的表。数据库迁移超出了本代码实验室的范围,因此 exportSchema 在此处设置为 false,以避免构建警告。在实际应用程序中,请考虑为 Room 设置一个目录以用于导出模式,以便您可以将当前模式检入您的版本控制系统。
  • 数据库通过每个 @Dao 的抽象“getter”方法公开 DAO。
  • 您定义了一个 单例WordRoomDatabase, 以防止同时打开多个数据库实例。
  • getDatabase 返回单例。它将在第一次访问数据库时创建数据库,使用 Room 的数据库构建器从应用程序上下文中创建 RoomDatabase 对象,并从 WordRoomDatabase 类中命名它为 "word_database"

9. 创建存储库

什么是存储库?

存储库类抽象了对多个数据源的访问。存储库不是 Architecture Components 库的一部分,但它是代码分离和架构的建议最佳实践。存储库类为应用程序的其余部分提供了用于数据访问的简洁 API。

cdfae5b9b10da57f.png

为什么要使用存储库?

存储库管理查询,并允许您使用多个后端。在最常见的示例中,存储库实现逻辑以决定是检索网络数据还是使用本地数据库中缓存的结果。

实现存储库

创建一个名为 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 是 生命周期库 的一部分。

72848dfccfe5777b.png

有关此主题的入门指南,请查看 ViewModel 概述ViewModels:一个简单的示例 博客文章。

为什么要使用 ViewModel?

ViewModel 以生命周期感知的方式保存应用程序的 UI 数据,该方式可以在配置更改中存活。将应用程序的 UI 数据与您的 ActivityFragment 类分离,可以让您更好地遵循单一职责原则:您的活动和片段负责将数据绘制到屏幕上,而您的 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 协程 代码实验室的步骤 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 分离。我们正在启动一个新的协程并调用 repository 的 insert,这是一个挂起函数。如前所述,ViewModels 有一个基于其生命周期的协程范围,称为 viewModelScope,您将在这里使用它。
  • 创建了 ViewModel 并实现了一个 ViewModelProvider.Factory,它以创建 WordViewModel 所需的依赖项作为参数:WordRepository

通过使用 viewModelsViewModelProvider.Factory,框架将负责 ViewModel 的生命周期。它将保留配置更改,即使 Activity 被重新创建,您也始终会获得 WordViewModel 类的正确实例。

11. 添加 XML 布局

接下来,您需要添加列表和项目的 XML 布局。

此代码实验室假定您熟悉在 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>

创建一个新的维度资源文件

  1. 项目窗口中单击应用程序模块。
  2. 选择文件 > 新建 > Android 资源文件。
  3. 从可用限定符中选择维度。
  4. 将文件命名为:dimens

aa5895240838057.png

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 中,用 RecyclerView 替换 TextView,并添加一个浮动操作按钮 (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 的外观应与可用操作相对应,因此您需要将图标替换为“+”符号。

首先,您需要添加一个新的矢量资产

  1. 选择文件 > 新建 > 矢量资产
  2. 单击剪贴画:字段中的 Android 机器人图标。 de077eade4adf77.png
  3. 搜索“add”并选择“+”资产。单击确定。 758befc99c8cc794.png
  4. 资产工作室窗口中,单击下一步de62e3548d443c7f.png
  5. 确认图标路径为 main > drawable,然后单击完成以添加资产。 2922fa2214257ec1.png
  6. 仍然在 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 中要好一些。此代码实验室假定您了解如何使用 RecyclerViewRecyclerView.ViewHolderListAdapter

您需要创建

  • 扩展 ListAdapterWordListAdapter 类。
  • 作为 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
        }
    }
}

这里您有

  • 允许我们将文本绑定到 TextViewWordViewHolder 类。该类公开了一个静态的 create() 函数,用于处理布局的膨胀。
  • WordsComparator 定义了如何计算两个单词是否相同,或者内容是否相同。
  • WordListAdapteronCreateViewHolder 中创建 WordViewHolder,并在 onBindViewHolder 中绑定它。

MainActivityonCreate() 方法中添加 RecyclerView

onCreate() 方法中,在 setContentView 之后

   val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
   val adapter = WordListAdapter()
   recyclerView.adapter = adapter
   recyclerView.layoutManager = LinearLayoutManager(this)

运行您的应用程序以确保一切正常。没有项目,因为您还没有连接数据。

79cb875d4296afce.png

13. 实例化 Repository 和数据库

您希望在应用程序中只有一个数据库实例和一个 Repository 实例。实现这一点的简单方法是在 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 创建了 Repository 实例。
  • 由于这些对象应该只在第一次需要时创建,而不是在应用程序启动时创建,因此您正在使用 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

  1. 选择文件 > 新建 > 活动 > 空活动
  2. 为活动名称输入 NewWordActivity
  3. 验证新的活动是否已添加到 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 的 allWords 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 返回,则通过调用 WordViewModelinsert() 方法将返回的单词插入数据库。

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. 总结

现在您已经拥有了一个可用的应用程序,让我们回顾一下您构建的内容。以下是应用程序结构

a70aca8d4b737712.png

应用程序的组件包括

  • MainActivity:使用 RecyclerViewWordListAdapter 在列表中显示单词。在 MainActivity 中,有一个 Observer 观察来自数据库的单词,并在单词发生更改时收到通知。
  • NewWordActivity: 将新单词添加到列表中。
  • WordViewModel:提供访问数据层的函数,并且它返回 LiveData,以便 MainActivity 可以设置观察器关系。*
  • LiveData<List<Word>>:使 UI 组件中的自动更新成为可能。您可以通过调用 flow.toLiveData(),将 Flow 转换为 LiveData
  • Repository: 管理一个或多个数据源。存储库为 ViewModel 公开了与底层数据提供者交互的函数。在这个应用程序中,后端是一个 Room 数据库。
  • Room:是 SQLite 数据库的包装器,并实现了 SQLite 数据库。Room 为您做了很多您过去必须自己做的事情。
  • DAO:将函数调用映射到数据库查询,以便当存储库调用一个函数(例如 getAlphabetizedWords())时,Room 可以执行 SELECT * FROM word_table ORDER BY word ASC
  • DAO 可以公开 suspend 查询用于一次性请求,以及 Flow 查询 - 当您希望收到数据库更改的通知时。
  • Word:是包含单个单词的实体类。
  • ViewsActivities(以及 Fragments)仅通过 ViewModel 与数据交互。因此,数据来自何处无关紧要。

用于自动 UI 更新(反应式 UI)的数据流

自动更新之所以可能,是因为您使用的是 LiveData。在 MainActivity 中,有一个 Observer 观察来自数据库的单词 LiveData,并在单词发生更改时收到通知。当发生更改时,观察器的 onChange() 方法会执行,并更新 WordListAdapter 中的 mWords

数据可以被观察,因为它是 LiveData。观察的是 WordViewModel allWords 属性返回的 LiveData<List<Word>>

WordViewModel 将后端的所有内容都隐藏在 UI 层中。它提供访问数据层的函数,并且它返回 LiveData,以便 MainActivity 可以设置观察器关系。 ViewsActivities(以及 Fragments)仅通过 ViewModel 与数据交互。因此,数据来自何处无关紧要。

在这种情况下,数据来自一个 RepositoryViewModel 不需要知道该存储库与什么进行交互。它只需要知道如何与 Repository 交互,即通过 Repository 公开的函数。

存储库管理一个或多个数据源。在 WordListSample 应用程序中,后端是一个 Room 数据库。Room 是 SQLite 数据库的包装器,并实现了 SQLite 数据库。Room 为您做了很多您过去必须自己做的事情。例如,Room 完成了您过去使用 SQLiteOpenHelper 类完成的所有操作。

DAO 将函数调用映射到数据库查询,以便当存储库调用一个函数(例如 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,其中包含完整的应用程序。