使用 Room 持久化数据

1. 开始之前

大多数生产级应用都需要持久化数据。例如,应用可能存储歌曲播放列表、待办事项列表、收入和支出记录、星座目录或个人数据历史记录。对于此类用例,您可以使用数据库来存储此持久数据。

Room 是 Android Jetpack 的一部分,是一个持久化库。Room 是 SQLite 数据库之上的抽象层。SQLite 使用专门的语言(SQL)执行数据库操作。Room 简化了数据库设置、配置和与应用交互的繁琐工作,因此您无需直接使用 SQLite。Room 还提供 SQLite 语句的编译时检查。

抽象层是一组隐藏底层实现/复杂性的函数。它为现有的功能集(在本例中为 SQLite)提供接口。

下图显示了 Room 作为数据源如何在课程中推荐的整体架构中进行融合。Room 是一个数据源。

data layer contains repositories and data sources

先决条件

  • 能够使用 Jetpack Compose 构建 Android 应用的基本用户界面 (UI)。
  • 能够使用诸如 TextIconIconButtonLazyColumn 的可组合项。
  • 能够使用 NavHost 可组合项在应用中定义路由和屏幕。
  • 能够使用 NavHostController 在屏幕之间导航。
  • 熟悉 Android 架构组件 ViewModel。能够使用 ViewModelProvider.Factory 实例化 ViewModel。
  • 熟悉并发基础知识。
  • 能够使用协程执行长时间运行的任务。
  • 具备 SQLite 数据库和 SQL 语言的基本知识。

您将学到什么

  • 如何使用 Room 库创建和与 SQLite 数据库交互。
  • 如何创建实体、数据访问对象 (DAO) 和数据库类。
  • 如何使用 DAO 将 Kotlin 函数映射到 SQL 查询。

您将构建什么

  • 您将构建一个将库存项目保存到 SQLite 数据库中的 **库存** 应用。

您需要什么

  • **库存** 应用的入门代码
  • 配备 Android Studio 的计算机
  • API 级别 26 或更高版本的设备或模拟器

2. 应用概述

在本 Codelab 中,您将使用库存应用的入门代码,并使用 Room 库向其添加数据库层。应用的最终版本将显示库存数据库中的项目列表。用户可以选择添加新项目、更新现有项目和从库存数据库中删除项目。在本 Codelab 中,您将项目数据保存到 Room 数据库。您将在下一个 Codelab 中完成应用其余功能的实现。

Phone screen with inventory items

Add item screen show in the phone screen.

Add Item screen with item details filled in.

3. 入门应用概述

下载本 Codelab 的入门代码

要开始,请下载入门代码

或者,您可以克隆代码的 GitHub 代码库

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-inventory-app.git
$ cd basic-android-kotlin-compose-training-inventory-app
$ git checkout starter

您可以在 库存应用 GitHub 代码库中浏览代码。

入门代码概述

  1. 在 Android Studio 中打开包含入门代码的项目。
  2. 在 Android 设备或模拟器上运行应用。确保模拟器或连接的设备运行 API 级别 26 或更高版本。数据库检查器 在运行 API 级别 26 或更高版本的模拟器/设备上有效。
  1. 注意应用没有显示任何库存数据。
  2. 点击浮动操作按钮 (FAB),它允许您向数据库添加新项目。

应用将导航到一个新屏幕,您可以在其中输入新项目的详细信息。

Phone screen empty inventory

Add item screen show in the phone screen.

入门代码的问题

  1. 在 **添加项目** 屏幕中,输入项目的详细信息,如项目名称、项目价格和可用项目的数量。
  2. 点击 **保存**。**添加项目** 屏幕不会关闭,但您可以使用后退键返回。保存功能未实现,因此项目详细信息不会保存。

注意应用不完整,**保存** 按钮功能未实现。

Add Item screen with item details filled in.

在本 Codelab 中,您将添加使用 Room 将库存详细信息保存到 SQLite 数据库的代码。您将使用 Room 持久化库与 SQLite 数据库交互。

代码演练

您下载的入门代码为您预先设计了屏幕布局。在本途径中,您将专注于实现数据库逻辑。以下部分是对某些文件的简要演练,以帮助您入门。

ui/home/HomeScreen.kt

此文件是主屏幕或应用中的第一个屏幕,其中包含用于显示库存列表的可组合项。它有一个 FAB + 用于向列表添加新项目。您将在途径的后面显示列表中的项目。

Phone screen with inventory items

ui/item/ItemEntryScreen.kt

此屏幕类似于 ItemEditScreen.kt。它们都包含用于项目详细信息的文本字段。当在主屏幕中点击 FAB 时,将显示此屏幕。ItemEntryViewModel.kt 是此屏幕的对应 ViewModel

Add Item screen with item details filled in.

ui/navigation/InventoryNavGraph.kt

此文件是整个应用的导航图。

4. Room 的主要组件

Kotlin 提供了一种使用数据类轻松处理数据的方法。虽然使用数据类处理内存中数据很容易,但当涉及持久化数据时,您需要将此数据转换为与数据库存储兼容的格式。为此,您需要来存储数据,以及查询来访问和修改数据。

以下三个 Room 组件使这些工作流程变得无缝。

  • Room 实体 代表应用数据库中的表。您可以使用它们来更新存储在表中的行中的数据,以及创建新的行以进行插入。
  • Room DAO 提供了应用用来检索、更新、插入和删除数据库中数据的函数。
  • Room 数据库类 是数据库类,它为您的应用提供了与该数据库关联的 DAO 的实例。

您将在本 Codelab 的后面实现并详细了解这些组件。下图演示了 Room 的组件如何协同工作以与数据库交互。

a3288e8f37250031.png

添加 Room 依赖项

在本任务中,您将向 Gradle 文件添加所需的 Room 组件库。

  1. 打开模块级 Gradle 文件 build.gradle.kts (Module: InventoryApp.app)
  2. dependencies 块中,添加以下代码所示的 Room 库的依赖项。
//Room
implementation("androidx.room:room-runtime:${rootProject.extra["room_version"]}")
ksp("androidx.room:room-compiler:${rootProject.extra["room_version"]}")
implementation("androidx.room:room-ktx:${rootProject.extra["room_version"]}")

KSP 是一个强大且简单的 Kotlin 注释解析 API。

5. 创建一个项目实体

实体 类定义一个表,此类的每个实例代表数据库表中的一行。实体类包含映射,以告知 Room 它打算如何呈现和交互数据库中的信息。在您的应用中,实体包含有关库存项目的信息,例如项目名称、项目价格和可用项目的数量。

8c9f1659ee82ca43.png

@Entity 注释将类标记为数据库实体类。对于每个实体类,应用都会创建一个数据库表来保存项目。实体的每个字段都表示为数据库中的一个列,除非另有说明(有关详细信息,请参阅 实体 文档)。存储在数据库中的每个实体实例都必须具有主键。主键 用于唯一标识数据库表中的每条记录/条目。应用在分配主键后,主键将无法修改;它代表实体对象,只要它存在于数据库中。

在本任务中,您将创建一个实体类,并定义字段来存储以下库存信息(针对每个项目):一个 Int 用于存储主键,一个 String 用于存储项目名称,一个 double 用于存储项目价格,以及一个 Int 用于存储库存中的数量。

  1. 在 Android Studio 中打开入门代码。
  2. 打开 com.example.inventory 基本包下的 data 包。
  3. data 包内,打开 Item Kotlin 类,它代表应用中的数据库实体。
// No need to copy over, this is part of the starter code
class Item(
    val id: Int,
    val name: String,
    val price: Double,
    val quantity: Int
)

数据类

数据类主要用于在 Kotlin 中保存数据。它们用关键字 data 定义。Kotlin 数据类对象具有一些额外的好处。例如,编译器会自动生成一些用于比较、打印和复制的工具,例如 toString()copy()equals()

示例

// Example data class with 2 properties.
data class User(val firstName: String, val lastName: String){
}

为了确保生成代码的一致性和有意义的行为,数据类必须满足以下要求

  • 主构造函数必须至少包含一个参数。
  • 所有主构造函数参数必须为 valvar
  • 数据类不能是 abstractopensealed

要了解更多关于数据类的信息,请查看数据类 文档。

  1. Item 类定义之前添加 data 关键字以将其转换为数据类。
data class Item(
    val id: Int,
    val name: String,
    val price: Double,
    val quantity: Int
)
  1. Item 类声明上方,使用 @Entity 注解数据类。使用 tableName 参数将 items 设置为 SQLite 表名。
import androidx.room.Entity

@Entity(tableName = "items")
data class Item(
   ...
)
  1. 使用 @PrimaryKey 注解 id 属性,以使 id 成为主键。主键是用来唯一标识 Item 表中每条记录/条目的 ID。
import androidx.room.PrimaryKey

@Entity(tableName = "items")
data class Item(
    @PrimaryKey
    val id: Int,
    ...
)
  1. id 分配默认值为 0,这对于 id 自动生成 id 值是必需的。
  2. autoGenerate 参数添加到 @PrimaryKey 注解中,以指定主键列是否应该自动生成。如果 autoGenerate 设置为 true,Room 将在将新的实体实例插入数据库时自动为主键列生成一个唯一值。这样可以确保每个实体实例都有一个唯一的标识符,而无需手动为主键列分配值。
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    // ...
)

很好!现在您已经创建了一个 Entity 类,您可以创建一个数据访问对象 (DAO) 来访问数据库。

6. 创建 item DAO

数据访问对象 (DAO) 是一种模式,您可以使用它通过提供抽象接口来将持久层与应用程序的其余部分隔离开来。这种隔离遵循单一职责原则,您在之前的代码实验室中已经见过。

DAO 的功能是隐藏底层持久层中执行数据库操作所涉及的所有复杂性,与应用程序的其余部分分开。这样一来,您就可以独立地更改数据层,而无需更改使用数据的代码。

8b91b8bbd7256a63.png

在本任务中,您将为 Room 定义一个 DAO。DAO 是 Room 的主要组件,负责定义访问数据库的接口。

您创建的 DAO 是一个自定义接口,提供用于查询/检索、插入、删除和更新数据库的便捷方法。Room 在编译时生成此类的实现。

Room 库提供了一些便捷注解,例如 @Insert@Delete@Update,用于定义执行简单插入、删除和更新的方法,而无需您编写 SQL 语句。

如果您需要定义更复杂的插入、删除、更新操作,或者您需要查询数据库中的数据,请使用 @Query 注解。

作为额外的好处,当您在 Android Studio 中编写查询时,编译器会检查您的 SQL 查询是否存在语法错误。

对于 Inventory 应用程序,您需要以下功能

  • 插入 或添加新条目。
  • 更新现有条目以更新名称、价格和数量。
  • 获取基于主键 id 的特定条目。
  • 获取所有条目,以便您能显示它们。
  • 删除数据库中的条目。

59aaa051e6a22e79.png

完成以下步骤,在您的应用程序中实现 item DAO

  1. data 包中,创建 Kotlin 接口 ItemDao.kt

name field is filled in as item dao

  1. 使用 @Dao 注解 ItemDao 接口。
import androidx.room.Dao

@Dao
interface ItemDao {
}
  1. 在接口主体内部,添加一个 @Insert 注解。
  2. @Insert 下方,添加一个 insert() 函数,该函数以 Entityitem 的实例作为参数。
  3. 使用 suspend 关键字标记该函数,使其在单独的线程上运行。

数据库操作可能需要很长时间才能执行,因此它们需要在单独的线程上运行。Room 不允许在主线程上访问数据库。

import androidx.room.Insert

@Insert
suspend fun insert(item: Item)

在将条目插入数据库时,可能会发生冲突。例如,代码中的多个地方尝试使用不同的冲突值(例如相同的 主键)来更新实体。实体是数据库中的行。在 Inventory 应用程序中,我们只从一个地方插入实体,即 **添加条目** 屏幕,因此我们不希望出现任何冲突,并且可以将冲突策略设置为 *忽略*。

  1. 添加一个参数 onConflict 并为其分配一个值为 OnConflictStrategy.IGNORE

参数 onConflict 告诉 Room 在发生冲突时该怎么做。OnConflictStrategy.IGNORE 策略会忽略新条目。

要了解有关可用冲突策略的更多信息,请查看 OnConflictStrategy 文档。

import androidx.room.OnConflictStrategy

@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(item: Item)

现在 Room 会生成将 item 插入数据库所需的所有代码。当您调用任何使用 Room 注解标记的 DAO 函数时,Room 都会在数据库上执行相应的 SQL 查询。例如,当您从 Kotlin 代码中调用上述方法 insert() 时,Room 会执行一个 SQL 查询,将实体插入数据库。

  1. 添加一个使用 @Update 注解的新函数,该函数以 Item 作为参数。

要更新的实体具有与传入的实体相同的 主键。您可以更新实体的其他属性的一部分或全部。

  1. insert() 方法类似,使用 suspend 关键字标记此函数。
import androidx.room.Update

@Update
suspend fun update(item: Item)

添加另一个使用 @Delete 注解的函数来删除条目,并将其设为挂起函数。

import androidx.room.Delete

@Delete
suspend fun delete(item: Item)

对于其余功能,没有便捷注解,因此您必须使用 @Query 注解并提供 SQLite 查询。

  1. 编写一个 SQLite 查询,从条目表中检索特定条目,该条目基于给定的 id。以下代码提供了一个示例查询,该查询从 items 中选择所有列,其中 id 与特定值匹配,而 id 是唯一的标识符。

示例

// Example, no need to copy over
SELECT * from items WHERE id = 1
  1. 添加一个 @Query 注解。
  2. 使用上一步中的 SQLite 查询作为字符串参数传递给 @Query 注解。
  3. @Query 添加一个 String 参数,该参数是用于从条目表中检索条目的 SQLite 查询。

查询现在表示从 items 中选择所有列,其中 id 与 :id 参数匹配。请注意,:id 在查询中使用冒号表示法来引用函数中的参数。

@Query("SELECT * from items WHERE id = :id")
  1. @Query 注解之后,添加一个 getItem() 函数,该函数接受一个 Int 参数并返回一个 Flow<Item>
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Query("SELECT * from items WHERE id = :id")
fun getItem(id: Int): Flow<Item>

建议在持久层中使用 Flow。使用 Flow 作为返回值类型,您可以收到数据库中数据发生变化时的通知。Room 会为您保持此 Flow 更新,这意味着您只需要显式获取一次数据。这种设置有助于更新库存列表,您将在下一个代码实验室中实现它。由于 Flow 返回类型,Room 还会在后台线程上运行查询。您无需显式将其设为 suspend 函数并将其在协程作用域内调用。

  1. 添加一个使用 @QuerygetAllItems() 函数。
  2. 让 SQLite 查询从 item 表中返回所有列,并按升序排序。
  3. getAllItems()Flow 的形式返回 Item 实体的列表。Room 会为您保持此 Flow 更新,这意味着您只需要显式获取一次数据。
@Query("SELECT * from items ORDER BY name ASC")
fun getAllItems(): Flow<List<Item>>

已完成 ItemDao

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ItemDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(item: Item)

    @Update
    suspend fun update(item: Item)

    @Delete
    suspend fun delete(item: Item)

    @Query("SELECT * from items WHERE id = :id")
    fun getItem(id: Int): Flow<Item>

    @Query("SELECT * from items ORDER BY name ASC")
    fun getAllItems(): Flow<List<Item>>
}
  1. 虽然您不会看到任何明显的更改,但请构建您的应用程序,确保它没有任何错误。

7. 创建数据库实例

在本任务中,您将创建一个 RoomDatabase,该数据库使用您在上一步创建的 Entity 和 DAO。数据库类定义了实体和 DAO 的列表。

Database 类为您的应用程序提供您定义的 DAO 的实例。反过来,应用程序可以使用 DAO 从数据库中检索数据,并将其作为关联数据实体对象的实例。应用程序还可以使用定义的数据实体更新对应表的行或创建要插入的新行。

您需要创建一个抽象 RoomDatabase 类,并使用 @Database 注解它。此类有一个方法可以返回现有 RoomDatabase 实例(如果数据库不存在)。

以下是获取 RoomDatabase 实例的常规流程

  • 创建一个扩展 RoomDatabasepublic abstract 类。您定义的新抽象类充当数据库持有者。您定义的类是抽象的,因为 Room 为您创建实现。
  • 使用 @Database 注解类。在参数中列出数据库中的实体并设置版本号。
  • 定义一个返回 ItemDao 实例的抽象方法或属性,Room 将为您生成实现。
  • 您只需要一个 RoomDatabase 实例,因此请将 RoomDatabase 设置为单例。
  • 使用 RoomRoom.databaseBuilder 创建您的 (item_database) 数据库(仅当它不存在时)。否则,返回现有的数据库。

创建数据库

  1. data 包中,创建一个 Kotlin 类 InventoryDatabase.kt
  2. InventoryDatabase.kt 文件中,使 InventoryDatabase 类成为扩展 RoomDatabaseabstract 类。
  3. 使用 @Database 注解类。忽略缺少参数的错误,您将在下一步中修复它。
import androidx.room.Database
import androidx.room.RoomDatabase

@Database
abstract class InventoryDatabase : RoomDatabase() {}

@Database 注解需要几个参数,以便 Room 可以构建数据库。

  1. Item 指定为唯一带有 entities 列表的类。
  2. version 设置为 1。每当您更改数据库表的模式时,您都必须增加版本号。
  3. exportSchema 设置为 false,以防止保留模式版本历史备份。
@Database(entities = [Item::class], version = 1, exportSchema = false)
  1. 在类主体内部,声明一个返回 ItemDao 的抽象函数,以便数据库了解 DAO。
abstract fun itemDao(): ItemDao
  1. 在抽象函数下方,定义一个 companion object,它允许访问用于创建或获取数据库的方法,并使用类名作为限定符。
 companion object {}
  1. companion 对象内部,声明一个私有的可空变量 Instance 用于数据库,并将其初始化为 null

Instance 变量在创建数据库后保留对数据库的引用。这有助于维护在给定时间打开的数据库的单个实例,这是一个昂贵的资源,需要创建和维护。

  1. 使用 @Volatile 注解 Instance

易失变量的值永远不会被缓存,所有读写操作都是对主内存的读写。这些功能有助于确保 Instance 的值始终是最新的,并且对于所有执行线程都是相同的。这意味着一个线程对 Instance 进行的更改会立即对所有其他线程可见。

@Volatile
private var Instance: InventoryDatabase? = null
  1. Instance 下方,在 companion 对象内部,定义一个名为 getDatabase() 的方法,该方法具有数据库构建器需要的 Context 参数。
  2. 返回类型 InventoryDatabase。出现错误消息,因为 getDatabase() 尚未返回任何内容。
import android.content.Context

fun getDatabase(context: Context): InventoryDatabase {}

多个线程可能同时请求数据库实例,从而导致两个数据库而不是一个数据库。此问题被称为 竞态条件。将获取数据库的代码包装在 synchronized 块中,意味着一次只有一个执行线程可以进入此代码块,这确保数据库只初始化一次。使用 synchronized{} 块来避免竞态条件。

  1. getDatabase() 内部,返回 Instance 变量——或者,如果 Instance 为 null,则在 synchronized{} 块中初始化它。使用 Elvis 运算符 (?:) 来执行此操作。
  2. 传入 this,即伴随对象。您将在后续步骤中修复错误。
return Instance ?: synchronized(this) { }
  1. 在同步块内部,使用数据库构建器来获取数据库。继续忽略错误,您将在下一步中修复它。
import androidx.room.Room

Room.databaseBuilder()
  1. synchronized 块内部,使用数据库构建器来获取数据库。将应用程序上下文、数据库类和数据库名称 item_database 传递给 Room.databaseBuilder()
Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")

Android Studio 生成了类型不匹配错误。要删除此错误,您需要在以下步骤中添加 build()

  1. 将所需的迁移策略添加到构建器中。使用 . fallbackToDestructiveMigration().
.fallbackToDestructiveMigration()
  1. 要创建数据库实例,请调用 .build()。此调用将删除 Android Studio 错误。
.build()
  1. build() 后,添加一个 also 块并将 Instance = it 分配给它,以保留对最近创建的数据库实例的引用。
.also { Instance = it }
  1. synchronized 块的末尾,返回 instance。您的最终代码如下所示
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

/**
* Database class with a singleton Instance object.
*/
@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class InventoryDatabase : RoomDatabase() {

    abstract fun itemDao(): ItemDao

    companion object {
        @Volatile
        private var Instance: InventoryDatabase? = null

        fun getDatabase(context: Context): InventoryDatabase {
            // if the Instance is not null, return it, otherwise create a new database instance.
            return Instance ?: synchronized(this) {
                Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")
                    .build()
                    .also { Instance = it }
            }
        }
    }
}
  1. 构建您的代码以确保没有错误。

8. 实现 Repository

在此任务中,您将实现 ItemsRepository 接口和 OfflineItemsRepository 类,以从数据库提供 getinsertdeleteupdate 实体。

  1. 打开 data 包下的 ItemsRepository.kt 文件。
  2. 将以下函数添加到接口,它们映射到 DAO 实现。
import kotlinx.coroutines.flow.Flow

/**
* Repository that provides insert, update, delete, and retrieve of [Item] from a given data source.
*/
interface ItemsRepository {
    /**
     * Retrieve all the items from the the given data source.
     */
    fun getAllItemsStream(): Flow<List<Item>>

    /**
     * Retrieve an item from the given data source that matches with the [id].
     */
    fun getItemStream(id: Int): Flow<Item?>

    /**
     * Insert item in the data source
     */
    suspend fun insertItem(item: Item)

    /**
     * Delete item from the data source
     */
    suspend fun deleteItem(item: Item)

    /**
     * Update item in the data source
     */
    suspend fun updateItem(item: Item)
}
  1. 打开 data 包下的 OfflineItemsRepository.kt 文件。
  2. 传入类型为 ItemDao 的构造函数参数。
class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository
  1. OfflineItemsRepository 类中,覆盖在 ItemsRepository 接口中定义的函数,并调用来自 ItemDao 的相应函数。
import kotlinx.coroutines.flow.Flow

class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository {
    override fun getAllItemsStream(): Flow<List<Item>> = itemDao.getAllItems()

    override fun getItemStream(id: Int): Flow<Item?> = itemDao.getItem(id)

    override suspend fun insertItem(item: Item) = itemDao.insert(item)

    override suspend fun deleteItem(item: Item) = itemDao.delete(item)

    override suspend fun updateItem(item: Item) = itemDao.update(item)
}

实现 AppContainer 类

在此任务中,您将实例化数据库并将 DAO 实例传递给 OfflineItemsRepository 类。

  1. 打开 data 包下的 AppContainer.kt 文件。
  2. ItemDao() 实例传递给 OfflineItemsRepository 构造函数。
  3. 通过在 InventoryDatabase 类上调用 getDatabase() 并传入上下文来实例化数据库实例,并调用 .itemDao() 来创建 Dao 的实例。
override val itemsRepository: ItemsRepository by lazy {
    OfflineItemsRepository(InventoryDatabase.getDatabase(context).itemDao())
}

现在,您拥有使用 Room 的所有构建块。此代码可以编译并运行,但您无法判断它是否真的有效。因此,现在是测试数据库的好时机。要完成测试,您需要 ViewModel 来与数据库通信。

9. 添加保存功能

到目前为止,您创建了一个数据库,而 UI 类是启动代码的一部分。要保存应用程序的瞬态数据并访问数据库,您需要更新 ViewModel。您的 ViewModel 通过 DAO 与数据库交互,并为 UI 提供数据。所有数据库操作都需要在主 UI 线程之外运行;您可以使用协程和 viewModelScope 来执行此操作。

UI 状态类演练

打开 ui/item/ItemEntryViewModel.kt 文件。 ItemUiState 数据类表示项目的 UI 状态。 ItemDetails 数据类表示单个项目。

启动代码为您提供了三个扩展函数

  • ItemDetails.toItem() 扩展函数将 ItemUiState UI 状态对象转换为 Item 实体类型。
  • Item.toItemUiState() 扩展函数将 Item Room 实体对象转换为 ItemUiState UI 状态类型。
  • Item.toItemDetails() 扩展函数将 Item Room 实体对象转换为 ItemDetails
// No need to copy, this is part of starter code
/**
* Represents Ui State for an Item.
*/
data class ItemUiState(
    val itemDetails: ItemDetails = ItemDetails(),
    val isEntryValid: Boolean = false
)

data class ItemDetails(
    val id: Int = 0,
    val name: String = "",
    val price: String = "",
    val quantity: String = "",
)

/**
* Extension function to convert [ItemDetails] to [Item]. If the value of [ItemDetails.price] is
* not a valid [Double], then the price will be set to 0.0. Similarly if the value of
* [ItemDetails.quantity] is not a valid [Int], then the quantity will be set to 0
*/
fun ItemDetails.toItem(): Item = Item(
    id = id,
    name = name,
    price = price.toDoubleOrNull() ?: 0.0,
    quantity = quantity.toIntOrNull() ?: 0
)

fun Item.formatedPrice(): String {
    return NumberFormat.getCurrencyInstance().format(price)
}

/**
* Extension function to convert [Item] to [ItemUiState]
*/
fun Item.toItemUiState(isEntryValid: Boolean = false): ItemUiState = ItemUiState(
    itemDetails = this.toItemDetails(),
    isEntryValid = isEntryValid
)

/**
* Extension function to convert [Item] to [ItemDetails]
*/
fun Item.toItemDetails(): ItemDetails = ItemDetails(
    id = id,
    name = name,
    price = price.toString(),
    quantity = quantity.toString()
)

您在视图模型中使用上述类来读取和更新 UI。

更新 ItemEntry ViewModel

在此任务中,您将存储库传递给 ItemEntryViewModel.kt 文件。您还将“添加项目”屏幕中输入的项目详细信息保存到数据库中。

  1. 注意 ItemEntryViewModel 类中的 validateInput() 私有函数。
// No need to copy over, this is part of starter code
private fun validateInput(uiState: ItemDetails = itemUiState.itemDetails): Boolean {
    return with(uiState) {
        name.isNotBlank() && price.isNotBlank() && quantity.isNotBlank()
    }
}

上述函数检查 namepricequantity 是否为空。您使用此函数来验证用户输入,然后再将实体添加到数据库或从数据库更新实体。

  1. 打开 ItemEntryViewModel 类,并在 ItemEntryViewModel 类的末尾添加一个类型为 ItemsRepositoryprivate 默认构造函数参数。
import com.example.inventory.data.ItemsRepository

class ItemEntryViewModel(private val itemsRepository: ItemsRepository) : ViewModel() {
}
  1. 更新 ui/AppViewModelProvider.kt 中的项目条目视图模型的 initializer,并将存储库实例作为参数传递。
object AppViewModelProvider {
    val Factory = viewModelFactory {
        // Other Initializers 
        // Initializer for ItemEntryViewModel
        initializer {
            ItemEntryViewModel(inventoryApplication().container.itemsRepository)
        }
        //...
    }
}
  1. 转到 ItemEntryViewModel.kt 文件,并在 ItemEntryViewModel 类的末尾添加一个名为 saveItem() 的挂起函数,以将项目插入 Room 数据库。此函数以非阻塞方式将数据添加到数据库中。
suspend fun saveItem() {
}
  1. 在函数内部,检查 itemUiState 是否有效,并将其转换为 Item 类型,以便 Room 能够理解数据。
  2. itemsRepository 上调用 insertItem() 并传入数据。UI 调用此函数将项目详细信息添加到数据库中。
suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}

您现在已将所有必需的函数添加到数据库中。在下一个任务中,您将更新 UI 以使用上述函数。

ItemEntryBody() 可组合演练

  1. ui/item/ItemEntryScreen.kt 文件中,ItemEntryBody() 可组合函数已为您部分实现,作为入门代码的一部分。请查看 ItemEntryScreen() 函数调用中的 ItemEntryBody() 可组合函数。
// No need to copy over, part of the starter code
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = { },
    modifier = Modifier
        .padding(innerPadding)
        .verticalScroll(rememberScrollState())
        .fillMaxWidth()
)
  1. 请注意,UI 状态和 updateUiState lambda 作为函数参数传递。查看函数定义以了解 UI 状态的更新方式。
// No need to copy over, part of the starter code
@Composable
fun ItemEntryBody(
    itemUiState: ItemUiState,
    onItemValueChange: (ItemUiState) -> Unit,
    onSaveClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        // ...
    ) {
        ItemInputForm(
             itemDetails = itemUiState.itemDetails,
             onValueChange = onItemValueChange,
             modifier = Modifier.fillMaxWidth()
         )
        Button(
             onClick = onSaveClick,
             enabled = itemUiState.isEntryValid,
             shape = MaterialTheme.shapes.small,
             modifier = Modifier.fillMaxWidth()
         ) {
             Text(text = stringResource(R.string.save_action))
         }
    }
}

您正在此可组合函数中显示 ItemInputForm 和一个 **保存** 按钮。在 ItemInputForm() 可组合函数中,您正在显示三个文本字段。只有在文本字段中输入文本后,**保存** 按钮才会启用。如果所有文本字段中的文本都有效(非空),则 isEntryValid 值为 true。

Phone screen with item details partially filled and save button disabled

Phone screen with item details filled and save button enabled

  1. 查看 ItemInputForm() 可组合函数的实现,并注意 onValueChange 函数参数。您正在使用用户在文本字段中输入的值更新 itemDetails 值。在 **保存** 按钮启用时,itemUiState.itemDetails 将包含需要保存的值。
// No need to copy over, part of the starter code
@Composable
fun ItemEntryBody(
    //...
) {
    Column(
        // ...
    ) {
        ItemInputForm(
             itemDetails = itemUiState.itemDetails,
             //...
         )
        //...
    }
}
// No need to copy over, part of the starter code
@Composable
fun ItemInputForm(
    itemDetails: ItemDetails,
    modifier: Modifier = Modifier,
    onValueChange: (ItemUiState) -> Unit = {},
    enabled: Boolean = true
) {
    Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        OutlinedTextField(
            value = itemUiState.name,
            onValueChange = { onValueChange(itemDetails.copy(name = it)) },
            //...
        )
        OutlinedTextField(
            value = itemUiState.price,
            onValueChange = { onValueChange(itemDetails.copy(price = it)) },
            //...
        )
        OutlinedTextField(
            value = itemUiState.quantity,
            onValueChange = { onValueChange(itemDetails.copy(quantity = it)) },
            //...
        )
    }
}

向保存按钮添加点击监听器

为了将所有内容联系起来,请向 **保存** 按钮添加一个点击处理程序。在点击处理程序中,启动一个协程并调用 saveItem() 以将数据保存在 Room 数据库中。

  1. ItemEntryScreen.kt 中,在 ItemEntryScreen 可组合函数内部,使用 rememberCoroutineScope() 可组合函数创建一个名为 coroutineScopeval
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. 更新 ItemEntryBody() 函数调用,并在 onSaveClick lambda 中启动一个协程。
ItemEntryBody(
   // ...
    onSaveClick = {
        coroutineScope.launch {
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. 查看 ItemEntryViewModel.kt 文件中的 saveItem() 函数实现,以检查 itemUiState 是否有效,将 itemUiState 转换为 Item 类型,并使用 itemsRepository.insertItem() 将其插入数据库。
// No need to copy over, you have already implemented this as part of the Room implementation 

suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}
  1. ItemEntryScreen.kt 中,在 ItemEntryScreen 可组合函数内部,在协程内部,调用 viewModel.saveItem() 以将项目保存在数据库中。
ItemEntryBody(
    // ...
    onSaveClick = {
        coroutineScope.launch {
            viewModel.saveItem()
        }
    },
    //...
)

请注意,您没有在 ItemEntryViewModel.kt 文件中的 saveItem() 中使用 viewModelScope.launch(),但在 ItemEntryBody() 调用存储库方法时,它很有必要。您只能从协程或另一个挂起函数调用挂起函数。函数 viewModel.saveItem() 是一个挂起函数。

  1. 构建并运行您的应用程序。
  2. 点击 **+** FAB。
  3. 在 **添加项目** 屏幕中,添加项目详细信息并点击 **保存**。请注意,点击 **保存** 按钮不会关闭 **添加项目** 屏幕。

Phone screen with item details filled and save button enabled

  1. onSaveClick lambda 中,在调用 viewModel.saveItem() 后添加对 navigateBack() 的调用,以导航回上一个屏幕。您的 ItemEntryBody() 函数看起来像以下代码
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = {
        coroutineScope.launch {
            viewModel.saveItem()
            navigateBack()
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. 再次运行应用程序并执行相同的步骤以输入和保存数据。请注意,这次应用程序会导航回 **库存** 屏幕。

此操作将保存数据,但您无法在应用程序中看到库存数据。在下一个任务中,您将使用 数据库检查器 查看您保存的数据。

App screen with empty inventory list

10. 使用数据库检查器查看数据库内容

您可以使用 数据库检查器 检查、查询和修改应用程序运行时的数据库。此功能对于数据库调试特别有用。数据库检查器适用于纯 SQLite 和基于 SQLite 构建的库,例如 Room。数据库检查器在运行 API 级别 26 的模拟器/设备上效果最佳。

  1. 如果尚未执行此操作,请在运行 API 级别 26 或更高版本的模拟器或已连接的设备上运行您的应用程序。
  2. 在 Android Studio 中,从菜单栏中选择 **查看** > **工具窗口** > **应用程序检查**。
  3. 选择 **数据库检查器** 选项卡。
  4. 在 **数据库检查器** 窗格中,从下拉菜单中选择 com.example.inventory(如果尚未选择)。库存应用程序中的 **item_database** 将出现在 **数据库** 窗格中。

76408bd5e93c3432.png

  1. 展开 **数据库** 窗格中 **item_database** 的节点,然后选择 **项目** 以进行检查。如果 **数据库** 窗格为空,请使用模拟器通过 **添加项目** 屏幕将一些项目添加到数据库中。
  2. 选中数据库检查器中的 **实时更新** 复选框,以便在您在模拟器或设备中与运行中的应用程序进行交互时自动更新它呈现的数据。

9e21d9f7eb426008.png

恭喜您!您创建了一个可以使用 Room 持久化数据的应用程序。在下一个代码实验室中,您将在应用程序中添加一个 lazyColumn 来显示数据库中的项目,并为应用程序添加新功能,例如删除和更新实体的功能。我们在那里见!

11. 获取解决方案代码

此代码实验室的解决方案代码位于 GitHub 存储库中。要下载完成的代码实验室的代码,请使用以下 git 命令

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-inventory-app.git
$ cd basic-android-kotlin-compose-training-inventory-app
$ git checkout room

或者,您可以将存储库下载为 zip 文件,将其解压缩并在 Android Studio 中打开它。

如果您想查看此代码实验室的解决方案代码,请在 GitHub 上查看它。

12. 总结

  • 将您的表定义为使用 @Entity 注释的数据类。将使用 @ColumnInfo 注释的属性定义为表中的列。
  • 将数据访问对象 (DAO) 定义为使用 @Dao 注释的接口。DAO 将 Kotlin 函数映射到数据库查询。
  • 使用注释定义 @Insert@Delete@Update 函数。
  • @Query 注释与 SQLite 查询字符串一起使用作为任何其他查询的参数。
  • 使用 数据库检查器 查看保存在 Android SQLite 数据库中的数据。

13. 了解详情

Android 开发者文档

博客文章

视频

其他文档和文章