使用Room持久保留数据

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

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

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

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

image.png

前提条件

  • 能够使用 Jetpack Compose 为 Android 应用构建基本界面。

  • 能够使用 TextIconIconButtonLazyColumn 等可组合函数。

  • 能够使用 NavHost 可组合函数定义应用中的路线和界面。

  • 能够使用 NavHostController 在界面之间导航。

  • 熟悉 Android 架构组件 ViewModel。能够使用 ViewModelProvider.Factory 实例化 ViewModel。

  • 熟悉并发基础知识。

  • 能够使用协程管理长时间运行的任务。

  • 掌握 SQLite 数据库和 SQL 语言的基础知识。

演示应用概览

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

显示商品目录中商品的手机屏幕
手机屏幕中显示“Add item”界面。
已填写商品详情的手机屏幕。

注意:以上屏幕截图来自本在线课程结束时的最终版应用,而不是此 Codelab 结束时的应用。这些屏幕截图旨在让您对该应用的最终版本有一个大致的概念。

下面请下载起始代码:

file_download下载 ZIP 文件

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

注意:起始代码位于所下载代码库的 starter 分支中。

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

起始代码概览

  1. 在 Android Studio 中打开包含起始代码的项目。

  2. 在 Android 设备或模拟器上运行应用。确保模拟器或连接的设备搭载的是 API 级别 26 或更高版本。 Database Inspector 适用于搭载 API 级别 26 及更高版本的模拟器/设备。

注意:借助 Database Inspector,您可以在应用运行时检查、查询和修改应用的数据库。Database Inspector 可处理普通的 SQLite 数据库或在 SQLite 的基础上构建的库(例如 Room)。

  1. 该应用未显示任何商品目录数据。

  2. 点按悬浮操作按钮 (FAB) 向数据库中添加新商品。

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

显示空白商品目录的手机屏幕
手机屏幕中显示“Add item”界面

起始代码存在的问题

  1. Add Item 界面中,输入商品的详情,例如名称、价格和数量。

  2. 点按 SaveAdd Item 界面未关闭,但您可以使用返回键返回。保存功能未实现,因此系统不会保存商品详情。

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

已填写商品详情的手机屏幕。

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

代码演示

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

ui/home/HomeScreen.kt

此文件是主屏幕,即应用的第一个屏幕,其中包含用于显示商品目录列表的可组合函数。它包含一个 FAB [图片上传失败...(image-f8c2cf-1712916361380)] ,可用于向列表中添加新商品。

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

ui/item/ItemEntryScreen.kt

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

已填写商品详情的手机屏幕

ui/navigation/InventoryNavGraph.kt

Room 的主要组件

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

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

  • Room 实体表示应用数据库中的表。可以使用它们更新表中的行所存储的数据,以及创建要插入的新行。

  • Room DAO 提供了供应用在数据库中检索、更新、插入和删除数据的方法。

  • Room Database 类是一个数据库类,可为应用提供与该数据库关联的 DAO 实例。

下图演示了 Room 的各组件如何协同工作以与数据库交互。

演示 Room 数据访问对象和实体如何与应用其余部分交互的图表

添加 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 注解。

注意:对于 Gradle 文件中的库依赖项,请务必使用 AndroidX 版本页面中最新稳定发布版本的版本号。

创建 item 实体

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

显示实体字段和实体实例的表格

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

在此演示中,将创建一个 Entity 类,并定义字段来存储每个商品的以下商品目录信息:Int 用于存储主键,String 用于存储商品名称,double 用于存储商品价格,Int 用于存储库存数量。

  1. data

  2. 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 类中的类标头的一部分,它跟在类名称(以及可选的类型参数)之后。

数据类

数据类在 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(
   ...
)

注意@Entity 注解有多个可能的参数。默认情况下(@Entity 没有参数),表名称与类名称相同。使用 tableName 参数可自定义表名称。为简单起见,请使用 item@Entity 还有几个其他参数,您可以参阅实体文档进行研究。

  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 设为 true,以便 Room 为每个实体生成一个递增 ID。这样做可以保证每个商品的 ID 都是唯一的。

data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    // ...
)

创建 item DAO

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

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

下面将为 Room 定义一个 DAO。DAO 是 Room 的主要组件,负责定义用于访问数据库的接口。

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

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

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

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

对于当前演示的应用,我们需要能够执行以下操作:

  • 插入或添加新商品。

  • 更新现有商品的名称、价格和数量。

  • 根据主键 id 获取特定商品。

  • 获取所有商品,从而可以显示它们。

  • 删除数据库中的条目。

完成以下步骤,以在实现商品 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)

将商品插入数据库中时,可能会发生冲突。例如,代码中的多个位置尝试使用存在冲突的不同值(比如同一主键)更新实体。实体是数据库中的行。在本演示应用中,我们仅从一处(即 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 注解的函数以删除商品,并将其设为挂起函数。

注意@Delete 注解会删除一个商品或一个商品列表。您需要传递要删除的实体。如果您没有实体,则可能需要在调用 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,也就是说,您只需要显式获取一次数据。此设置有助于更新您将在下一个实现的商品目录。由于返回值类型为 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. 尽管您不会看到任何明显的更改,但您仍应构建应用以确保其没有错误。

创建 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

import android.content.Context

fun getDatabase(context: Context): InventoryDatabase {}

多个线程可能会同时请求数据库实例,导致产生两个数据库,而不是一个。此问题称为竞态条件。封装代码以在 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 }
            }
        }
    }
}

提示:可以将此代码用作未来项目的模板。创建 RoomDatabase 实例的方式与前面步骤中的过程类似。可能必须替换特定于实际的应用的实体和 DAO。

  1. 构建代码以确保没有错误。

实现存储库

实现 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 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 与数据库通信。

添加保存功能

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

界面状态类演示

在项目的<packageName>基础软件包下创建包名和文件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

注意:rememberCoroutineScope() 是一个可组合函数,用于返回绑定到其被调用的组合的 CoroutineScope。如果您想在可组合函数外启动协程,并确保在该作用域退出组合后取消该协程,可以使用 rememberCoroutineScope() 可组合函数。如果您需要手动控制协程的生命周期,例如,在发生用户事件时取消动画,则可以使用此函数。

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. 再次运行应用,然后执行相同的步骤来输入并保存数据。

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

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

使用 Database Inspector 查看数据库内容

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

注意:Database Inspector 只能处理 API 级别 26 及更高版本的 Android 操作系统中所包含的 SQLite 库。它无法处理与您的应用捆绑的其他 SQLite 库。

  1. 在搭载 API 级别 26 或更高版本的模拟器或已连接设备上运行您的应用(如果您尚未这样做)。
  2. 在 Android Studio 中,从菜单栏中依次选择 View > Tool Windows > App Inspection
  3. 选择 Database Inspector 标签页。
  4. Database Inspector 窗格中,从下拉菜单中选择 com.example.inventory(如果尚未选择)。Inventory 应用中的 item_database 将显示于 Databases 窗格中。
  1. Databases 窗格中展开 item_database 的节点,然后选择要检查的 Item。如果 Databases 窗格为空,请使用模拟器通过 Add Item 界面向数据库中添加一些商品。
  2. 选中 Database Inspector 中的 Live updates 复选框,以便随着与模拟器或设备中正在运行的应用互动而自动更新呈现的数据。

获取解决方案代码

此 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 中打开。

file_download下载 ZIP 文件

注意:解决方案代码位于所下载代码库的 room 分支中。

总结

  • 将表定义为带有 @Entity 注解的数据类。将带有 @ColumnInfo 注解的属性定义为表中的列。
  • 将数据访问对象 (DAO) 定义为带有 @Dao 注解的接口。DAO 用于将 Kotlin 函数映射到数据库查询。
  • 使用注解来定义 @Insert@Delete@Update 函数。
  • @Query 注解和作为参数的 SQLite 查询字符串用于所有其他查询。
  • 使用 Database Inspector 查看 Android SQLite 数据库中保存的数据。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容