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 以异步方式处理对底层数据的更新。
从流中获取数据称为从流中收集。在 UI 层从流中收集时,需要考虑以下几点。
- 生命周期事件(例如配置更改,例如旋转设备)会导致活动重新创建。这会导致重新组合并从您的
Flow
重新收集。 - 您希望将值缓存在状态中,以便在生命周期事件之间不会丢失现有数据。
- 如果不再有观察者,例如在可组合函数的生命周期结束后,应取消流。
从 ViewModel
公开 Flow
的推荐方法是使用 StateFlow
。使用 StateFlow
允许保存和观察数据,而不管 UI 生命周期的变化。
stateIn
运算符有三个参数,如下所述
scope
-viewModelScope
定义StateFlow
的生命周期。当viewModelScope
被取消时,StateFlow
也会被取消。started
- 管道仅在 UI 可见时才应处于活动状态。使用SharingStarted.WhileSubscribed()
来实现此目的。要配置最后一个订阅者消失与共享协程停止之间的时间延迟(以毫秒为单位),请将TIMEOUT_MILLIS
传递给SharingStarted.WhileSubscribed()
方法。initialValue
- 将状态流的初始值设置为HomeUiState()
。
将 Flow
转换为 StateFlow
后,您可以使用 collectAsState()
方法收集它,将其数据转换为相同类型的 State
。
在此步骤中,您将检索 Room 数据库中的所有项目作为 UI 状态的可观察 API StateFlow
。当 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
类中,声明一个名为homeUiState
的val
,其类型为StateFlow<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. 显示 Inventory 数据
在本任务中,您将在 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. 测试您的数据库
以前的 Codelab 讨论了测试代码的重要性。在本任务中,您将添加一些单元测试来测试您的 DAO 查询,然后在您完成 Codelab 的过程中添加更多测试。
测试数据库实现的推荐方法是编写一个在 Android 设备上运行的 JUnit 测试。由于这些测试不需要创建活动,因此它们的执行速度比 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
的私有var
变量。
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() 函数。您这样做是因为不需要持久化信息,而是在进程被终止时需要将其删除。您在主线程中使用 .allowMainThreadQueries()
运行 DAO 查询,仅用于测试。
- 添加另一个关闭数据库的函数。使用
@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. 显示项目详情
在此任务中,您将在**Item Details**屏幕上读取和显示实体详细信息。您使用库存应用程序数据库中的项目 UI 状态(例如名称、价格和数量),并使用 ItemDetailsScreen
可组合项在**Item Details**屏幕上显示它们。 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
中,向ItemDetailsScreen
可组合项添加一个新的类型为ItemDetailsViewModel
的参数,并使用工厂方法对其进行初始化。
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
类中创建一个名为uiState
的类型为StateFlow<ItemDetailsUiState>
的val
。 - 从项目存储库检索数据,并使用扩展函数
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()
的实现。您正在将ItemDetailsBody()
中当前选定的item
传递给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()
)
//...
}
- 运行应用程序。当您单击**Inventory**屏幕上的任何列表元素时,将显示**Item Details**屏幕。
- 注意,屏幕不再是空白的。它显示从库存数据库检索到的实体详细信息。
- 点击**Sell**按钮。没有任何反应!
在下一节中,您将实现**Sell**按钮的功能。
7. 实现项目详情屏幕
ui/item/ItemEditScreen.kt
项目编辑屏幕已作为启动代码的一部分提供给您。
此布局包含用于编辑任何新库存项目详细信息的文本字段可组合项。
此应用程序的代码尚未完全发挥功能。例如,在**Item Details**屏幕上,当您点击**Sell**按钮时,**Quantity in Stock**不会减少。当您点击**Delete**按钮时,应用程序确实会提示您确认对话框。但是,当您选择**Yes**按钮时,应用程序实际上并没有删除该项目。
最后,FAB 按钮 打开一个空的**Edit Item**屏幕。
在本节中,您将实现**Sell**、**Delete**和 FAB 按钮的功能。
8. 实现销售项目
在本节中,您将扩展应用程序的功能以实现销售功能。此更新涉及以下任务
- 为 DAO 函数添加一个测试以更新实体。
- 在
ItemDetailsViewModel
中添加一个函数以减少数量并在应用程序数据库中更新实体。 - 如果数量为零,则禁用**Sell**按钮。
- 在
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
语句以检查quality
是否大于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)
)
- 运行应用程序。
- 在**Inventory**屏幕上,单击列表元素。当显示**Item Details**屏幕时,点击**Sell**并注意数量值减少了 1。
- 在**Item Details**屏幕上,连续点击**Sell**按钮,直到数量为零。
数量达到零后,再次点击出售。由于函数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 块中,您添加了一个过滤器以返回仅包含非空值的流。使用toItemUiState()
,您可以将item
实体转换为ItemUiState
。您将actionEnabled
值作为true
传递以启用保存按钮。
要解决Unresolved reference: itemsRepository
错误,您需要将ItemsRepository
作为依赖项传递给视图模型。
- 向
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
值。如果itemDetails
为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() {
}
- 在
getUpdatedItemEntry()
函数内部,添加一个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 参考