使用 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 会以异步方式处理底层数据的更新。

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

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

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

stateIn 运算符有三个参数,如下所示

  • scope - viewModelScope 定义 StateFlow 的生命周期。当 viewModelScope 被取消时,StateFlow 也会被取消。
  • started - 只有当 UI 可见时,管道才应处于活动状态。SharingStarted.WhileSubscribed() 用于实现此目的。要配置上次订阅者消失和共享协程停止之间的延迟(以毫秒为单位),请将 TIMEOUT_MILLIS 传入 SharingStarted.WhileSubscribed() 方法。
  • initialValue - 将状态流的初始值设为 HomeUiState()

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

在此步骤中,您将检索 Room 数据库中的所有项目,将其作为 UI 状态的 StateFlow 可观察 API。当 Room Inventory 数据更改时,UI 会自动更新。

  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 类中,声明一个类型为 StateFlow<HomeUiState>val,名为 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. 测试您的数据库

之前的 Codelabs 讨论了测试代码的重要性。在此任务中,您将添加一些单元测试来测试您的 DAO 查询,然后随着 Codelab 的进展添加更多测试。

测试数据库实现的推荐方法是编写一个在 Android 设备上运行的 JUnit 测试。由于这些测试无需创建 Activity,因此它们的执行速度比 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 的 private 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() 函数。这样做是因为信息无需持久化,而是在进程被终止时需要删除。您正在主线程中运行 DAO 查询,并使用 .allowMainThreadQueries(),仅用于测试。

  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 中,向类型为 ItemDetailsViewModelItemDetailsScreen 可组合项添加新参数,并使用工厂方法对其进行初始化。
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 类中创建一个类型为 StateFlow<ItemDetailsUiState>val,名为 uiState
  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. 运行应用。当您点击库存屏幕上的任何列表元素时,会显示项目详细信息屏幕。
  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 语句,检查 quantity 是否大于 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. 库存屏幕上,点击一个列表元素。当显示项目详细信息屏幕时,点按售出,注意数量值会减少一。

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. 运行测试并确保其通过。

填充文本字段

如果您运行应用,转到项目详细信息屏幕,然后点击 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 块中,您添加一个过滤器,以返回只包含非空值的 flow。使用 toItemUiState(),您将 item 实体转换为 ItemUiState。您将 actionEnabled 的值作为 true 传入,以启用保存按钮。

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

  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 的值。如果 isEntryValid 的值为 true,则应用会启用保存按钮。只有用户输入有效时,您才将此值设置为 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. updateItem() 函数内部,添加一个 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 参考文档