使用 Room 读取和更新数据

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 持久性库保存数据。用户可以使用“添加项目”屏幕将数据添加到应用数据库。

Add Item screen with item details filled in.

Phone screen empty inventory

在本 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 以显示存储在数据库中的数据。

Phone screen with inventory items

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) 7b1535d90ee957fa.png
  • 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 会自动更新。

  1. 打开 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())
  1. HomeViewModel 类中,声明一个名为 homeUiStateval,其类型为 StateFlow<HomeUiState>。您将很快解决初始化错误。
val homeUiState: StateFlow<HomeUiState>
  1. itemsRepository 上调用 getAllItemsStream() 并将其分配给您刚刚声明的 homeUiState
val homeUiState: StateFlow<HomeUiState> =
    itemsRepository.getAllItemsStream()

现在您会收到一个错误 - 未解析的引用:itemsRepository。要解决未解析的引用错误,您需要将 ItemsRepository 对象传递到 HomeViewModel 中。

  1. HomeViewModel 类添加一个类型为 ItemsRepository 的构造函数参数。
import com.example.inventory.data.ItemsRepository

class HomeViewModel(itemsRepository: ItemsRepository): ViewModel() {
  1. ui/AppViewModelProvider.kt 文件中,在 HomeViewModel 初始化程序中,传递 ItemsRepository 对象,如下所示。
initializer {
    HomeViewModel(inventoryApplication().container.itemsRepository)
}
  1. 返回 HomeViewModel.kt 文件。注意类型不匹配错误。要解决此问题,请添加如下所示的转换映射。
val homeUiState: StateFlow<HomeUiState> =
    itemsRepository.getAllItemsStream().map { HomeUiState(it) }

Android Studio 仍然向您显示类型不匹配错误。此错误是由于 homeUiState 的类型为 StateFlow,而 getAllItemsStream() 返回 Flow

  1. 使用 stateIn 运算符将 Flow 转换为 StateFlowStateFlow 是 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()
        )
  1. 构建应用以确保代码中没有错误。不会有任何视觉变化。

4. 显示 Inventory 数据

在本任务中,您将在 HomeScreen 中收集并更新 UI 状态。

  1. 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)
)
  1. HomeScreen 可组合函数中,添加一个名为 homeUiStateval 以从 HomeViewModel 收集 UI 状态。您使用collectAsState(),它从此 StateFlow 收集值,并通过 State 表示其最新值。
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue

val homeUiState by viewModel.homeUiState.collectAsState()
  1. 更新 HomeBody() 函数调用并将 homeUiState.itemList 传递给 itemList 参数。
HomeBody(
    itemList = homeUiState.itemList,
    onItemClick = navigateToItemUpdate,
    modifier = modifier.padding(innerPadding)
)
  1. 运行应用。如果您在应用数据库中保存了项目,请注意库存列表会显示。如果列表为空,请向应用数据库添加一些库存项目。

Phone screen with inventory items

5. 测试您的数据库

以前的 Codelab 讨论了测试代码的重要性。在本任务中,您将添加一些单元测试来测试您的 DAO 查询,然后在您完成 Codelab 的过程中添加更多测试。

测试数据库实现的推荐方法是编写一个在 Android 设备上运行的 JUnit 测试。由于这些测试不需要创建活动,因此它们的执行速度比 UI 测试快。

  1. 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")
  1. 切换到**Project**视图,右键单击**src** > **New** > **Directory**为您的测试创建一个测试源集。

9121189f4a0d2613.png

  1. 在**New Directory**弹出窗口中选择**androidTest/kotlin**。

fba4ed57c7589f7f.png

  1. 创建一个名为 ItemDaoTest.kt 的 Kotlin 类。
  2. 使用 @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 {
}
  1. 在类内部,添加类型为 ItemDaoInventoryDatabase 的私有 var 变量。
import com.example.inventory.data.InventoryDatabase
import com.example.inventory.data.ItemDao

private lateinit var itemDao: ItemDao
private lateinit var inventoryDatabase: InventoryDatabase
  1. 添加一个创建数据库的函数,并使用 @Before 注释它,以便它可以在每个测试之前运行。
  2. 在方法内部,初始化 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 查询,仅用于测试。

  1. 添加另一个关闭数据库的函数。使用 @After 注释它以关闭数据库并在每个测试后运行。
import org.junit.After
import java.io.IOException

@After
@Throws(IOException::class)
fun closeDb() {
    inventoryDatabase.close()
}
  1. 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)
  1. 添加实用程序函数以将一个项目,然后两个项目添加到数据库中。稍后,您将在测试中使用这些函数。将它们标记为 suspend,以便它们可以在协程中运行。
private suspend fun addOneItemToDb() {
    itemDao.insert(item1)
}

private suspend fun addTwoItemsToDb() {
    itemDao.insert(item1)
    itemDao.insert(item2)
}
  1. 编写一个将单个项目插入数据库的测试,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 的原因。

  1. 运行测试并确保它通过。

2f0ddde91781d6bd.png

8f66e03d03aac31a.png

  1. 为数据库中的 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

de7761a894d1b2ab.png

  1. HomeScreen 可组合函数中,请注意 HomeBody() 函数调用。 navigateToItemUpdate 正在传递给 onItemClick 参数,当您单击列表中的任何项目时,该参数会被调用。
// No need to copy over 
HomeBody(
    itemList = homeUiState.itemList,
    onItemClick = navigateToItemUpdate,
    modifier = modifier
        .padding(innerPadding)
        .fillMaxSize()
)
  1. 打开 ui/navigation/InventoryNavGraph.kt 并注意 HomeScreen 可组合项中的 navigateToItemUpdate 参数。此参数将导航目标指定为项目详细信息屏幕。
// No need to copy over 
HomeScreen(
    navigateToItemEntry = { navController.navigate(ItemEntryDestination.route) },
    navigateToItemUpdate = {
        navController.navigate("${ItemDetailsDestination.route}/${it}")
   }

onItemClick 功能的一部分已为您实现。当您单击列表项时,应用程序会导航到项目详细信息屏幕。

  1. 单击库存列表中的任何项目以查看带有空字段的项目详细信息屏幕。

Item details screen with empty data

要使用项目详细信息填充文本字段,您需要在 ItemDetailsScreen() 中收集 UI 状态。

  1. 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)
)
  1. ItemDetailsScreen() 可组合项内部,创建一个名为 uiStateval 以收集 UI 状态。使用 collectAsState() 收集 uiState StateFlow 并通过 State 表示其最新值。Android Studio 显示未解析的引用错误。
import androidx.compose.runtime.collectAsState

val uiState = viewModel.uiState.collectAsState()
  1. 要解决此错误,请在 ItemDetailsViewModel 类中创建一个名为 uiState 的类型为 StateFlow<ItemDetailsUiState>val
  2. 从项目存储库检索数据,并使用扩展函数 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()
             )
  1. ItemsRepository 传递到 ItemDetailsViewModel 以解决 Unresolved reference: itemsRepository 错误。
class ItemDetailsViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
    ) : ViewModel() {
  1. ui/AppViewModelProvider.kt 中,更新 ItemDetailsViewModel 的初始化程序,如下面的代码片段所示
initializer {
    ItemDetailsViewModel(
        this.createSavedStateHandle(),
        inventoryApplication().container.itemsRepository
    )
}
  1. 返回 ItemDetailsScreen.kt 并注意 ItemDetailsScreen() 可组合项中的错误已解决。
  2. ItemDetailsScreen() 可组合项中,更新 ItemDetailsBody() 函数调用并将 uiState.value 传递给 itemUiState 参数。
ItemDetailsBody(
    itemUiState = uiState.value,
    onSellItem = {  },
    onDelete = { },
    modifier = modifier.padding(innerPadding)
)
  1. 观察 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()
         )

      //...
    }
  1. 运行应用程序。当您单击**Inventory**屏幕上的任何列表元素时,将显示**Item Details**屏幕。
  2. 注意,屏幕不再是空白的。它显示从库存数据库检索到的实体详细信息。

Item details screen with valid item details

  1. 点击**Sell**按钮。没有任何反应!

在下一节中,您将实现**Sell**按钮的功能。

7. 实现项目详情屏幕

ui/item/ItemEditScreen.kt

项目编辑屏幕已作为启动代码的一部分提供给您。

此布局包含用于编辑任何新库存项目详细信息的文本字段可组合项。

Edit item layout with item name item price and quantity in stock felids

此应用程序的代码尚未完全发挥功能。例如,在**Item Details**屏幕上,当您点击**Sell**按钮时,**Quantity in Stock**不会减少。当您点击**Delete**按钮时,应用程序确实会提示您确认对话框。但是,当您选择**Yes**按钮时,应用程序实际上并没有删除该项目。

item delete confirmation pop up

最后,FAB 按钮 aad0ce469e4a3a12.png 打开一个空的**Edit Item**屏幕。

Edit item screen with empty felids

在本节中,您将实现**Sell**、**Delete**和 FAB 按钮的功能。

8. 实现销售项目

在本节中,您将扩展应用程序的功能以实现销售功能。此更新涉及以下任务

  • 为 DAO 函数添加一个测试以更新实体。
  • ItemDetailsViewModel 中添加一个函数以减少数量并在应用程序数据库中更新实体。
  • 如果数量为零,则禁用**Sell**按钮。
  1. ItemDaoTest.kt 中,添加一个名为 daoUpdateItems_updatesItemsInDB() 的函数,该函数没有参数。使用 @Test@Throws(Exception::class) 进行注释。
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB()
  1. 定义函数并创建一个runBlocking块。在其中调用 addTwoItemsToDb()
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
    addTwoItemsToDb()
}
  1. 使用不同的值更新这两个实体,调用 itemDao.update
itemDao.update(Item(1, "Apples", 15.0, 25))
itemDao.update(Item(2, "Bananas", 5.0, 50))
  1. 使用 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))
  1. 确保完成后的函数如下所示
@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))
}
  1. 运行测试并确保它通过。

ViewModel 中添加一个函数

  1. ItemDetailsViewModel.kt 中,在 ItemDetailsViewModel 类内部,添加一个名为 reduceQuantityByOne() 的函数,该函数没有参数。
fun reduceQuantityByOne() {
}
  1. 在函数内部,使用 viewModelScope.launch{} 启动一个协程。
import kotlinx.coroutines.launch
import androidx.lifecycle.viewModelScope


viewModelScope.launch {
}
  1. launch 块内部,创建一个名为 currentItemval 并将其设置为uiState.value.toItem().
val currentItem = uiState.value.toItem()

uiState.value 的类型为ItemUiState。您使用扩展函数toItem()将其转换为 Item 实体类型。

  1. 添加一个 if 语句以检查 quality 是否大于 0
  2. 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))
       }
    }
}
  1. 返回 ItemDetailsScreen.kt
  2. ItemDetailsScreen 可组合项中,转到 ItemDetailsBody() 函数调用。
  3. onSellItem lambda 中,调用 viewModel.reduceQuantityByOne()
ItemDetailsBody(
    itemUiState = uiState.value,
    onSellItem = { viewModel.reduceQuantityByOne() },
    onDelete = { },
    modifier = modifier.padding(innerPadding)
)
  1. 运行应用程序。
  2. 在**Inventory**屏幕上,单击列表元素。当显示**Item Details**屏幕时,点击**Sell**并注意数量值减少了 1。

Item details screen decreases the quantity by one when sell button is tapped

  1. 在**Item Details**屏幕上,连续点击**Sell**按钮,直到数量为零。

数量达到零后,再次点击出售。由于函数reduceQuantityByOne()在更新数量之前会检查数量是否大于零,因此不会出现任何视觉变化。

Item details screen with quantity in stock as 0

为了给用户提供更好的反馈,您可能需要在没有物品可出售时禁用出售按钮。

  1. ItemDetailsViewModel类中,根据map转换中的it.quantity设置outOfStock的值。
val uiState: StateFlow<ItemDetailsUiState> =
    itemsRepository.getItemStream(itemId)
        .filterNotNull()
        .map {
            ItemDetailsUiState(outOfStock = it.quantity <= 0, itemDetails = it.toItemDetails())
        }.stateIn(
            //...
        )
  1. 运行您的应用。注意,当库存数量为零时,应用会禁用出售按钮。

Item details screen with sell button disabled

恭喜您在您的应用中实现了出售物品功能!

删除项目实体

与前面的任务一样,您必须通过实现删除功能来进一步扩展应用的功能。此功能比出售功能更容易实现。该过程涉及以下任务

  • 为删除 DAO 查询添加测试。
  • ItemDetailsViewModel类中添加一个函数,用于从数据库中删除实体。
  • 更新ItemDetailsBody组合。

添加 DAO 测试

  1. ItemDaoTest.kt中,添加一个名为daoDeleteItems_deletesAllItemsFromDB()的测试。
@Test
@Throws(Exception::class)
fun daoDeleteItems_deletesAllItemsFromDB()
  1. 使用runBlocking {}启动一个协程。
fun daoDeleteItems_deletesAllItemsFromDB() = runBlocking {
}
  1. 向数据库添加两个项目,并对这两个项目调用itemDao.delete()以将其从数据库中删除。
addTwoItemsToDb()
itemDao.delete(item1)
itemDao.delete(item2)
  1. 从数据库中检索实体,并检查列表是否为空。完成的测试应如下所示
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中添加删除函数

  1. ItemDetailsViewModel中,添加一个名为deleteItem()的新函数,该函数不带参数且不返回值。
  2. deleteItem()函数内部,添加一个itemsRepository.deleteItem()函数调用,并将uiState.value.toItem()作为参数传递。
suspend fun deleteItem() {
    itemsRepository.deleteItem(uiState.value.itemDetails.toItem())
}

在此函数中,您使用toItem()扩展函数将uiStateitemDetails类型转换为Item实体类型。

  1. ui/item/ItemDetailsScreen组合中,添加一个名为coroutineScopeval,并将其设置为rememberCoroutineScope()。此方法返回绑定到调用它的组合(ItemDetailsScreen组合)的协程作用域。
import androidx.compose.runtime.rememberCoroutineScope


val coroutineScope = rememberCoroutineScope()
  1. 滚动到ItemDetailsBody()函数。
  2. onDelete lambda 内部使用coroutineScope启动一个协程。
  3. launch块内,在viewModel上调用deleteItem()方法。
import kotlinx.coroutines.launch

ItemDetailsBody(
    itemUiState = uiState.value,
    onSellItem = { viewModel.reduceQuantityByOne() },
    onDelete = {
        coroutineScope.launch {
           viewModel.deleteItem()
    }
    modifier = modifier.padding(innerPadding)
)
  1. 删除项目后,导航回库存屏幕。
  2. deleteItem()函数调用后调用navigateBack()
onDelete = {
    coroutineScope.launch {
        viewModel.deleteItem()
        navigateBack()
    }
  1. 仍在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()函数显示以下警报

item delete confirmation pop up

  1. 运行应用程序。
  2. 库存屏幕上选择一个列表元素。
  3. 项目详情屏幕上,点击删除
  4. 在警报对话框中点击,应用将导航回库存屏幕。
  5. 确认您删除的实体不再存在于应用数据库中。

恭喜您实现了删除功能!

Item details screen with Alert dialog window.

Phone screen displaying inventory list without the deleted item

编辑项目实体

与前面的部分类似,在本部分中,您向应用添加了另一个功能增强功能,该功能可以编辑项目实体。

以下是编辑应用数据库中实体的步骤的快速浏览

  • 向测试获取项目 DAO 查询添加测试。
  • 使用实体详细信息填充编辑项目屏幕中的文本字段。
  • 使用 Room 更新数据库中的实体。

添加 DAO 测试

  1. ItemDaoTest.kt中,添加一个名为daoGetItem_returnsItemFromDB()的测试。
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB()
  1. 定义函数。在协程内部,向数据库添加一个项目。
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
    addOneItemToDb()
}
  1. 使用itemDao.getItem()函数从数据库中检索实体,并将其设置为名为itemval
val item = itemDao.getItem(1)
  1. 将实际值与检索到的值进行比较,并使用assertEquals()进行断言。您完成的测试如下所示
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
    addOneItemToDb()
    val item = itemDao.getItem(1)
    assertEquals(item.first(), item1)
}
  1. 运行测试并确保其通过。

填充文本字段

如果您运行应用,转到项目详情屏幕,然后点击 FAB,您会发现屏幕的标题现在是编辑项目。但是,所有文本字段都是空的。在此步骤中,您将使用实体详细信息填充编辑项目屏幕中的文本字段。

Item details screen decreases the quantity by one when sell button is tapped

Edit item screen with empty felids

  1. ItemDetailsScreen.kt中,滚动到ItemDetailsScreen组合。
  2. FloatingActionButton()中,将onClick参数更改为包含uiState.value.itemDetails.id,即所选实体的id。您使用此id来检索实体详细信息。
FloatingActionButton(
    onClick = { navigateToEditItem(uiState.value.itemDetails.id) },
    modifier = /*...*/
)
  1. ItemEditViewModel类中,添加一个init块。
init {

}
  1. init块内部,使用viewModelScope.launch启动一个协程。
import kotlinx.coroutines.launch

viewModelScope.launch { }
  1. 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作为依赖项传递给视图模型。

  1. ItemEditViewModel类添加一个构造函数参数。
class ItemEditViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
)
  1. AppViewModelProvider.kt文件中,在ItemEditViewModel初始化程序中,添加ItemsRepository对象作为参数。
initializer {
    ItemEditViewModel(
        this.createSavedStateHandle(),
        inventoryApplication().container.itemsRepository
    )
}
  1. 运行应用程序。
  2. 转到项目详情并点击73b88f16638608f0.png FAB。
  3. 注意字段会填充项目详细信息。
  4. 编辑库存数量或任何其他字段,然后点击保存

没有任何反应!这是因为您没有更新应用数据库中的实体。您将在下一节中修复此问题。

Item details screen decreases the quantity by one when sell button is tapped

Edit item screen with empty felids

使用 Room 更新实体

在此最终任务中,您将添加代码的最终部分以实现更新功能。您在 ViewModel 中定义必要的函数,并在ItemEditScreen中使用它们。

又是编码时间!

  1. ItemEditViewModel类中,添加一个名为updateUiState()的函数,该函数接收一个ItemUiState对象且不返回值。此函数使用用户输入的新值更新itemUiState
fun updateUiState(itemDetails: ItemDetails) {
    itemUiState =
        ItemUiState(itemDetails = itemDetails, isEntryValid = validateInput(itemDetails))
}

在此函数中,您将传入的itemDetails分配给itemUiState并更新isEntryValid值。如果itemDetailstrue,则应用会启用保存按钮。您仅在用户输入有效时才将此值设置为true

  1. 转到ItemEditScreen.kt文件。
  2. ItemEditScreen组合中,向下滚动到ItemEntryBody()函数调用。
  3. onItemValueChange参数值设置为新函数updateUiState
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = { },
    modifier = modifier.padding(innerPadding)
)
  1. 运行应用程序。
  2. 转到编辑项目屏幕。
  3. 将其中一个实体值设为空,使其无效。注意保存按钮如何自动禁用。

Item details screen with sell button enabled

Edit Item screen with all text fields in and save button enabled

Edit Item  screen with Save button disabled

  1. 返回ItemEditViewModel类并添加一个名为updateItem()suspend函数,该函数不带参数。您使用此函数将更新的实体保存到 Room 数据库。
suspend fun updateItem() {
}
  1. getUpdatedItemEntry()函数内部,添加一个if条件以使用函数validateInput()验证用户输入。
  2. itemsRepository上的updateItem()函数进行调用,并将itemUiState.itemDetails.toItem()作为参数传递。可以添加到 Room 数据库的实体需要是Item类型。完成的函数如下所示
suspend fun updateItem() {
    if (validateInput(itemUiState.itemDetails)) {
        itemsRepository.updateItem(itemUiState.itemDetails.toItem())
    }
}
  1. 返回ItemEditScreen组合。您需要一个协程作用域来调用updateItem()函数。创建一个名为coroutineScope的 val,并将其设置为rememberCoroutineScope()
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. ItemEntryBody()函数调用中,将onSaveClick函数参数更新为在coroutineScope中启动一个协程。
  2. 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)
)
  1. 运行应用并尝试编辑库存项目。您现在可以编辑库存应用数据库中的任何项目。

Edit item screen item details edited

Item details screen with updated item details

恭喜您创建了第一个使用 Room 管理数据库的应用!

9. 解决方案代码

此 codelab 的解决方案代码位于 GitHub 存储库和下面显示的分支中

10. 了解更多

Android 开发者文档

Kotlin 参考