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 中显示所有单词。
  • 当用户点击“+”按钮时,打开第二个 Activity。当用户输入单词时,该单词将添加到数据库并在 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. 在“创建新项目”窗口中,选择空 Activity,然后点击下一步
  3. 在下一个屏幕上,将应用命名为 RoomWordSample,然后点击完成

9b6cbaec81794071.png

4. 更新 Gradle 文件

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

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

打开 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:所选的 onConflict 策略会忽略与列表中已有的单词完全相同的新的单词。要了解有关可用冲突策略的更多信息,请查看文档
  • suspend fun deleteAll():声明一个挂起函数来删除所有单词。
  • 没有删除多个实体的便捷注释,因此它使用通用@Query进行注释。
  • @Query("DELETE FROM word_table")@Query要求您将 SQL 查询作为字符串参数提供给注释,从而允许进行复杂的读取查询和其他操作。
  • fun getAlphabetizedWords(): List<Word>:一个获取所有单词并将其返回为WordList的方法。
  • @Query("SELECT * FROM word_table ORDER BY word ASC"):返回按升序排序的单词列表的查询。

7. 观察数据库更改

当数据更改时,您通常希望采取一些操作,例如在 UI 中显示更新后的数据。这意味着您必须观察数据,以便在数据更改时做出反应。

要观察数据更改,您将使用来自kotlinx-coroutinesFlow。在您的方法描述中使用类型为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 数据库实例。

让我们现在创建一个。

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

9. 创建存储库

什么是存储库?

存储库类抽象了对多个数据源的访问。存储库不是架构组件库的一部分,而是代码分离和架构的建议最佳实践。存储库类为应用程序的其余部分提供了一个简洁的数据访问 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 概述ViewModel:一个简单的示例博客文章。

为什么要使用 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 协程 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)作为参数。

通过使用 viewModelsViewModelProvider.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>

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

  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 中,将 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 的外观应与可用的操作相对应,因此您需要将图标替换为“+”符号。

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

  1. 选择**文件 > 新建 > 矢量资源**。
  2. 单击**剪贴画:**字段中的 Android 机器人图标。de077eade4adf77.png
  3. 搜索“添加”,然后选择“+”资源。单击**确定**。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 中要好一些。此 codelab 假设您知道如何使用 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
        }
    }
}

这里您有

  • WordViewHolder 类,它允许我们将文本绑定到 TextView。该类公开了一个静态的 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. 实例化存储库和数据库

您希望在应用中只拥有一个数据库实例和一个存储库实例。实现此目的的一种简单方法是将它们创建为 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

  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中的所有单词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:管理一个或多个数据源。Repository公开了 ViewModel 与底层数据提供程序交互的方法。在此应用程序中,后端是 Room 数据库。
  • Room:是 SQLite 数据库的包装器并实现它。Room 为您完成了许多您过去必须自己完成的工作。
  • DAO:将方法调用映射到数据库查询,以便当 Repository 调用getAlphabetizedWords()等方法时,Room 可以执行SELECT * FROM word_table ORDER BY word ASC
  • DAO 可以为一次性请求公开suspend查询,并公开Flow查询 - 当您希望收到数据库更改的通知时。
  • Word:是包含单个单词的实体类。
  • ViewsActivities(以及Fragments)仅通过ViewModel与数据交互。因此,数据来自哪里并不重要。

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

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

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

WordViewModel隐藏了 UI 层对后端的所有信息。它提供访问数据层的方法,并返回LiveData,以便MainActivity可以设置观察者关系。ViewsActivities(以及Fragments)仅通过ViewModel与数据交互。因此,数据来自哪里并不重要。

在这种情况下,数据来自RepositoryViewModel不需要知道该 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,其中包含完整的应用程序。