使用 Room 持久保留数据

1. 准备工作

大多数达到生产质量标准的应用都包含需要持久保留的数据。例如,应用可能会存储歌曲播放列表、待办事项列表中的内容、支出和收入记录、星座目录或个人数据历史记录。对于此类用例,您可以使用数据库来存储这些持久性数据。

Room 是一个持久性库,属于 Android Jetpack 的一部分。Room 是在 SQLite 数据库基础上构建的一个抽象层。SQLite 使用一种专门的语言 (SQL) 来执行数据库操作。Room 并不直接使用 SQLite,而是负责简化数据库设置和配置以及数据库与应用交互方面的琐碎工作。Room 还提供 SQLite 语句的编译时检查。

抽象层是一组隐藏了底层实现/复杂性的函数。抽象层可为现有功能集提供一个接口,就像在本例中使用 SQLite 一样。

下图展示了 Room 作为数据源如何融入本课程中推荐的总体架构。Room 是一个数据源。

数据层包含存储库和数据源

前提条件

  • 能够使用 Jetpack Compose 为 Android 应用构建基本界面。
  • 能够使用 TextIconIconButtonLazyColumn 等可组合函数。
  • 能够使用 NavHost 可组合函数定义应用中的路线和界面。
  • 能够使用 NavHostController 在界面之间导航。
  • 熟悉 Android 架构组件 ViewModel。能够使用 ViewModelProvider.Factory 实例化 ViewModel。
  • 熟悉并发基础知识。
  • 能够使用协程管理长时间运行的任务。
  • 掌握 SQLite 数据库和 SQL 语言的基础知识。

学习内容

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

构建内容

  • 您将构建一个 Inventory 应用,用于将商品目录中的商品保存到 SQLite 数据库中。

所需条件

  • Inventory 应用的起始代码
  • 一台安装了 Android Studio 的电脑
  • 搭载 API 级别 26 或更高级别的设备或模拟器

2. 应用概览

在此 Codelab 中,您将使用 Inventory 应用的起始代码,并使用 Room 库向其中添加数据库层。最终版本的应用会显示商品目录数据库中的商品列表。用户可以选择在商品目录数据库中添加新商品、更新现有商品和删除其中的商品。在此 Codelab 中,您需要将商品数据保存到 Room 数据库。您将在下一个 Codelab 中实现应用的其余功能。

显示商品目录中商品的手机屏幕

手机屏幕中显示“Add item”界面。

已填写商品详情的“Add Item”界面。

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

您可以在 Inventory app GitHub 代码库中浏览该代码。

起始代码概览

  1. 在 Android Studio 中打开包含起始代码的项目。
  2. 在 Android 设备或模拟器上运行应用。确保模拟器或连接的设备搭载的是 API 级别 26 或更高版本。 Database Inspector 适用于搭载 API 级别 26 及更高版本的模拟器/设备。
  1. 您会注意到,该应用未显示任何商品目录数据。
  2. 点按悬浮操作按钮 (FAB) 向数据库中添加新商品。

应用会转到一个新界面,您可以在其中输入新商品的详情。

显示空白商品目录的手机屏幕

手机屏幕中显示“Add item”界面。

起始代码存在的问题

  1. Add Item 界面中,输入商品的详情,例如名称、价格和数量。
  2. 点按 SaveAdd Item 界面未关闭,但您可以使用返回键返回。保存功能未实现,因此系统不会保存商品详情。

请注意,该应用尚未完成,Save 按钮功能尚未实现。

已填写商品详情的“Add Item”界面。

在此 Codelab 中,您将添加使用 Room 将商品目录详情保存到 SQLite 数据库中的代码。您可以使用 Room 持久性库与 SQLite 数据库进行交互。

代码演示

您下载的起始代码已为您预先设计了界面布局。在本在线课程中,您只需专心实现数据库逻辑即可。以下部分简要介绍了一些文件,以帮助您上手。

ui/home/HomeScreen.kt

此文件是主屏幕,即应用的第一个屏幕,其中包含用于显示商品目录列表的可组合函数。它包含一个 FAB +,可用于向列表中添加新商品。您将在本在线课程的稍后阶段显示列表中的商品。

显示商品目录中商品的手机屏幕

ui/item/ItemEntryScreen.kt

此界面类似于 ItemEditScreen.kt。它们都提供了用于输入商品详情的文本字段。点按主屏幕中的 FAB 即会显示此界面。ItemEntryViewModel.kt 是此界面的对应 ViewModel

已填写商品详情的“Add Item”界面。

ui/navigation/InventoryNavGraph.kt

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

4. Room 的主要组件

Kotlin 提供了一种通过数据类轻松处理数据的方式。虽然使用数据类可以轻松地处理内存中的数据,但当需要持久保留数据时,您需要将这些数据转换为与数据库存储系统兼容的格式。为此,您需要使用表来存储数据,并使用查询来访问和修改数据。

Room 的以下三个组件可以使这些工作流变得顺畅。

  • Room 实体表示应用数据库中的表。您可以使用它们更新表中的行所存储的数据,以及创建要插入的新行。
  • Room DAO 提供了供应用在数据库中检索、更新、插入和删除数据的方法。
  • Room Database 类是一个数据库类,可为您的应用提供与该数据库关联的 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 是一个功能强大且简单易用的 API,用于解析 Kotlin 注解。

5. 创建商品 Entity

Entity 类定义了一个表,该类的每个实例都表示数据库表中的一行。Entity 类以映射告知 Room 它打算如何呈现数据库中的信息并与之交互。在您的应用中,实体将保存有关商品目录商品的信息,例如商品名称、商品价格和商品数量。

8c9f1659ee82ca43.png

@Entity 注解用于将某个类标记为数据库 Entity 类。对于每个 Entity 类,该应用都会创建一个数据库表来保存这些项。除非另行说明,否则 Entity 的每个字段在数据库中都表示为一列(如需了解详情,请参阅实体文档)。存储在数据库中的每个实体实例都必须有一个主键。主键用于唯一标识数据库表中的每个记录/条目。应用分配主键后,便无法再修改主键;只要主键存在于数据库中,它就会表示实体对象。

在此任务中,您将创建一个 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. id 属性添加 @PrimaryKey 注解,使 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,
    // ...
)

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

6. 创建商品 DAO

数据访问对象 (DAO) 是一种模式,其作用是通过提供抽象接口将持久性层与应用的其余部分分离。这种分离遵循您曾在之前的 Codelab 中接触过的单一责任原则

DAO 的功能在于,让在底层持久性层执行数据库操作所涉及的所有复杂性与应用的其余部分分离。这样,您就可以独立于使用数据的代码更改数据层。

8b91b8bbd7256a63.png

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

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

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

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

另一个好处是,当您在 Android Studio 中编写查询时,编译器会检查 SQL 查询是否存在语法错误。

对于 Inventory 应用,您需要能够执行以下操作:

  • 插入或添加新商品。
  • 更新现有商品的名称、价格和数量。
  • 根据主键 id 获取特定商品。
  • 获取所有商品,从而可以显示它们。
  • 删除数据库中的条目。

59aaa051e6a22e79.png

完成以下步骤,以在您的应用中实现商品 DAO:

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

名称字段已填充为商品 DAO

  1. 为接口 ItemDao 添加 @Dao 注解。
import androidx.room.Dao

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

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

import androidx.room.Insert

@Insert
suspend fun insert(item: Item)

将商品插入数据库中时,可能会发生冲突。例如,代码中的多个位置尝试使用存在冲突的不同值(比如同一主键)更新实体。实体是数据库中的行。在 Inventory 应用中,我们仅从一处(即 Add Item 界面)插入实体,因此我们预计不会发生任何冲突,可以将冲突策略设为 Ignore

  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 从 item 表中检索特定商品。以下代码提供了一个示例查询,该查询从 items 中选择所有列,其中 id 与特定值匹配,id 是一个唯一标识符。

示例:

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

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

@Query("SELECT * from items WHERE id = :id")
  1. @Query 注解后面,添加一个接受 Int 参数并返回 Flow<Item>getItem() 函数。
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,也就是说,您只需要显式获取一次数据。此设置有助于更新您将在下一个 Codelab 中实现的商品目录。由于返回值类型为 Flow,Room 还会在后台线程上运行该查询。您无需将其明确设为 suspend 函数并在协程作用域内调用它。

  1. 添加 @Query 注解和 getAllItems() 函数。
  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. 创建 Database 实例

在此任务中,您将创建一个 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 对象内,为数据库声明一个私有的可为 null 变量 Instance,并将其初始化为 null

Instance 变量将在数据库创建后保留对数据库的引用。这有助于保持在任意时间点都只有一个打开的数据库实例,因为这种资源的创建和维护成本极高。

  1. Instance 添加 @Volatile 注解。

volatile 变量的值绝不会缓存,所有读写操作都将在主内存中完成。这些功能有助于确保 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. 实现存储库

在此任务中,您将实现 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. 添加保存功能

到目前为止,您已经创建了一个数据库,而界面类是起始代码的一部分。为了保存应用的瞬态数据,同时也为了访问数据库,您需要更新 ViewModel。您的 ViewModel 通过 DAO 与数据库交互,并为界面提供数据。所有数据库操作都必须在主界面线程之外运行,您将使用协程和 viewModelScope 做到这一点。

界面状态类演示

打开 ui/item/ItemEntryViewModel.kt 文件。ItemUiState 数据类表示商品的界面状态。ItemDetails 数据类表示单个商品。

起始代码为您提供了三个扩展函数:

  • ItemDetails.toItem() 扩展函数会将 ItemUiState 界面状态对象转换为 Item 实体类型。
  • Item.toItemUiState() 扩展函数会将 Item Room 实体对象转换为 ItemUiState 界面状态类型。
  • 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()
)

您可以在视图模型中使用上面的类来读取和更新界面。

更新 ItemEntry ViewModel

在此任务中,您需要将存储库传递给 ItemEntryViewModel.kt 文件。您还需要将在 Add Item 界面中输入的商品详情保存到数据库。

  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() 并传入数据。界面会调用此函数,以将商品详情添加到数据库。
suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}

现在,向数据库添加实体所需的函数已全部添加。在下一个任务中,您将更新界面以使用上述函数。

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. 请注意,界面状态和 updateUiState lambda 将作为函数参数传递。请查看函数定义,了解界面状态如何更新。
// 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))
         }
    }
}

您在此可组合函数中显示了 ItemInputFormSave 按钮。在 ItemInputForm() 可组合函数中,您显示了三个文本字段。只有在文本字段中输入文本后,系统才会启用 Save 按钮。如果所有文本字段中的文本均有效(非空),则 isEntryValid 值为 true。

手机屏幕显示:部分商品详情已自动填充,“Save”按钮已停用

手机屏幕显示:商品详情已填充,“Save”按钮已启用

  1. 查看 ItemInputForm() 可组合函数实现,并注意 onValueChange 函数参数。您将使用用户在文本字段中输入的值更新 itemDetails 值。启用 Save 按钮后,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)) },
            //...
        )
    }
}

向“Save”按钮添加点击监听器

为了将一切连接到一起,请为 Save 按钮添加一个点击处理程序。在点击处理程序中,您将启动一个协程并调用 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. Add Item 界面中,添加商品详情并点按 Save。请注意,点按 Save 按钮不会关闭 Add Item 界面。

手机屏幕显示:商品详情已填充,“Save”按钮已启用

  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. 再次运行应用,然后执行相同的步骤来输入并保存数据。请注意,这一次,应用将返回 Inventory 界面。

此操作会保存数据,但您在应用中看不到商品目录数据。在下一个任务中,您将使用 Database Inspector 查看已保存的数据。

显示空白商品目录清单的应用屏幕

10. 使用 Database Inspector 查看数据库内容

借助 Database Inspector,您可以在应用运行时检查、查询和修改应用的数据库。此功能对于数据库调试尤为有用。Database Inspector 可处理普通的 SQLite 数据库以及在 SQLite 的基础上构建的库(例如 Room)。Database Inspector 在搭载 API 级别 26 的模拟器/设备上使用效果最佳。

  1. 在搭载 API 级别 26 或更高版本的模拟器或已连接设备上运行您的应用(如果您尚未这样做)。
  2. 在 Android Studio 中,从菜单栏中依次选择 View > Tool Windows > App Inspection
  3. 选择 Database Inspector 标签页。
  4. Database Inspector 窗格中,从下拉菜单中选择 com.example.inventory(如果尚未选择)。Inventory 应用中的 item_database 将显示于 Databases 窗格中。

76408bd5e93c3432.png

  1. Databases 窗格中展开 item_database 的节点,然后选择要检查的 Item。如果 Databases 窗格为空,请使用模拟器通过 Add Item 界面向数据库中添加一些商品。
  2. 选中数据库检查器中的 Live updates 复选框,以便随着您与模拟器或设备中正在运行的应用互动而自动更新呈现的数据。

9e21d9f7eb426008.png

恭喜!您创建了一个可以使用 Room 保留数据的应用。在下一个 Codelab 中,您将向应用添加一个 lazyColumn 以显示数据库中的商品,并向应用添加新功能,例如删除和更新实体。到时候见!

11. 获取解决方案代码

此 Codelab 的解决方案代码位于 GitHub 仓库中。如需下载完成后的 Codelab 代码,请使用以下 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 查询字符串用于所有其他查询。
  • 使用 Database Inspector 查看 Android SQLite 数据库中保存的数据。

13. 了解更多内容

Android 开发者文档

博文

视频

其他文档和文章