1. 准备工作
大多数生产级应用都需要持久化数据。例如,应用可能需要存储歌曲播放列表、待办事项列表中的项目、支出和收入记录、星座目录或个人数据历史记录。对于此类用例,您可以使用数据库来存储这些持久性数据。
Room 是 Android Jetpack 的一部分,是一个持久性库。Room 是一个构建在 SQLite 数据库之上的抽象层。SQLite 使用一种专门的语言 (SQL) 执行数据库操作。Room 简化了数据库设置、配置以及与应用交互的繁琐任务,而不是直接使用 SQLite。Room 还提供 SQLite 语句的编译时检查。
一个抽象层是一组隐藏底层实现/复杂性的函数。它为现有功能集(在本例中为 SQLite)提供接口。
下图展示了 Room 作为数据源如何融入本课程推荐的整体架构。Room 是一个数据源 (Data Source)。
前提条件
- 能够使用 Jetpack Compose 为 Android 应用构建基本用户界面 (UI)。
- 能够使用
Text
、Icon
、IconButton
和LazyColumn
等可组合项。 - 能够使用
NavHost
可组合项定义应用中的路由和屏幕。 - 能够使用
NavHostController
在屏幕之间导航。 - 熟悉 Android 架构组件
ViewModel
。能够使用ViewModelProvider.Factory
实例化 ViewModel。 - 熟悉并发基础知识。
- 能够使用协程执行耗时任务。
- 具备 SQLite 数据库和 SQL 语言的基础知识。
您将学到什么
- 如何使用 Room 库创建 SQLite 数据库并与之交互。
- 如何创建实体 (entity)、数据访问对象 (DAO) 和数据库类。
- 如何使用 DAO 将 Kotlin 函数映射到 SQL 查询。
您将构建什么
- 您将构建一个 **Inventory** 应用,将库存 Item 保存到 SQLite 数据库中。
您需要什么
- **Inventory** 应用的启动器代码
- 一台安装了 Android Studio 的计算机
- API 级别 26 或更高版本的设备或模拟器
2. 应用概览
在此 Codelab 中,您将使用 Inventory 应用的启动器代码,并使用 Room 库向其中添加数据库层。应用的最终版本将显示库存数据库中的 Item 列表。用户可以选择添加新 Item、更新现有 Item 以及从库存数据库中删除 Item。在本 Codelab 中,您将 Item 数据保存到 Room 数据库中。您将在下一个 Codelab 中完成应用的其余功能。
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 仓库中浏览代码。
启动器代码概览
- 在 Android Studio 中打开包含启动器代码的项目。
- 在 Android 设备或模拟器上运行应用。确保模拟器或连接的设备的 API 级别为 26 或更高。 Database Inspector 适用于运行 API 级别 26 及更高版本的模拟器/设备。
- 请注意,应用未显示任何库存数据。
- 轻触浮动操作按钮 (FAB),该按钮可让您向数据库中添加新 Item。
应用会导航到一个新屏幕,您可以在其中输入新 Item 的详细信息。
启动器代码存在的问题
- 在 **Add Item** 屏幕中,输入 Item 的详细信息,例如 Item 的名称、价格和数量。
- 轻触 **Save**。**Add Item** 屏幕不会关闭,但您可以使用返回键返回。保存功能尚未实现,因此 Item 详细信息未保存。
请注意,应用不完整,并且 **Save** 按钮的功能尚未实现。
在此 Codelab 中,您将添加使用 Room 将库存详细信息保存到 SQLite 数据库中的代码。您将使用 Room 持久性库与 SQLite 数据库进行交互。
代码演练
您下载的启动器代码为您预先设计了屏幕布局。在此路径中,您将重点实现数据库逻辑。以下部分简要介绍了其中一些文件,以帮助您入门。
ui/home/HomeScreen.kt
此文件是主屏幕,或应用中的第一个屏幕,其中包含用于显示库存列表的可组合项。它有一个 FAB 用于向列表中添加新 Item。您稍后将在路径中显示列表中的 Item。
ui/item/ItemEntryScreen.kt
此屏幕类似于 ItemEditScreen.kt
。它们都有用于 Item 详细信息的文本字段。当在主屏幕中轻触 FAB 时,将显示此屏幕。ItemEntryViewModel.kt
是此屏幕对应的 ViewModel
。
ui/navigation/InventoryNavGraph.kt
此文件是整个应用的导航图。
4. Room 的主要组件
Kotlin 提供了一种通过数据类轻松使用数据的方法。虽然使用数据类处理内存中的数据很容易,但如果要持久化数据,则需要将数据转换为与数据库存储兼容的格式。为此,您需要表来存储数据,以及查询来访问和修改数据。
Room 的以下三个组件使得这些工作流程变得无缝。
- Room 实体代表你应用的数据库中的表格。你使用它们来更新存储在表格行中的数据,并创建新的行用于插入。
- Room DAO (数据访问对象) 提供应用用于在数据库中检索、更新、插入和删除数据的方法。
- Room 数据库类 (Database class) 是为应用提供与该数据库关联的 DAO 实例的数据库类。
您稍后将在 Codelab 中实现并详细了解这些组件。下图演示了 Room 的组件如何协同工作以与数据库交互。
添加 Room 依赖项
在此任务中,您将所需的 Room 组件库添加到 Gradle 文件中。
- 打开模块级 Gradle 文件
build.gradle.kts (Module: InventoryApp.app)
。 - 在
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. 创建 Item 实体
一个实体 (Entity) 类定义一个表,此类的每个实例表示数据库表中的一行。实体类具有映射,用于告诉 Room 如何呈现和与其在数据库中的信息交互。在您的应用中,实体包含有关库存 Item 的信息,例如 Item 名称、Item 价格和可用 Item 的数量。
@Entity
注解将类标记为数据库实体类。对于每个实体类,应用都会创建一个数据库表来存放 Item。除非另有说明(有关详细信息,请参阅 Entity 文档),否则实体的每个字段都表示数据库中的一个列。存储在数据库中的每个实体实例都必须有一个主键。主键 (primary key) 用于唯一标识数据库表中的每个记录/条目。应用分配主键后,它就无法修改;它代表实体对象,只要它存在于数据库中。
在此任务中,您将创建一个实体类并定义字段来存储每个 Item 的以下库存信息:一个 Int
用于存储主键,一个 String
用于存储 Item 名称,一个 double
用于存储 Item 价格,以及一个 Int
用于存储库存数量。
- 在 Android Studio 中打开启动器代码。
- 打开基础包
com.example.inventory
下的data
包。 - 在
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){
}
为了确保生成代码的一致性和有意义的行为,数据类必须满足以下要求
- 主构造函数必须至少有一个参数。
- 所有主构造函数参数必须是
val
或var
。 - 数据类不能是
abstract
、open
或sealed
。
要了解有关数据类的更多信息,请查看数据类文档。
- 在
Item
类定义前加上data
关键字,将其转换为数据类。
data class Item(
val id: Int,
val name: String,
val price: Double,
val quantity: Int
)
- 在
Item
类声明上方,使用@Entity
注解数据类。使用tableName
参数将items
设置为 SQLite 表名。
import androidx.room.Entity
@Entity(tableName = "items")
data class Item(
...
)
- 使用
@PrimaryKey
注解id
属性,将id
设为主键。主键是唯一标识Item
表中每个记录/条目的 ID
import androidx.room.PrimaryKey
@Entity(tableName = "items")
data class Item(
@PrimaryKey
val id: Int,
...
)
- 将
id
赋一个默认值0
,这是id
自动生成id
值所必需的。 - 向
@PrimaryKey
注解添加autoGenerate
参数,指定主键列是否应自动生成。如果autoGenerate
设置为true
,则当新实体实例插入数据库时,Room 将自动为主键列生成唯一值。这可确保每个实体实例都具有唯一的标识符,而无需手动为 primary key 列赋值
data class Item(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
// ...
)
太棒了!现在您已经创建了一个实体类,您可以创建一个数据访问对象 (DAO) 来访问数据库。
6. 创建 Item DAO
数据访问对象 (DAO) 是一种模式,您可以使用它通过提供抽象接口将持久层与应用的其他部分分离。这种隔离遵循了您在之前的 Codelab 中看到的单一职责原则。
DAO 的功能是隐藏底层持久层中执行数据库操作涉及的所有复杂性,将其与应用的其他部分分离。这使您可以独立于使用数据的代码更改数据层。
在此任务中,您将为 Room 定义一个 DAO。DAO 是 Room 的主要组件,负责定义访问数据库的接口。
您创建的 DAO 是一个自定义接口,它提供了用于查询/检索、插入、删除和更新数据库的便利方法。Room 在编译时生成此类的实现。
Room
库提供了便利注解,例如 @Insert
、@Delete
和 @Update
,用于定义执行简单插入、删除和更新的方法,而无需编写 SQL 语句。
如果需要为插入、删除、更新定义更复杂的操作,或者需要查询数据库中的数据,请使用 @Query
注解。
额外的好处是,当您在 Android Studio 中编写查询时,编译器会检查您的 SQL 查询是否存在语法错误。
对于 Inventory 应用,您需要以下功能
- **插入**或添加新 Item。
- **更新**现有 Item 以更新名称、价格和数量。
- 根据 Item 的主键
id
**获取**特定 Item。 - **获取所有 Item**,以便您显示它们。
- **删除**数据库中的条目。
完成以下步骤以在应用中实现 Item DAO
- 在
data
包中,创建 Kotlin 接口ItemDao.kt
。
- 使用
@Dao
注解ItemDao
接口。
import androidx.room.Dao
@Dao
interface ItemDao {
}
- 在接口的主体中,添加一个
@Insert
注解。 - 在
@Insert
下方,添加一个insert()
函数,该函数将Entity
类item
的实例作为其参数。 - 使用
suspend
关键字标记该函数,以便它可以在单独的线程上运行。
数据库操作可能需要很长时间才能执行,因此需要运行在单独的线程上。Room 不允许在主线程上进行数据库访问。
import androidx.room.Insert
@Insert
suspend fun insert(item: Item)
将 Item 插入数据库时,可能会发生冲突。例如,代码中的多个位置尝试使用不同的冲突值(例如相同的主键)更新实体。实体是数据库中的一行。在 Inventory 应用中,我们只从一个地方(即 **Add Item** 屏幕)插入实体,因此我们预计不会发生冲突,可以将冲突策略设置为 *Ignore*。
- 添加参数
onConflict
,并为其赋一个值OnConflictStrategy.
*IGNORE
*。
参数 onConflict
告诉 Room 在发生冲突时做什么。OnConflictStrategy.
*IGNORE
* 策略会忽略新 Item。
要详细了解可用的冲突策略,请查看 OnConflictStrategy
文档。
import androidx.room.OnConflictStrategy
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(item: Item)
现在 Room
会生成将 item
插入数据库所需的所有代码。当您调用任何标记有 Room 注解的 DAO 函数时,Room 会在数据库上执行相应的 SQL 查询。例如,当您从 Kotlin 代码中调用上述方法 insert()
时,Room
会执行 SQL 查询将实体插入数据库。
- 添加一个带
@Update
注解的新函数,该函数将一个Item
作为参数。
被更新的实体与传入的实体具有相同的主键。您可以更新实体部分或全部的其他属性。
- 与
insert()
方法类似,使用suspend
关键字标记此函数。
import androidx.room.Update
@Update
suspend fun update(item: Item)
添加另一个带 @Delete
注解的函数来删除 Item,并将其设为 suspending 函数。
import androidx.room.Delete
@Delete
suspend fun delete(item: Item)
其余功能没有便利注解,因此您必须使用 @Query
注解并提供 SQLite 查询。
- 编写 SQLite 查询,根据给定的
id
从 Item 表中检索特定 Item。以下代码提供了示例查询,该查询从items
中选择所有列,其中id
与特定值匹配,并且id
是一个唯一的标识符。
示例
// Example, no need to copy over
SELECT * from items WHERE id = 1
- 添加 @Query 注解。
- 将上一步中的 SQLite 查询作为字符串参数传递给
@Query
注解。 - 向
@Query
添加一个String
参数,该参数是一个 SQLite 查询,用于从 Item 表中检索 Item。
该查询现在表示从 items
中选择所有列,其中 id
与 :id
参数匹配。注意查询中的冒号表示法 :id
用于引用函数中的参数。
@Query("SELECT * from items WHERE id = :id")
- 在
@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
的最新状态,这意味着您只需要显式获取一次数据。此设置有助于更新库存列表,您将在下一个 Codelab 中实现此功能。由于返回类型是 Flow
,Room 也会在后台线程上运行查询。您无需明确将其设为 suspend
函数并在协程作用域内调用它。
- 添加一个带有
getAllItems()
函数的@Query
。 - 让 SQLite 查询返回 Item 表中的所有列,按升序排列。
- 让
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>>
}
- 虽然您看不到任何可见更改,但请构建您的应用以确保它没有错误。
7. 创建数据库实例
在此任务中,您将创建一个 RoomDatabase
,它使用您在之前任务中创建的 Entity
和 DAO。数据库类定义了实体和 DAO 的列表。
Database
类为您的应用提供了您定义的 DAO 的实例。反过来,应用可以使用 DAO 从数据库中检索数据,这些数据是关联数据实体对象的实例。应用还可以使用定义的数据实体更新相应表中的行,或创建新行以供插入。
您需要创建一个抽象 RoomDatabase
类并使用 @Database
对其进行注解。这个类有一个方法,如果数据库不存在,该方法将返回 RoomDatabase
的现有实例。
以下是获取 RoomDatabase
实例的一般过程
- 创建一个继承
RoomDatabase
的public abstract
类。您定义的新抽象类充当数据库持有者。您定义的类是抽象的,因为Room
会为您创建实现。 - 使用
@Database
注解该类。在参数中,列出数据库的实体并设置版本号。 - 定义一个抽象方法或属性,该方法或属性返回
ItemDao
实例,Room
会为您生成实现。 - 整个应用只需要一个
RoomDatabase
实例,所以将RoomDatabase
设为单例。 - 使用
Room
的Room.databaseBuilder
仅当数据库不存在时才创建您的 (item_database
) 数据库。否则,返回现有数据库。
创建数据库
- 在
data
包中,创建 Kotlin 类InventoryDatabase.kt
。 - 在
InventoryDatabase.kt
文件中,将InventoryDatabase
类设为继承RoomDatabase
的abstract
类。 - 使用
@Database
注解该类。忽略缺少的参数错误,您将在下一步中修复此错误。
import androidx.room.Database
import androidx.room.RoomDatabase
@Database
abstract class InventoryDatabase : RoomDatabase() {}
@Database
注解需要几个参数,以便 Room
可以构建数据库。
- 将
Item
指定为entities
列表中的唯一类。 - 将
version
设置为1
。无论何时更改数据库表的 schema,都必须增加版本号。 - 将
exportSchema
设置为false
,以便不保留 schema 版本历史备份。
@Database(entities = [Item::class], version = 1, exportSchema = false)
- 在类的主体中,声明一个抽象函数,该函数返回
ItemDao
,以便数据库知道 DAO。
abstract fun itemDao(): ItemDao
- 在抽象函数下方,定义一个
companion object
,它允许访问创建或获取数据库的方法,并使用类名作为限定符。
companion object {}
- 在
companion
对象中,声明一个用于数据库的私有可空变量Instance
,并将其初始化为null
。
Instance
变量保留对已创建的数据库的引用。这有助于在给定时间维护数据库的单个实例打开,这是一个昂贵且维护成本高的资源。
- 使用
@Volatile
注解Instance
。
volatile 变量的值永远不会被缓存,所有读写操作都是针对主内存进行的。这些特性有助于确保 Instance
的值始终是最新的,并且对所有执行线程都相同。这意味着一个线程对 Instance
所做的更改会立即对所有其他线程可见。
@Volatile
private var Instance: InventoryDatabase? = null
- 在
Instance
下方,仍在companion
对象中,定义一个带Context
参数的getDatabase()
方法,这是数据库构建器所需的。 - 返回类型为
InventoryDatabase
。出现错误消息是因为getDatabase()
尚未返回任何内容。
import android.content.Context
fun getDatabase(context: Context): InventoryDatabase {}
多个线程可能同时请求数据库实例,这会导致两个数据库而不是一个。这个问题称为竞态条件。将获取数据库的代码包装在 synchronized
代码块中意味着一次只有一个执行线程可以进入此代码块,这确保数据库只初始化一次。使用 synchronized{}
代码块来避免竞态条件。
- 在
getDatabase()
中,返回Instance
变量,或者如果Instance
为 null,则在synchronized{}
代码块中对其进行初始化。使用 Elvis 运算符 (?:
) 来执行此操作。 - 传入
this
,即 companion object。您将在后续步骤中修复错误。
return Instance ?: synchronized(this) { }
- 在 synchronized 代码块内部,使用数据库构建器获取数据库。继续忽略错误,您将在后续步骤中修复这些错误。
import androidx.room.Room
Room.databaseBuilder()
- 在
synchronized
代码块中,使用数据库构建器获取数据库。将应用上下文、数据库类和数据库名称item_database
传递给Room.databaseBuilder()
。
Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")
Android Studio 会生成类型不匹配错误。要移除此错误,您必须在以下步骤中添加 build()
。
- 将所需的迁移策略添加到构建器中。使用
.
fallbackToDestructiveMigration()
。
.fallbackToDestructiveMigration()
- 要创建数据库实例,调用
.build()
。此调用会移除 Android Studio 错误。
.build()
- 在
build()
之后,添加一个also
代码块,并将Instance = it
赋值以保留对最近创建的数据库实例的引用。
.also { Instance = it }
- 在
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 }
}
}
}
}
- 构建您的代码,确保没有错误。
8. 实现 Repository
在此任务中,您将实现 ItemsRepository
接口和 OfflineItemsRepository
类,以提供从数据库中get
、insert
、delete
和 update
实体的方法。
- 打开
data
包下的ItemsRepository.kt
文件。 - 向接口添加以下函数,这些函数映射到 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)
}
- 打开
data
包下的OfflineItemsRepository.kt
文件。 - 传入类型为
ItemDao
的构造函数参数。
class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository
- 在
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
类。
- 打开
data
包下的AppContainer.kt
文件。 - 将
ItemDao()
实例传递给OfflineItemsRepository
构造函数。 - 通过调用
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 state 类演练
打开 ui/item/ItemEntryViewModel.kt
文件。ItemUiState
数据类表示 Item 的 UI state。ItemDetails
数据类表示一个 Item。
启动器代码为您提供了三个扩展函数
ItemDetails.toItem()
扩展函数将ItemUiState
UI state 对象转换为Item
实体类型。Item.toItemUiState()
扩展函数将Item
Room 实体对象转换为ItemUiState
UI state 类型。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
文件。您还将保存在 **Add Item** 屏幕中输入的 Item 详细信息到数据库中。
- 注意
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()
}
}
上述函数检查 name
、price
和 quantity
是否为空。您将使用此函数在将实体添加到数据库或更新实体之前验证用户输入。
- 打开
ItemEntryViewModel
类,并添加一个类型为ItemsRepository
的private
默认构造函数参数。
import com.example.inventory.data.ItemsRepository
class ItemEntryViewModel(private val itemsRepository: ItemsRepository) : ViewModel() {
}
- 更新
ui/AppViewModelProvider.kt
中 Item entry 视图模型的initializer
,并传入存储库实例作为参数。
object AppViewModelProvider {
val Factory = viewModelFactory {
// Other Initializers
// Initializer for ItemEntryViewModel
initializer {
ItemEntryViewModel(inventoryApplication().container.itemsRepository)
}
//...
}
}
- 转到
ItemEntryViewModel.kt
文件,并在ItemEntryViewModel
类的末尾添加一个名为saveItem()
的 suspend 函数,用于将 Item 插入 Room 数据库。此函数以非阻塞方式将数据添加到数据库中。
suspend fun saveItem() {
}
- 在函数内部,检查
itemUiState
是否有效,并将其转换为Item
类型,以便 Room 能够理解数据。 - 在
itemsRepository
上调用insertItem()
并传入数据。UI 调用此函数将 Item 详细信息添加到数据库。
suspend fun saveItem() {
if (validateInput()) {
itemsRepository.insertItem(itemUiState.itemDetails.toItem())
}
}
您现在已经添加了将实体添加到数据库所需的所有函数。在下一个任务中,您将更新 UI 以使用上述函数。
ItemEntryBody() 可组合函数演练
- 在
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()
)
- 注意,UI state 和
updateUiState
lambda 作为函数参数传入。查看函数定义以了解 UI state 如何更新。
// 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
和一个 **Save** 按钮。在 ItemInputForm()
可组合项中,您显示三个文本字段。仅当文本字段中输入文本时,**Save** 按钮才启用。如果所有文本字段中的文本都有效(不为空),则 *isEntryValid
* 值为 true。
- 查看
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 数据库中。
- 在
ItemEntryScreen.kt
中,在ItemEntryScreen
可组合函数内部,使用rememberCoroutineScope()
可组合函数创建一个名为coroutineScope
的val
。
import androidx.compose.runtime.rememberCoroutineScope
val coroutineScope = rememberCoroutineScope()
- 更新 *
ItemEntryBody
*()
函数调用,并在onSaveClick
lambda 中启动一个协程。
ItemEntryBody(
// ...
onSaveClick = {
coroutineScope.launch {
}
},
modifier = modifier.padding(innerPadding)
)
- 查看
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())
}
}
- 在
ItemEntryScreen.kt
中,在ItemEntryScreen
可组合函数内部,在协程中,调用viewModel.saveItem()
将 Item 保存到数据库中。
ItemEntryBody(
// ...
onSaveClick = {
coroutineScope.launch {
viewModel.saveItem()
}
},
//...
)
注意,您没有在 ItemEntryViewModel.kt
文件中为 saveItem()
使用 viewModelScope.launch()
,但在您调用存储库方法时,对于 *ItemEntryBody
*()
是必需的。您只能从协程或另一个 suspend 函数中调用 suspend 函数。viewModel.saveItem()
函数是一个 suspend 函数。
- 构建并运行应用。
- 点击 + FAB。
- 在 **Add Item** 屏幕中,添加 Item 详细信息并轻触 **Save**。请注意,轻触 **Save** 按钮不会关闭 **Add Item** 屏幕。
- 在
onSaveClick
lambda 中,在调用viewModel.saveItem()
后添加对navigateBack()
的调用,以返回上一屏幕。您的ItemEntryBody()
函数如下所示
ItemEntryBody(
itemUiState = viewModel.itemUiState,
onItemValueChange = viewModel::updateUiState,
onSaveClick = {
coroutineScope.launch {
viewModel.saveItem()
navigateBack()
}
},
modifier = modifier.padding(innerPadding)
)
- 再次运行应用,执行相同的步骤输入并保存数据。请注意,这次应用会返回 **Inventory** 屏幕。
此操作会保存数据,但您无法在应用中看到库存数据。在下一个任务中,您将使用 Database Inspector 查看您保存的数据。
10. 使用 Database Inspector 查看数据库内容
Database Inspector 可让您在应用运行时检查、查询和修改应用的数据库。此功能对于数据库调试特别有用。Database Inspector 适用于普通 SQLite 和构建在 SQLite 之上的库,例如 Room。Database Inspector 在运行 API level 26 的模拟器/设备上效果最佳。
- 如果在模拟器或连接的设备(API level 26 或更高版本)上运行应用,请确保已这样做。
- 在 Android Studio 中,从菜单栏中选择 **View** > **Tool Windows** > **App Inspection**。
- 选择 **Database Inspector** 标签页。
- 在 **Database Inspector** 面板中,如果尚未选择
com.example.inventory
,请从下拉菜单中选择它。Inventory 应用中的 **item_database** 会显示在 **Databases** 面板中。
- 展开 **Databases** 面板中 **item_database** 的节点,然后选择 **Item** 进行检查。如果您的 **Databases** 面板为空,请使用模拟器通过 **Add Item** 屏幕向数据库中添加一些 Item。
- 勾选 Database Inspector 中的 **Live updates** 复选框,以便在您与模拟器或设备中运行的应用交互时自动更新其显示的数据。
恭喜!您已使用 Room 创建了一个能够持久化数据的应用。在下一个 Codelab 中,您将在应用中添加一个 lazyColumn
以在数据库中显示 Item,并向应用添加新功能,例如删除和更新实体的功能。下次再见!
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 开发者文档
博文
视频
其他文档和文章