使用 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 界面向应用数据库添加数据。

已填写商品详情的“Add Item”界面。

显示空白商品目录的手机屏幕

在此 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. 更新界面状态

在此任务中,您将向应用添加一个 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) 7b1535d90ee957fa.png
  • HomeBody() 可组合函数

HomeBody() 可组合函数会根据传入的列表显示商品目录商品。在起始代码实现中,我们将空列表 (listOf()) 传递给了 HomeBody() 可组合函数。如需将商品目录列表传递给此可组合函数,您必须从存储库中检索商品目录数据,并将其传入 HomeViewModel

HomeViewModel 中发出界面状态

当您向 ItemDao 添加用于获取商品的 getItem()getAllItems() 方法时,您将 Flow 指定为返回值类型。回想一下,Flow 代表通用数据流。通过返回 Flow,您只需在指定生命周期内明确调用 DAO 中的方法一次即可。Room 以异步方式处理底层数据的更新。

从数据流中获取数据的过程称为收集数据流。从界面层中的数据流收集数据时,需要考虑一些事项。

  • 配置更改等生命周期事件(例如旋转设备)会导致重新创建 activity,进而导致重组,并从您的 Flow 重新收集数据。
  • 建议您将值缓存为状态,这样现有数据就不会在生命周期事件之间丢失。
  • 如果没有任何观察器(例如在可组合项的生命周期结束后),则应取消数据流。

如需从 ViewModel 公开 Flow,推荐使用 StateFlow。无论界面生命周期如何,使用 StateFlow 均可保存和观察数据。如需将 Flow 转换为 StateFlow,您可以使用 stateIn 操作符。

stateIn 操作符有三个参数,如下所述:

  • scope - viewModelScope 定义了 StateFlow 的生命周期。取消 viewModelScope 后,StateFlow 也会取消。
  • started - 仅当界面可见时,流水线才应有效。为此,请使用 SharingStarted.WhileSubscribed()。如需配置从最后一个订阅者消失到停止共享协程之间的延迟时间(以毫秒为单位),请将 TIMEOUT_MILLIS 传递给 SharingStarted.WhileSubscribed() 方法。
  • initialValue - 将状态流的初始值设置为 HomeUiState()

Flow 转换为 StateFlow 后,您可以使用 collectAsState() 方法对其进行收集,并将其数据转换为相同类型的 State

在此步骤中,您将检索 Room 数据库中的所有商品,作为界面状态的 StateFlow 可观察 API。当 Room Inventory 数据发生更改时,界面会自动更新。

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

您现在会收到一个“Unresolved reference: itemsRepository”错误。如需解决“Unresolved reference”错误,您需要将 ItemsRepository 对象传递给 HomeViewModel

  1. 将类型为 ItemsRepository 的构造函数参数添加到 HomeViewModel 类中。
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 是界面状态的可观察 API,可让界面自行更新。
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 中的界面状态。

  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 收集界面状态。您将使用 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. 运行应用。请注意,如果您已在应用数据库中保存了商品,系统会显示商品目录列表。如果列表为空,请将一些商品目录商品添加到应用数据库。

显示商品目录商品的手机屏幕

5. 测试您的数据库

之前的 Codelab 讨论了测试代码的重要性。在此任务中,您将添加一些单元测试来测试 DAO 查询,然后按照此 Codelab 的步骤继续操作时,还将添加更多测试。

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

  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. ItemDaoTest 类添加 @RunWith(AndroidJUnit4::class) 注解。现在,您的类如以下示例代码所示:
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 界面上显示实体详情。您将使用 Inventory 应用数据库中的商品界面状态(例如名称、价格和数量),并使用 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 参数。此参数将导航目的地指定为“Item Details”界面。
// No need to copy over
HomeScreen(
    navigateToItemEntry = { navController.navigate(ItemEntryDestination.route) },
    navigateToItemUpdate = {
        navController.navigate("${ItemDetailsDestination.route}/${it}")
   }

我们已为您实现 onItemClick 功能的这一部分。点击列表项后,应用会转到“Item Details”界面。

  1. 点击商品目录列表中的任意商品,即可查看包含空白字段的“Item Details”界面。

包含空白数据的“Item Details”界面

若要使用商品详情填充文本字段,您需要在 ItemDetailsScreen() 中收集界面状态。

  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 来收集界面状态。使用 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() 的实现。您将当前选定的 itemItemDetailsBody() 传递到 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”界面

  1. 点按 Sell 按钮。毫无反应!

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

7. 实现“Item Details”界面

ui/item/ItemEditScreen.kt

在起始代码中已经为您提供了“Edit Item”界面。

此布局包含一些文本字段可组合项,这些可组合项用于修改商品目录中任何新商品的详情。

编辑商品布局,其中包含商品名称、商品价格和库存数量字段

此应用的代码仍然没有完全正常运行。例如,在 Item Details 界面中,当您点按 Sell 按钮时,Quantity in Stock 不会减少。当您点按 Delete 按钮时,应用会显示确认对话框。但是,当您选择 Yes 按钮时,应用实际上不会删除该商品。

商品删除确认弹出式窗口

最后,FAB 按钮 aad0ce469e4a3a12.png 会打开一个空的 Edit Item 界面。

包含空白字段的“Edit Item”界面

在本部分中,您将实现 SellDelete 和 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.ktItemDetailsViewModel 类中,添加一个名为 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。

当用户点按“Sell”按钮后,“Item Details”界面中的数量会减少 1

  1. Item Details 界面中,持续点按 Sell 按钮,直到数量为零。

数量降至零后,再次点按 Sell。没有外观变化,因为函数 reduceQuantityByOne() 会在更新数量之前检查数量是否大于零。

“Quantity in stock”为 0 的“Item Details”界面

为了向用户提供更好的反馈,您可能需要在没有可供销售的商品时停用 Sell 按钮。

  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. 运行应用。请注意,当库存数量为零时,应用会停用 Sell 按钮。

已禁用“Sell”按钮的“Item Details”界面

恭喜您为应用实现了商品销售功能。

删除商品实体

与上一个任务一样,您必须实现删除功能,进一步扩展应用的功能。与销售功能相比,此功能更易于实现。此过程涉及以下任务:

  • 为删除 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. 删除商品后,返回到“Inventory”界面。
  2. deleteItem() 函数调用后调用 navigateBack()
onDelete = {
    coroutineScope.launch {
        viewModel.deleteItem()
        navigateBack()
    }
  1. 仍在 ItemDetailsScreen.kt 文件中,滚动到 ItemDetailsBody() 函数。

起始代码已包含此函数。此可组合项会显示一个提醒对话框,用于在删除商品之前获取用户确认,并在您点按 Yes 时调用 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()
                },
                //...
            )
        }
    }
}

当您点按 No 时,应用会关闭提醒对话框。showConfirmationDialog() 函数会显示以下提醒:

商品删除确认弹出式窗口

  1. 运行应用。
  2. Inventory 界面上选择一个列表元素。
  3. Item Details 界面中,点按 Delete
  4. 点按提醒对话框中的 Yes,应用会返回到 Inventory 界面。
  5. 确认您删除的实体不再位于应用数据库中。

恭喜您实现了删除功能!

包含提醒对话框窗口的“Item Details”界面。

显示商品目录列表但未显示已删除商品的手机屏幕

修改商品实体

与前面的部分类似,在此部分中,您将向应用中添加另一项用于修改商品实体的增强功能。

下面,我们将快速过一遍修改应用数据库中实体的步骤:

  • 添加测试,测试用于获取商品的 DAO 查询。
  • 使用实体详情填充 Edit Item 界面中的文本字段。
  • 使用 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 界面,然后点击 FAB,您可以看到界面标题现在为 Edit Item。不过,所有文本字段均为空。在此步骤中,您将使用实体详情填充 Edit Item 界面中的文本字段。

当用户点按“Sell”按钮后,“Item Details”界面中的数量会减少 1

包含空白字段的“Edit Item”界面

  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)
    }
}

在此发布块中,您添加一个过滤条件,以返回仅包含非 null 值的数据流。借助 toItemUiState(),您可以将 item 实体转换为 ItemUiState。将 actionEnabled 值作为 true 传递,以启用 Save 按钮。

如需解决 Unresolved reference: itemsRepository 错误,您需要将 ItemsRepository 作为依赖项传入 ViewModel。

  1. 将构造函数参数添加到 ItemEditViewModel 类。
class ItemEditViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
)
  1. AppViewModelProvider.kt 文件的 ItemEditViewModel 初始化程序中,将 ItemsRepository 对象添加为参数。
initializer {
    ItemEditViewModel(
        this.createSavedStateHandle(),
        inventoryApplication().container.itemsRepository
    )
}
  1. 运行应用。
  2. 前往 Item Details,然后点按 73b88f16638608f0.png FAB。
  3. 请注意,这些字段会填充商品详情。
  4. 修改库存数量或任何其他字段,然后点按 Save 按钮。

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

当用户点按“Sell”按钮后,“Item Details”界面中的数量会减少 1

包含空白字段的“Edit Item”界面

使用 Room 更新实体

在最后这个任务中,您将添加最后几段代码以实现更新功能。您将在 ViewModel 中定义必要的函数,并在 ItemEditScreen 中使用这些函数。

又到编写代码的时候了!

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

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

  1. 转到 ItemEditScreen.kt 文件。
  2. ItemEditScreen 可组合项中,向下滚动到 ItemEntryBody() 函数调用。
  3. onItemValueChange 参数值设为新函数 updateUiState
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = { },
    modifier = modifier.padding(innerPadding)
)
  1. 运行应用。
  2. 转到 Edit Item 界面。
  3. 将其中一个实体值留空,使其无效。请注意 Save 按钮如何自动停用。

已启用“Sell”按钮的“Item Details”界面

已填写所有文本字段并已启用“Save”按钮的“Edit Item”界面

已禁用“Save”按钮的“Edit Item”界面

  1. 返回 ItemEditViewModel 类,添加一个名为 updateItem()suspend 函数,该函数不接受任何参数。您可以使用此函数将更新后的实体保存到 Room 数据库。
suspend fun updateItem() {
}
  1. getUpdatedItemEntry() 函数内,添加一个 if 条件,通过使用函数 validateInput() 来验证用户输入。
  2. itemsRepository 调用 updateItem() 函数,并传入 itemUiState.itemDetails.toItem()。实体必须为 Item 类型,才能添加到 Room 数据库中。完成后的函数如下所示:
suspend fun updateItem() {
    if (validateInput(itemUiState.itemDetails)) {
        itemsRepository.updateItem(itemUiState.itemDetails.toItem())
    }
}
  1. 返回到 ItemEditScreen 可组合项。您需要使用协程作用域来调用 updateItem() 函数。创建一个名为 coroutineScope 的变量并将其设置为 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. 运行应用,并尝试修改商品目录商品。您现在可以修改 Inventory 应用数据库中的任何商品。

已修改了商品详情的“Edit Item”界面

已更新商品详情的“Item Details”界面

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

9. 解决方案代码

此 Codelab 的解决方案代码位于下方所示的 GitHub 代码库分支中:

10. 了解更多内容

Android 开发者文档

Kotlin 参考文档