大多数达到生产质量标准的应用都包含需要持久保留的数据。例如,应用可能会存储歌曲播放列表、待办事项列表中的内容、支出和收入记录、星座目录或个人数据历史记录。对于此类用例,您可以使用数据库来存储这些持久性数据。
Room 是一个持久性库,属于 Android Jetpack 的一部分。Room 是在 SQLite 数据库基础上构建的一个抽象层。SQLite 使用一种专门的语言 (SQL) 来执行数据库操作。Room 并不直接使用 SQLite,而是负责简化数据库设置和配置以及数据库与应用交互方面的琐碎工作。Room 还提供 SQLite 语句的编译时检查。
抽象层是一组隐藏了底层实现/复杂性的函数。抽象层可为现有功能集提供一个接口,就像在本例中使用 SQLite 一样。
下图展示了 Room 作为数据源如何融入本课程中推荐的总体架构。Room 是一个数据源。
image.png前提条件
-
能够使用 Jetpack Compose 为 Android 应用构建基本界面。
-
能够使用
Text
、Icon
、IconButton
和LazyColumn
等可组合函数。 -
能够使用
NavHost
可组合函数定义应用中的路线和界面。 -
能够使用
NavHostController
在界面之间导航。 -
熟悉 Android 架构组件
ViewModel
。能够使用ViewModelProvider.Factory
实例化 ViewModel。 -
熟悉并发基础知识。
-
能够使用协程管理长时间运行的任务。
-
掌握 SQLite 数据库和 SQL 语言的基础知识。
演示应用概览
在此 演示应用中,将使用 Inventory 应用的起始代码,并使用 Room 库向其中添加数据库层。最终版本的应用会显示商品目录数据库中的商品列表。用户可以选择在商品目录数据库中添加新商品、更新现有商品和删除其中的商品。在此 演示中,需要将商品数据保存到 Room 数据库。
显示商品目录中商品的手机屏幕 | 手机屏幕中显示“Add item”界面。 | 已填写商品详情的手机屏幕。 |
---|
注意:以上屏幕截图来自本在线课程结束时的最终版应用,而不是此 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
注意:起始代码位于所下载代码库的 starter
分支中。
可以在 Inventory app
GitHub 代码库中浏览该代码。
起始代码概览
-
在 Android Studio 中打开包含起始代码的项目。
-
在 Android 设备或模拟器上运行应用。确保模拟器或连接的设备搭载的是 API 级别 26 或更高版本。 Database Inspector 适用于搭载 API 级别 26 及更高版本的模拟器/设备。
注意:借助 Database Inspector,您可以在应用运行时检查、查询和修改应用的数据库。Database Inspector 可处理普通的 SQLite 数据库或在 SQLite 的基础上构建的库(例如 Room)。
-
该应用未显示任何商品目录数据。
-
点按悬浮操作按钮 (FAB) 向数据库中添加新商品。
应用会转到一个新界面,可以在其中输入新商品的详情。
显示空白商品目录的手机屏幕 | 手机屏幕中显示“Add item”界面 |
---|---|
起始代码存在的问题
-
在 Add Item 界面中,输入商品的详情,例如名称、价格和数量。
-
点按 Save。Add 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 组件库。
-
打开模块级 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 是一个功能强大且简单易用的 API,用于解析 Kotlin 注解。
⚠注意:对于 Gradle 文件中的库依赖项,请务必使用 AndroidX 版本页面中最新稳定发布版本的版本号。
创建 item 实体
Entity 类定义了一个表,该类的每个实例都表示数据库表中的一行。Entity 类以映射告知 Room 它打算如何呈现数据库中的信息并与之交互。在演示的应用中,实体将保存有关商品目录商品的信息,例如商品名称、商品价格和商品数量。
显示实体字段和实体实例的表格@Entity
注解用于将某个类标记为数据库 Entity 类。对于每个 Entity 类,该应用都会创建一个数据库表来保存这些项。除非另行说明,否则 Entity 的每个字段在数据库中都表示为一列(如需了解详情,请参阅实体文档)。存储在数据库中的每个实体实例都必须有一个主键。主键用于唯一标识数据库表中的每个记录/条目。应用分配主键后,便无法再修改主键;只要主键存在于数据库中,它就会表示实体对象。
在此演示中,将创建一个 Entity 类,并定义字段来存储每个商品的以下商品目录信息:Int
用于存储主键,String
用于存储商品名称,double
用于存储商品价格,Int
用于存储库存数量。
-
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 类中的类标头的一部分,它跟在类名称(以及可选的类型参数)之后。
数据类
数据类在 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(
...
)
⚠注意:
@Entity
注解有多个可能的参数。默认情况下(@Entity
没有参数),表名称与类名称相同。使用tableName
参数可自定义表名称。为简单起见,请使用item
。@Entity
还有几个其他参数,您可以参阅实体文档进行研究。
- 为
id
属性添加@PrimaryKey
注解,使id
成为主键。主键是一个 ID,用于唯一标识Item
表格中的每个记录/条目
import androidx.room.PrimaryKey
@Entity(tableName = "items")
data class Item(
@PrimaryKey
val id: Int,
...
)
-
为
id
分配默认值0
,这样才能使id
自动生成id
值。 -
将参数
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:
- 在
data
软件包中,创建 Kotlin 接口ItemDao.kt
。
- 为接口
ItemDao
添加@Dao
注解。
import androidx.room.Dao
@Dao
interface ItemDao {
}
-
在该接口的主体内添加
@Insert
注解。 -
在
@Insert
下,添加一个insert()
函数,该函数将Entity
类的实例item
作为其参数。 -
使用
suspend
关键字标记函数,使其在单独的线程上运行。
数据库操作的执行可能用时较长,因此需要在单独的线程上运行。Room 不允许在主线程上访问数据库。
import androidx.room.Insert
@Insert
suspend fun insert(item: Item)
将商品插入数据库中时,可能会发生冲突。例如,代码中的多个位置尝试使用存在冲突的不同值(比如同一主键)更新实体。实体是数据库中的行。在本演示应用中,我们仅从一处(即 Add Item 界面)插入实体,因此我们预计不会发生任何冲突,可以将冲突策略设为 Ignore。
- 添加参数
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 查询以将实体插入到数据库中。
- 添加一个带有
@Update
注解的新函数,该函数接受Item
作为参数。
更新的实体与传入的实体具有相同的主键。您可以更新该实体的部分或全部其他属性。
- 与
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 查询。
- 编写一个 SQLite 查询,根据给定
id
从 item 表中检索特定商品。以下代码提供了一个示例查询,该查询从items
中选择所有列,其中id
与特定值匹配,id
是一个唯一标识符。
示例:
// Example, no need to copy over
SELECT * from items WHERE id = 1
-
添加
@Query
注解。 -
使用上一步中的 SQLite 查询作为
@Query
注解的字符串参数。 -
向
@Query
添加一个String
参数,它是用于从 item 表中检索商品的 SQLite 查询。
该查询现在会从 items
中选择所有列,其中 id
与 :id
参数匹配。请注意,:id
在查询中使用英文冒号来引用函数中的参数。
@Query("SELECT * from items WHERE id = :id")
- 在
@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
函数并在协程作用域内进行调用。
-
添加
@Query
注解和getAllItems()
函数。 -
让 SQLite 查询返回
item
表中的所有列,依升序排序。 -
让
getAllItems()
返回Item
实体的列表作为Flow
。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>>
}
- 尽管您不会看到任何明显的更改,但您仍应构建应用以确保其没有错误。
创建 Database 实例
创建一个 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
。每当您更改数据库表的架构时,都必须提升版本号。 -
将
exportSchema
设为false
,这样就不会保留架构版本记录的备份。
@Database(entities = [Item::class], version = 1, exportSchema = false)
- 在类的主体内,声明一个返回
ItemDao
的抽象函数,以便数据库了解 DAO。
abstract fun itemDao(): ItemDao
- 在抽象函数下方,定义一个
companion object
,以允许访问用于创建或获取数据库的方法,并将类名称用作限定符。
companion object {}
- 在
companion
对象内,为数据库声明一个私有的可为 null 变量Instance
,并将其初始化为null
。
Instance
变量将在数据库创建后保留对数据库的引用。这有助于保持在任意时间点都只有一个打开的数据库实例,因为这种资源的创建和维护成本极高。
- 为
Instance
添加@Volatile
注解。
volatile 变量的值绝不会缓存,所有读写操作都将在主内存中完成。这些功能有助于确保 Instance
的值始终是最新的,并且对所有执行线程都相同。也就是说,一个线程对 Instance
所做的更改会立即对所有其他线程可见。
@Volatile
private var Instance: InventoryDatabase? = null
-
在
Instance
下但仍在companion
对象内,定义getDatabase()
方法并提供数据库构建器所需的Context
参数。 -
返回类型
InventoryDatabase
。
import android.content.Context
fun getDatabase(context: Context): InventoryDatabase {}
多个线程可能会同时请求数据库实例,导致产生两个数据库,而不是一个。此问题称为竞态条件。封装代码以在 synchronized
块内获取数据库意味着一次只有一个执行线程可以进入此代码块,从而确保数据库仅初始化一次。
-
在
getDatabase()
内,返回Instance
变量;如果Instance
为 null 值,请在synchronized{}
块内对其进行初始化。请使用 elvis 运算符 (?:
) 执行此操作。 -
传入伴生对象
this
。您将在后续步骤中修复该错误。
return Instance ?: synchronized(this) { }
- 在同步的代码块内,使用数据库构建器获取数据库。继续忽略错误,您将在后续步骤中修复这些错误。
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 }
}
}
}
}
提示:可以将此代码用作未来项目的模板。创建
RoomDatabase
实例的方式与前面步骤中的过程类似。可能必须替换特定于实际的应用的实体和 DAO。
- 构建代码以确保没有错误。
实现存储库
实现 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 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
与数据库通信。
添加保存功能
到目前为止,已经创建了一个数据库,而界面类是起始代码的一部分。为了保存应用的瞬态数据,同时也为了访问数据库,需要创建 ViewModel
。 ViewModel
通过 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 界面中输入的商品详情保存到数据库。
- 请注意
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
并更新商品条目视图模型的initializer
,并将仓库实例作为参数传入。
object AppViewModelProvider {
val Factory = viewModelFactory {
// Other Initializers
// Initializer for ItemEntryViewModel
initializer {
ItemEntryViewModel(inventoryApplication().container.itemsRepository)
}
//...
}
}
- 转到
ItemEntryViewModel.kt
文件,在ItemEntryViewModel
类的末尾添加一个名为saveItem()
的挂起函数,以将一个商品插入 Room 数据库中。此函数以非阻塞方式将数据添加到数据库。
suspend fun saveItem() {
}
- 在该函数内,检查
itemUiState
是否有效并将其转换为Item
类型,以便 Room 可以理解数据。 - 对
itemsRepository
调用insertItem()
并传入数据。界面会调用此函数,以将商品详情添加到数据库。
suspend fun saveItem() {
if (validateInput()) {
itemsRepository.insertItem(itemUiState.itemDetails.toItem())
}
}
现在,向数据库添加实体所需的函数已全部添加。下面将更新界面以使用上述函数。
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()
)
- 请注意,界面状态和
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))
}
}
}
您在此可组合函数中显示了 ItemInputForm
和 Save 按钮。在 ItemInputForm()
可组合函数中,您显示了三个文本字段。只有在文本字段中输入文本后,系统才会启用 Save 按钮。如果所有文本字段中的文本均有效(非空),则 isEntryValid
值为 true。
手机屏幕显示:部分商品详情已自动填充,“Save”按钮已停用 | 手机屏幕显示:商品详情已填充,“Save”按钮已启用 |
---|---|
- 查看
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
。
注意:rememberCoroutineScope()
是一个可组合函数,用于返回绑定到其被调用的组合的 CoroutineScope
。如果您想在可组合函数外启动协程,并确保在该作用域退出组合后取消该协程,可以使用 rememberCoroutineScope()
可组合函数。如果您需要手动控制协程的生命周期,例如,在发生用户事件时取消动画,则可以使用此函数。
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()
可将该商品保存在数据库中。
ItemEntryBody(
// ...
onSaveClick = {
coroutineScope.launch {
viewModel.saveItem()
}
},
//...
)
请注意,没有在 ItemEntryViewModel.kt
文件中为 saveItem()
使用 viewModelScope.launch()
,但在调用存储库方法时,ItemEntryBody``()
需要使用该函数。您只能从协程或其他挂起函数调用挂起函数。函数 viewModel.saveItem()
就是一个挂起函数。
- 构建并运行您的应用。
- 点按 + FAB。
- 在 Add 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)
)
- 再次运行应用,然后执行相同的步骤来输入并保存数据。
此操作会保存数据,但您在应用中看不到商品目录数据。下面将使用 Database Inspector 查看已保存的数据。
显示空白商品目录清单的应用屏幕使用 Database Inspector 查看数据库内容
借助 Database Inspector,可以在应用运行时检查、查询和修改应用的数据库。此功能对于数据库调试尤为有用。Database Inspector 可处理普通的 SQLite 数据库以及在 SQLite 的基础上构建的库(例如 Room)。Database Inspector 在搭载 API 级别 26 的模拟器/设备上使用效果最佳。
注意:Database Inspector 只能处理 API 级别 26 及更高版本的 Android 操作系统中所包含的 SQLite 库。它无法处理与您的应用捆绑的其他 SQLite 库。
- 在搭载 API 级别 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 界面向数据库中添加一些商品。
- 选中 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 中打开。
⚠注意:解决方案代码位于所下载代码库的
room
分支中。
总结
- 将表定义为带有
@Entity
注解的数据类。将带有@ColumnInfo
注解的属性定义为表中的列。 - 将数据访问对象 (DAO) 定义为带有
@Dao
注解的接口。DAO 用于将 Kotlin 函数映射到数据库查询。 - 使用注解来定义
@Insert
、@Delete
和@Update
函数。 - 将
@Query
注解和作为参数的 SQLite 查询字符串用于所有其他查询。 - 使用 Database Inspector 查看 Android SQLite 数据库中保存的数据。
网友评论