使用 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
  • The HomeBody() 可组合函数

The HomeBody() 可组合函数根据传入的列表显示库存项目。作为初始代码实现的一部分,一个空列表 (listOf()) 被传递到 HomeBody() 可组合函数。要将库存列表传递到此可组合函数,您必须从存储库检索库存数据并将其传递到 HomeViewModel 中。

HomeViewModel 中发出 UI 状态

当您向 ItemDao 添加获取项目的方法 - getItem()getAllItems() - 时,您指定了 Flow 作为返回类型。回想一下,Flow 表示数据流。通过返回 Flow,您只需为给定的生命周期显式调用一次 DAO 中的方法。Room 以异步方式处理基础数据的更新。

从流中获取数据称为“从流中收集”。在 UI 层从流中收集时,需要考虑以下几点。

  • 生命周期事件(例如配置更改,例如旋转设备)会导致活动重新创建。这会导致重新组合并再次从您的 Flow 中收集。
  • 您希望将值缓存为状态,以便在生命周期事件之间不会丢失现有数据。
  • 如果不再有观察者(例如,在可组合函数的生命周期结束后),则应取消流。

ViewModel 公开 Flow 的推荐方法是使用 StateFlow。使用 StateFlow 允许保存和观察数据,而不管 UI 生命周期如何。要将 Flow 转换为 StateFlow,可以使用 stateIn 运算符。

The 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. 显示库存数据

在本任务中,您将在 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. 切换到**项目**视图,右键单击**src** > **新建** > **目录**,为您的测试创建一个测试源集。

9121189f4a0d2613.png

  1. 从**新建目录**弹出窗口中选择**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. 显示项目详细信息

在此任务中,您将在**项目详细信息**屏幕上读取和显示实体详细信息。您将使用库存应用数据库中的项目 UI 状态(例如名称、价格和数量),并使用 ItemDetailsScreen 可组合函数在**项目详细信息**屏幕上显示它们。 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 类中创建一个名为 uiStateval,类型为 StateFlow<ItemDetailsUiState>
  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. 运行应用。当您单击**库存**屏幕上的任何列表元素时,将显示**项目详细信息**屏幕。
  2. 注意,屏幕不再为空白。它显示从库存数据库检索到的实体详细信息。

Item details screen with valid item details

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

在下一部分中,您将实现**出售**按钮的功能。

7. 实现项目详细信息屏幕

ui/item/ItemEditScreen.kt

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

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

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

此应用的代码仍未完全发挥功能。例如,在**项目详细信息**屏幕上,当您点击**出售**按钮时,**库存数量**不会减少。当您点击**删除**按钮时,应用会提示您确认对话框。但是,当您选择**是**按钮时,应用实际上并没有删除该项目。

item delete confirmation pop up

最后,FAB 按钮 aad0ce469e4a3a12.png 会打开一个空的**编辑项目**屏幕。

Edit item screen with empty felids

在本节中,您将实现**出售**、**删除**和 FAB 按钮的功能。

8. 实现出售项目

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

  • 为 DAO 函数添加一个测试,用于更新实体。
  • ItemDetailsViewModel 中添加一个函数,以减少数量并在应用数据库中更新实体。
  • 如果数量为零,则禁用**出售**按钮。
  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. 在“**库存**”屏幕上,点击一个列表元素。当“**商品详情**”屏幕显示时,点击“**出售**”,注意数量值减少了 1。

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

  1. 在“**商品详情**”屏幕上,持续点击“**出售**”按钮,直到数量为零。

数量达到零后,再次点击“**出售**”。没有视觉变化,因为函数 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. 运行测试并确保其通过。

填充文本字段

如果运行应用程序,转到“**商品详情**”屏幕,然后点击悬浮操作按钮,您会发现屏幕的标题现在是“**编辑商品**”。但是,所有文本字段都是空的。在此步骤中,您将使用实体详细信息填充“**编辑商品**”屏幕中的文本字段。

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 悬浮操作按钮。
  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() 函数。创建一个名为 coroutineScopeval 并将其设置为 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. 解决方案代码

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

10. 了解更多

Android 开发者文档

Kotlin 参考