使用 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. 应用程序概述

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

Phone screen with inventory items

Add item screen show in the phone screen.

Add Item screen with item details filled in.

3. 启动应用程序概述

下载此代码实验室的启动代码

要开始,请下载启动代码

或者,您可以克隆代码的 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.

在此代码实验室中,您将添加使用 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 的实例。

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

a3288e8f37250031.png

添加 Room 依赖项

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

  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. 使用data关键字作为Item类的定义前缀,将其转换为数据类。
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成为主键。主键是一个 ID,用于唯一标识Item表中的每个记录/条目。
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,
    // ...
)

太棒了!现在您已经创建了实体类,您可以创建一个数据访问对象 (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()函数,该函数以实体类item的实例作为其参数。
  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. 添加带有getAllItems()函数的@Query
  2. 让 SQLite 查询返回item表中的所有列,并按升序排序。
  3. getAllItems()返回一个Item实体列表作为FlowRoom会为您更新此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. 创建数据库实例

在此任务中,您将创建一个使用您在先前任务中创建的Entity和 DAO 的RoomDatabase。数据库类定义了实体和 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对象中,定义一个具有Context参数的getDatabase()方法,数据库构建器需要此参数。
  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分配给它以保存对最近创建的 db 实例的引用。
.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. 实现存储库

在此任务中,您将实现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 数据类表示 Item 的 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 类,并添加一个类型为 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 的节点,然后选择 Item 进行检查。如果您的“数据库”窗格为空,请使用您的模拟器通过“添加项目”屏幕将一些项目添加到数据库。
  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 中打开。

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

12. 总结

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

13. 了解更多

Android 开发者文档

博客文章

视频

其他文档和文章