1. 开始之前
在之前的 Codelab 中,您学习了如何使用 Room 持久性库(SQLite 数据库之上的一个抽象层)来存储应用数据。在本 Codelab 中,您将为 Inventory 应用添加更多功能,并学习如何使用 Room 从 SQLite 数据库中读取、显示、更新和删除数据。您将使用 LazyColumn
来显示数据库中的数据,并在数据库中的底层数据发生变化时自动更新数据。
前提条件
- 能够使用 Room 库创建 SQLite 数据库并与之交互。
- 能够创建实体、DAO 和数据库类。
- 能够使用数据访问对象 (DAO) 将 Kotlin 函数映射到 SQL 查询。
- 能够在
LazyColumn
中显示列表项。 - 完成本单元中的前一个 Codelab,使用 Room 持久化数据。
您将学到什么
- 如何从 SQLite 数据库中读取和显示实体。
- 如何使用 Room 库从 SQLite 数据库中更新和删除实体。
您将构建什么
- 一个 Inventory 应用,用于显示库存物品列表,并可以使用 Room 从应用数据库中更新、编辑和删除物品。
您需要什么
- 一台装有 Android Studio 的计算机
2. 启动应用概览
本 Codelab 使用前一个 Codelab 使用 Room 持久化数据 的 Inventory 应用解决方案代码作为启动代码。启动应用已使用 Room 持久性库保存数据。用户可以使用添加物品屏幕将数据添加到应用数据库中。
在此 Codelab 中,您将扩展应用以使用 Room 库读取和显示数据,以及更新和删除数据库中的实体。
下载本 Codelab 的启动代码
要开始,请下载启动代码
$ 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 上查看。
3. 更新 UI 状态
在此任务中,您将向应用添加一个 LazyColumn
以显示存储在数据库中的数据。
HomeScreen 可组合函数演练
- 打开
ui/home/HomeScreen.kt
文件并查看HomeScreen()
可组合项。
@Composable
fun HomeScreen(
navigateToItemEntry: () -> Unit,
navigateToItemUpdate: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
topBar = {
// Top app with app title
},
floatingActionButton = {
FloatingActionButton(
// onClick details
) {
Icon(
// Icon details
)
}
},
) { innerPadding ->
// Display List header and List of Items
HomeBody(
itemList = listOf(), // Empty list is being passed in for itemList
onItemClick = navigateToItemUpdate,
modifier = modifier.padding(innerPadding)
.fillMaxSize()
)
}
此可组合函数显示以下项目
- 带有应用标题的顶部应用栏
- 用于向库存添加新项目的浮动操作按钮 (FAB)
HomeBody()
可组合函数
HomeBody()
可组合函数根据传入的列表显示库存项目。作为入门代码实现的一部分,会将一个空列表 (listOf()
) 传递给 HomeBody()
可组合函数。要将库存列表传递给此可组合项,您必须从仓库检索库存数据,并将其传入 HomeViewModel
。
在 HomeViewModel
中发出 UI 状态
当您向 ItemDao
添加用于获取项目的方法 getItem()
和 getAllItems()
时,您指定了 Flow
作为返回类型。回想一下,Flow
表示通用数据流。通过返回 Flow
,您只需在给定生命周期内显式调用 DAO 中的方法一次。Room 会以异步方式处理底层数据的更新。
从 flow 中获取数据称为从 flow 中收集数据。在 UI 层从 flow 中收集数据时,需要考虑以下几点。
- 生命周期事件(例如配置变更,如旋转设备)会导致 Activity 重建。这会导致重新组合,并再次从
Flow
中收集数据。 - 您希望将值作为状态缓存起来,以便现有数据不会在生命周期事件之间丢失。
- 如果不再有观察者,例如可组合项的生命周期结束之后,应取消 Flow。
从 ViewModel
中公开 Flow
的推荐方法是使用 StateFlow
。使用 StateFlow
可以保存和观察数据,而无论 UI 生命周期的如何。要将 Flow
转换为 StateFlow
,请使用 stateIn
运算符。
stateIn
运算符有三个参数,如下所示
scope
-viewModelScope
定义StateFlow
的生命周期。当viewModelScope
被取消时,StateFlow
也会被取消。started
- 只有当 UI 可见时,管道才应处于活动状态。SharingStarted.WhileSubscribed()
用于实现此目的。要配置上次订阅者消失和共享协程停止之间的延迟(以毫秒为单位),请将TIMEOUT_MILLIS
传入SharingStarted.WhileSubscribed()
方法。initialValue
- 将状态流的初始值设为HomeUiState()
。
将 Flow
转换为 StateFlow
后,您可以使用 collectAsState()
方法收集它,将其数据转换为相同类型的 State
。
在此步骤中,您将检索 Room 数据库中的所有项目,将其作为 UI 状态的 StateFlow
可观察 API。当 Room Inventory 数据更改时,UI 会自动更新。
- 打开
ui/home/HomeViewModel.kt
文件,其中包含TIMEOUT_MILLIS
常量和一个HomeUiState
数据类,其中包含一个项目列表作为构造函数参数。
// No need to copy over, this code is part of starter code
class HomeViewModel : ViewModel() {
companion object {
private const val TIMEOUT_MILLIS = 5_000L
}
}
data class HomeUiState(val itemList: List<Item> = listOf())
- 在
HomeViewModel
类中,声明一个类型为StateFlow<HomeUiState>
的val
,名为homeUiState
。您很快就会解决初始化错误。
val homeUiState: StateFlow<HomeUiState>
- 在
itemsRepository
上调用getAllItemsStream()
,并将其分配给您刚声明的homeUiState
。
val homeUiState: StateFlow<HomeUiState> =
itemsRepository.getAllItemsStream()
您现在会遇到错误 - 未解析的引用:itemsRepository。要解决此未解析的引用错误,您需要将 ItemsRepository
对象传入 HomeViewModel
。
- 向
HomeViewModel
类添加一个类型为ItemsRepository
的构造函数参数。
import com.example.inventory.data.ItemsRepository
class HomeViewModel(itemsRepository: ItemsRepository): ViewModel() {
- 在
ui/AppViewModelProvider.kt
文件中,在HomeViewModel
初始化程序中,传入ItemsRepository
对象,如所示。
initializer {
HomeViewModel(inventoryApplication().container.itemsRepository)
}
- 回到
HomeViewModel.kt
文件。请注意类型不匹配错误。要解决此问题,请添加转换映射,如下所示。
val homeUiState: StateFlow<HomeUiState> =
itemsRepository.getAllItemsStream().map { HomeUiState(it) }
Android Studio 仍然显示类型不匹配错误。这是因为 homeUiState
的类型是 StateFlow
,而 getAllItemsStream()
返回的是 Flow
。
- 使用
stateIn
运算符将Flow
转换为StateFlow
。StateFlow
是用于 UI 状态的可观察 API,可使 UI 自动更新。
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
val homeUiState: StateFlow<HomeUiState> =
itemsRepository.getAllItemsStream().map { HomeUiState(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
initialValue = HomeUiState()
)
- 构建应用,确保代码中没有错误。不会有任何视觉变化。
4. 显示库存数据
在此任务中,您将在 HomeScreen
中收集并更新 UI 状态。
- 在
HomeScreen.kt
文件中的HomeScreen
可组合函数中,添加一个类型为HomeViewModel
的新函数参数并对其进行初始化。
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.inventory.ui.AppViewModelProvider
@Composable
fun HomeScreen(
navigateToItemEntry: () -> Unit,
navigateToItemUpdate: (Int) -> Unit,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory)
)
- 在
HomeScreen
可组合函数中,添加一个名为homeUiState
的val
,用于从HomeViewModel
收集 UI 状态。您使用collectAsState
()
,它会从该StateFlow
收集值,并通过State
表示其最新值。
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
val homeUiState by viewModel.homeUiState.collectAsState()
- 更新
HomeBody()
函数调用,并将homeUiState.itemList
传入itemList
参数。
HomeBody(
itemList = homeUiState.itemList,
onItemClick = navigateToItemUpdate,
modifier = modifier.padding(innerPadding)
)
- 运行应用。如果您在应用数据库中保存了项目,请注意库存列表会显示出来。如果列表为空,请向应用数据库添加一些库存项目。
5. 测试您的数据库
之前的 Codelabs 讨论了测试代码的重要性。在此任务中,您将添加一些单元测试来测试您的 DAO 查询,然后随着 Codelab 的进展添加更多测试。
测试数据库实现的推荐方法是编写一个在 Android 设备上运行的 JUnit 测试。由于这些测试无需创建 Activity,因此它们的执行速度比 UI 测试快。
- 在
build.gradle.kts (Module :app)
文件中,请注意以下 Espresso 和 JUnit 依赖项。
// Testing
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
- 切换到 Project 视图,右键点击 src > New > Directory,为您的测试创建一个测试源集。
- 从 New Directory 弹窗中选择 androidTest/kotlin。
- 创建一个名为
ItemDaoTest.kt
的 Kotlin 类。 - 使用
@RunWith(AndroidJUnit4::class)
标注ItemDaoTest
类。您的类现在看起来像以下示例代码
package com.example.inventory
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ItemDaoTest {
}
- 在该类内部,添加类型为
ItemDao
和InventoryDatabase
的 privatevar
变量。
import com.example.inventory.data.InventoryDatabase
import com.example.inventory.data.ItemDao
private lateinit var itemDao: ItemDao
private lateinit var inventoryDatabase: InventoryDatabase
- 添加一个用于创建数据库的函数,并用
@Before
标注它,以便它可以在每个测试之前运行。 - 在该方法内部,初始化
itemDao
。
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import org.junit.Before
@Before
fun createDb() {
val context: Context = ApplicationProvider.getApplicationContext()
// Using an in-memory database because the information stored here disappears when the
// process is killed.
inventoryDatabase = Room.inMemoryDatabaseBuilder(context, InventoryDatabase::class.java)
// Allowing main thread queries, just for testing.
.allowMainThreadQueries()
.build()
itemDao = inventoryDatabase.itemDao()
}
在此函数中,您使用内存数据库,而不会将其持久化到磁盘。为此,您使用 inMemoryDatabaseBuilder() 函数。这样做是因为信息无需持久化,而是在进程被终止时需要删除。您正在主线程中运行 DAO 查询,并使用 .allowMainThreadQueries()
,仅用于测试。
- 添加另一个用于关闭数据库的函数。用
@After
标注它,以便在每个测试之后运行以关闭数据库。
import org.junit.After
import java.io.IOException
@After
@Throws(IOException::class)
fun closeDb() {
inventoryDatabase.close()
}
- 在
ItemDaoTest
类中声明要供数据库使用的项目,如以下代码示例所示
import com.example.inventory.data.Item
private var item1 = Item(1, "Apples", 10.0, 20)
private var item2 = Item(2, "Bananas", 15.0, 97)
- 添加实用函数,用于向数据库添加一个项目,然后再添加两个项目。稍后,您将在测试中使用这些函数。将它们标记为
suspend
,以便它们可以在协程中运行。
private suspend fun addOneItemToDb() {
itemDao.insert(item1)
}
private suspend fun addTwoItemsToDb() {
itemDao.insert(item1)
itemDao.insert(item2)
}
- 编写一个测试,用于向数据库插入单个项目
insert()
。将测试命名为daoInsert_insertsItemIntoDB
,并用@Test
标注它。
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
@Test
@Throws(Exception::class)
fun daoInsert_insertsItemIntoDB() = runBlocking {
addOneItemToDb()
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], item1)
}
在此测试中,您使用实用函数 addOneItemToDb()
向数据库添加一个项目。然后,读取数据库中的第一个项目。使用 assertEquals()
,将期望值与实际值进行比较。您在新的协程中使用 runBlocking{}
运行测试。此设置是您将实用函数标记为 suspend
的原因。
- 运行测试并确保它通过。
- 编写另一个测试,用于从数据库中获取
getAllItems()
。将测试命名为daoGetAllItems_returnsAllItemsFromDB
。
@Test
@Throws(Exception::class)
fun daoGetAllItems_returnsAllItemsFromDB() = runBlocking {
addTwoItemsToDb()
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], item1)
assertEquals(allItems[1], item2)
}
在上述测试中,您在协程内部向数据库添加了两个项目。然后读取这两个项目,并将它们与期望值进行比较。
6. 显示项目详细信息
在此任务中,您将在项目详细信息屏幕上读取并显示实体详细信息。您使用库存应用数据库中的项目 UI 状态(例如名称、价格和数量),并通过 ItemDetailsScreen
可组合项在项目详细信息屏幕上显示它们。ItemDetailsScreen
可组合函数已为您预先编写,其中包含三个显示项目详细信息的 Text 可组合项。
ui/item/ItemDetailsScreen.kt
此屏幕是入门代码的一部分,显示项目的详细信息,您将在稍后的 Codelab 中看到。在此 Codelab 中,您无需在此屏幕上进行任何操作。ItemDetailsViewModel.kt
是此屏幕对应的 ViewModel
。
- 在
HomeScreen
可组合函数中,注意HomeBody()
函数调用。正在将navigateToItemUpdate
传递给onItemClick
参数,该参数在您点击列表中的任何项目时调用。
// No need to copy over
HomeBody(
itemList = homeUiState.itemList,
onItemClick = navigateToItemUpdate,
modifier = modifier
.padding(innerPadding)
.fillMaxSize()
)
- 打开
ui/navigation/InventoryNavGraph.kt
,并注意HomeScreen
可组合项中的navigateToItemUpdate
参数。此参数指定了导航目标为项目详细信息屏幕。
// No need to copy over
HomeScreen(
navigateToItemEntry = { navController.navigate(ItemEntryDestination.route) },
navigateToItemUpdate = {
navController.navigate("${ItemDetailsDestination.route}/${it}")
}
这部分 onItemClick
功能已为您实现。当您点击列表项时,应用会导航到项目详细信息屏幕。
- 点击库存列表中的任何项目,即可看到字段为空的项目详细信息屏幕。
要使用项目详细信息填充文本字段,您需要在 ItemDetailsScreen()
中收集 UI 状态。
- 在
UI/Item/ItemDetailsScreen.kt
中,向类型为ItemDetailsViewModel
的ItemDetailsScreen
可组合项添加新参数,并使用工厂方法对其进行初始化。
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.inventory.ui.AppViewModelProvider
@Composable
fun ItemDetailsScreen(
navigateToEditItem: (Int) -> Unit,
navigateBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ItemDetailsViewModel = viewModel(factory = AppViewModelProvider.Factory)
)
- 在
ItemDetailsScreen()
可组合项内部,创建一个名为uiState
的val
,用于收集 UI 状态。使用collectAsState()
收集uiState
StateFlow
,并通过State
表示其最新值。Android Studio 会显示一个未解析的引用错误。
import androidx.compose.runtime.collectAsState
val uiState = viewModel.uiState.collectAsState()
- 要解决此错误,请在
ItemDetailsViewModel
类中创建一个类型为StateFlow<ItemDetailsUiState>
的val
,名为uiState
。 - 从项目仓库检索数据,并使用扩展函数
toItemDetails()
将其映射到ItemDetailsUiState
。扩展函数Item.toItemDetails()
已作为入门代码的一部分为您编写。
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
val uiState: StateFlow<ItemDetailsUiState> =
itemsRepository.getItemStream(itemId)
.filterNotNull()
.map {
ItemDetailsUiState(itemDetails = it.toItemDetails())
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
initialValue = ItemDetailsUiState()
)
- 将
ItemsRepository
传入ItemDetailsViewModel
,以解决Unresolved reference: itemsRepository
错误。
class ItemDetailsViewModel(
savedStateHandle: SavedStateHandle,
private val itemsRepository: ItemsRepository
) : ViewModel() {
- 在
ui/AppViewModelProvider.kt
中,更新ItemDetailsViewModel
的初始化程序,如以下代码段所示
initializer {
ItemDetailsViewModel(
this.createSavedStateHandle(),
inventoryApplication().container.itemsRepository
)
}
- 回到
ItemDetailsScreen.kt
,注意ItemDetailsScreen()
可组合项中的错误已解决。 - 在
ItemDetailsScreen()
可组合项中,更新ItemDetailsBody()
函数调用,并将uiState.value
传入itemUiState
参数。
ItemDetailsBody(
itemUiState = uiState.value,
onSellItem = { },
onDelete = { },
modifier = modifier.padding(innerPadding)
)
- 观察
ItemDetailsBody()
和ItemInputForm()
的实现。您正在将当前选定的item
从ItemDetailsBody()
传递给ItemDetails()
。
// No need to copy over
@Composable
private fun ItemDetailsBody(
itemUiState: ItemUiState,
onSellItem: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
//...
) {
var deleteConfirmationRequired by rememberSaveable { mutableStateOf(false) }
ItemDetails(
item = itemDetailsUiState.itemDetails.toItem(), modifier = Modifier.fillMaxWidth()
)
//...
}
- 运行应用。当您点击库存屏幕上的任何列表元素时,会显示项目详细信息屏幕。
- 请注意,屏幕不再空白。它会显示从库存数据库中检索到的实体详细信息。
- 点按售出按钮。没有任何反应!
在下一部分中,您将实现售出按钮的功能。
7. 实现物品详情屏幕相关功能
ui/item/ItemEditScreen.kt
物品编辑屏幕已作为入门代码的一部分提供给您。
此布局包含文本字段可组合项,用于编辑任何新库存项目的详细信息。
此应用的代码尚未完全实现所有功能。例如,在项目详细信息屏幕中,当您点按售出按钮时,库存数量不会减少。当您点按删除按钮时,应用确实会显示确认对话框。但是,当您选择是按钮时,应用实际上并未删除该项目。
最后,FAB 按钮 会打开一个空白的编辑物品屏幕。
在此部分中,您将实现售出、删除和 FAB 按钮的功能。
8. 实现物品售出
在此部分中,您将扩展应用的功能以实现售出功能。此更新涉及以下任务
- 添加用于更新实体的 DAO 函数测试。
- 在
ItemDetailsViewModel
中添加一个函数,用于减少数量并更新应用数据库中的实体。 - 如果数量为零,则停用售出按钮。
- 在
ItemDaoTest.kt
中,添加一个名为daoUpdateItems_updatesItemsInDB()
的函数,不带参数。用@Test
和@Throws(Exception::class)
标注。
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB()
- 定义函数并创建
runBlocking
代码块。在其中调用addTwoItemsToDb()
。
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
addTwoItemsToDb()
}
- 使用不同的值更新两个实体,调用
itemDao.update
。
itemDao.update(Item(1, "Apples", 15.0, 25))
itemDao.update(Item(2, "Bananas", 5.0, 50))
- 使用
itemDao.getAllItems()
检索实体。将它们与已更新的实体进行比较并断言。
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], Item(1, "Apples", 15.0, 25))
assertEquals(allItems[1], Item(2, "Bananas", 5.0, 50))
- 确保完成的函数如下所示
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
addTwoItemsToDb()
itemDao.update(Item(1, "Apples", 15.0, 25))
itemDao.update(Item(2, "Bananas", 5.0, 50))
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], Item(1, "Apples", 15.0, 25))
assertEquals(allItems[1], Item(2, "Bananas", 5.0, 50))
}
- 运行测试并确保它通过。
在 ViewModel
中添加一个函数
- 在
ItemDetailsViewModel.kt
中,在ItemDetailsViewModel
类内部,添加一个名为reduceQuantityByOne()
的函数,不带参数。
fun reduceQuantityByOne() {
}
- 在函数内部,使用
viewModelScope.launch{}
启动一个协程。
import kotlinx.coroutines.launch
import androidx.lifecycle.viewModelScope
viewModelScope.launch {
}
- 在
launch
代码块内部,创建一个名为currentItem
的val
,并将其设置为uiState.value.toItem()
。
val currentItem = uiState.value.toItem()
uiState.value
的类型为 ItemUiState
。您可以使用扩展函数 toItem
()
将其转换为 Item
实体类型。
- 添加一个
if
语句,检查quantity
是否大于0
。 - 在
itemsRepository
上调用updateItem()
,并传入已更新的currentItem
。使用copy()
更新quantity
值,以便函数如下所示
fun reduceQuantityByOne() {
viewModelScope.launch {
val currentItem = uiState.value.itemDetails.toItem()
if (currentItem.quantity > 0) {
itemsRepository.updateItem(currentItem.copy(quantity = currentItem.quantity - 1))
}
}
}
- 回到
ItemDetailsScreen.kt
。 - 在
ItemDetailsScreen
可组合项中,转到ItemDetailsBody()
函数调用。 - 在
onSellItem
lambda 中,调用viewModel.reduceQuantityByOne()
。
ItemDetailsBody(
itemUiState = uiState.value,
onSellItem = { viewModel.reduceQuantityByOne() },
onDelete = { },
modifier = modifier.padding(innerPadding)
)
- 运行应用。
- 在库存屏幕上,点击一个列表元素。当显示项目详细信息屏幕时,点按售出,注意数量值会减少一。
- 在项目详细信息屏幕中,连续点按售出按钮,直到数量为零。
数量达到零后,再次点按售出。没有视觉变化,因为 reduceQuantityByOne()
函数会在更新数量之前检查数量是否大于零。
为了给用户更好的反馈,您可能希望在没有可售出项目时禁用售出按钮。
- 在
ItemDetailsViewModel
类中,根据map
转换中的it
.quantity
设置outOfStock
值。
val uiState: StateFlow<ItemDetailsUiState> =
itemsRepository.getItemStream(itemId)
.filterNotNull()
.map {
ItemDetailsUiState(outOfStock = it.quantity <= 0, itemDetails = it.toItemDetails())
}.stateIn(
//...
)
- 运行您的应用。请注意,当库存数量为零时,应用会禁用售出按钮。
恭喜您在应用中实现了售出项目功能!
删除项目实体
与上一项任务一样,您必须通过实现删除功能来进一步扩展应用的功能。此功能比售出功能更容易实现。该过程涉及以下任务
- 添加删除 DAO 查询的测试。
- 在
ItemDetailsViewModel
类中添加一个函数,用于从数据库中删除实体。 - 更新
ItemDetailsBody
可组合项。
添加 DAO 测试
- 在
ItemDaoTest.kt
中,添加一个名为daoDeleteItems_deletesAllItemsFromDB()
的测试。
@Test
@Throws(Exception::class)
fun daoDeleteItems_deletesAllItemsFromDB()
- 使用
runBlocking {}
启动协程。
fun daoDeleteItems_deletesAllItemsFromDB() = runBlocking {
}
- 向数据库添加两个项目,并对这两个项目调用
itemDao.delete()
,以将其从数据库中删除。
addTwoItemsToDb()
itemDao.delete(item1)
itemDao.delete(item2)
- 从数据库中检索实体,并检查列表是否为空。完成的测试应如下所示
import org.junit.Assert.assertTrue
@Test
@Throws(Exception::class)
fun daoDeleteItems_deletesAllItemsFromDB() = runBlocking {
addTwoItemsToDb()
itemDao.delete(item1)
itemDao.delete(item2)
val allItems = itemDao.getAllItems().first()
assertTrue(allItems.isEmpty())
}
在 ItemDetailsViewModel
中添加删除函数
- 在
ItemDetailsViewModel
中,添加一个名为deleteItem()
的新函数,不带参数,不返回任何内容。 - 在
deleteItem()
函数内部,添加一个itemsRepository.deleteItem()
函数调用,并传入uiState.value.
toItem
()
。
suspend fun deleteItem() {
itemsRepository.deleteItem(uiState.value.itemDetails.toItem())
}
在此函数中,您使用 toItem
()
扩展函数将 uiState
从 itemDetails
类型转换为 Item
实体类型。
- 在
ui/item/ItemDetailsScreen
可组合项中,添加一个名为coroutineScope
的val
,并将其设置为rememberCoroutineScope()
。此方法会返回一个绑定到调用它的组合(ItemDetailsScreen
可组合项)的协程作用域。
import androidx.compose.runtime.rememberCoroutineScope
val coroutineScope = rememberCoroutineScope()
- 滚动到
ItemDetailsBody()
函数。 - 在
onDelete
lambda 内部使用coroutineScope
启动协程。 - 在
launch
代码块内部,调用viewModel
上的deleteItem()
方法。
import kotlinx.coroutines.launch
ItemDetailsBody(
itemUiState = uiState.value,
onSellItem = { viewModel.reduceQuantityByOne() },
onDelete = {
coroutineScope.launch {
viewModel.deleteItem()
}
modifier = modifier.padding(innerPadding)
)
- 删除项目后,导航回库存屏幕。
- 在
deleteItem()
函数调用之后调用navigateBack()
。
onDelete = {
coroutineScope.launch {
viewModel.deleteItem()
navigateBack()
}
- 仍在
ItemDetailsScreen.kt
文件中,滚动到ItemDetailsBody()
函数。
此函数是入门代码的一部分。此可组合项显示一个警告对话框,以获取用户确认,然后删除项目,并在您点按是时调用 deleteItem()
函数。
// No need to copy over
@Composable
private fun ItemDetailsBody(
itemUiState: ItemUiState,
onSellItem: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
/*...*/
) {
//...
if (deleteConfirmationRequired) {
DeleteConfirmationDialog(
onDeleteConfirm = {
deleteConfirmationRequired = false
onDelete()
},
//...
)
}
}
}
当您点按否时,应用会关闭警告对话框。showConfirmationDialog()
函数会显示以下警告
- 运行应用。
- 在库存屏幕上选择一个列表元素。
- 在项目详细信息屏幕中,点按删除。
- 在警告对话框中点按是,应用会导航回库存屏幕。
- 确认您删除的实体不再位于应用数据库中。
恭喜您实现了删除功能!
编辑项目实体
与前面的部分类似,在此部分中,您将向应用添加另一个功能增强,用于编辑项目实体。
以下是编辑应用数据库中实体的快速流程
- 向测试获取项目 DAO 查询添加测试。
- 使用实体详细信息填充文本字段和编辑物品屏幕。
- 使用 Room 更新数据库中的实体。
添加 DAO 测试
- 在
ItemDaoTest.kt
中,添加一个名为daoGetItem_returnsItemFromDB()
的测试。
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB()
- 定义函数。在协程内部,向数据库添加一个项目。
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
addOneItemToDb()
}
- 使用
itemDao.getItem()
函数从数据库中检索实体,并将其设置为名为item
的val
。
val item = itemDao.getItem(1)
- 将实际值与检索到的值进行比较,并使用
assertEquals()
断言。完成的测试如下所示
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
addOneItemToDb()
val item = itemDao.getItem(1)
assertEquals(item.first(), item1)
}
- 运行测试并确保其通过。
填充文本字段
如果您运行应用,转到项目详细信息屏幕,然后点击 FAB,您可以看到屏幕标题现在是编辑物品。但是,所有文本字段都是空的。在此步骤中,您将使用实体详细信息填充编辑物品屏幕中的文本字段。
- 在
ItemDetailsScreen.kt
中,滚动到ItemDetailsScreen
可组合项。 - 在
FloatingActionButton()
中,将onClick
参数更改为包含uiState.value.itemDetails.id
,这是所选实体的id
。您使用此id
检索实体详细信息。
FloatingActionButton(
onClick = { navigateToEditItem(uiState.value.itemDetails.id) },
modifier = /*...*/
)
- 在
ItemEditViewModel
类中,添加一个init
块。
init {
}
- 在
init
块内部,使用viewModelScope
.
launch
启动一个协程。
import kotlinx.coroutines.launch
viewModelScope.launch { }
- 在
launch
块内部,使用itemsRepository.getItemStream(itemId)
检索实体详细信息。
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
init {
viewModelScope.launch {
itemUiState = itemsRepository.getItemStream(itemId)
.filterNotNull()
.first()
.toItemUiState(true)
}
}
在此 launch 块中,您添加一个过滤器,以返回只包含非空值的 flow。使用 toItemUiState()
,您将 item
实体转换为 ItemUiState
。您将 actionEnabled
的值作为 true
传入,以启用保存按钮。
要解决 Unresolved reference: itemsRepository
错误,您需要将 ItemsRepository
作为依赖项传入 view model。
- 向
ItemEditViewModel
类添加一个构造函数参数。
class ItemEditViewModel(
savedStateHandle: SavedStateHandle,
private val itemsRepository: ItemsRepository
)
- 在
AppViewModelProvider.kt
文件中的ItemEditViewModel
初始化程序中,添加ItemsRepository
对象作为参数。
initializer {
ItemEditViewModel(
this.createSavedStateHandle(),
inventoryApplication().container.itemsRepository
)
}
- 运行应用。
- 转到项目详细信息并点按
FAB。
- 请注意,字段会填充项目详细信息。
- 编辑库存数量或任何其他字段,然后点按保存。
没有任何反应!这是因为您没有更新应用数据库中的实体。您将在下一部分中解决此问题。
使用 Room 更新实体
在此最后一项任务中,您将添加最后一部分代码以实现更新功能。您将在 ViewModel 中定义必要的函数,并在 ItemEditScreen
中使用它们。
又到了编写代码的时间!
- 在
ItemEditViewModel
类中,添加一个名为updateUiState()
的函数,该函数接受一个ItemUiState
对象,不返回任何内容。此函数将使用用户输入的新值更新itemUiState
。
fun updateUiState(itemDetails: ItemDetails) {
itemUiState =
ItemUiState(itemDetails = itemDetails, isEntryValid = validateInput(itemDetails))
}
在此函数中,您将传入的 itemDetails
分配给 itemUiState
并更新 isEntryValid
的值。如果 isEntryValid
的值为 true
,则应用会启用保存按钮。只有用户输入有效时,您才将此值设置为 true
。
- 转到
ItemEditScreen.kt
文件。 - 在
ItemEditScreen
可组合项中,向下滚动到ItemEntryBody()
函数调用。 - 将
onItemValueChange
参数值设置为新函数updateUiState
。
ItemEntryBody(
itemUiState = viewModel.itemUiState,
onItemValueChange = viewModel::updateUiState,
onSaveClick = { },
modifier = modifier.padding(innerPadding)
)
- 运行应用。
- 转到编辑物品屏幕。
- 将其中一个实体值设置为空,使其无效。注意保存按钮如何自动禁用。
- 回到
ItemEditViewModel
类,添加一个名为updateItem()
的suspend
函数,该函数不接受任何参数。您可以使用此函数将更新后的实体保存到 Room 数据库中。
suspend fun updateItem() {
}
- 在
updateItem()
函数内部,添加一个if
条件,使用validateInput()
函数验证用户输入。 - 调用
itemsRepository
上的updateItem()
函数,并传入itemUiState.itemDetails.
toItem
()
。可以添加到 Room 数据库的实体需要是Item
类型。完成的函数如下所示
suspend fun updateItem() {
if (validateInput(itemUiState.itemDetails)) {
itemsRepository.updateItem(itemUiState.itemDetails.toItem())
}
}
- 回到
ItemEditScreen
可组合项。您需要一个协程作用域来调用updateItem()
函数。创建一个名为coroutineScope
的 val,并将其设置为rememberCoroutineScope()
。
import androidx.compose.runtime.rememberCoroutineScope
val coroutineScope = rememberCoroutineScope()
- 在
ItemEntryBody()
函数调用中,更新onSaveClick
函数参数,以在coroutineScope
中启动一个协程。 - 在
launch
代码块内部,在viewModel
上调用updateItem()
并导航回。
import kotlinx.coroutines.launch
onSaveClick = {
coroutineScope.launch {
viewModel.updateItem()
navigateBack()
}
},
完成的 ItemEntryBody()
函数调用如下所示
ItemEntryBody(
itemUiState = viewModel.itemUiState,
onItemValueChange = viewModel::updateUiState,
onSaveClick = {
coroutineScope.launch {
viewModel.updateItem()
navigateBack()
}
},
modifier = modifier.padding(innerPadding)
)
- 运行应用并尝试编辑库存项目。您现在可以编辑库存应用数据库中的任何项目。
恭喜您创建了第一个使用 Room 管理数据库的应用!
9. 解决方案代码
此 Codelab 的解决方案代码位于 GitHub 仓库和以下分支中
10. 了解详情
Android 开发者文档
Kotlin 参考文档