使用 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) adc468afa54b6e70.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,以便为测试创建测试源代码集。

e53b0f0e0b6aba29.png

  1. New Directory 弹出式窗口中选择 androidTest/kotlin

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

cd95648114520f13.png

6521e8595bb33a91.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

a5009ad021b830ff.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”界面。

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

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

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

商品删除确认弹出式窗口

最后,FAB 按钮 be6c7ed4ac207351.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”界面。

不含已删除商品的“Inventory”界面

修改商品实体

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

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

  • 添加测试,测试用于获取商品的 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”界面

包含空白字段的“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,然后点按 2ae4a1588eba091b.png FAB。
  3. 请注意,这些字段会填充商品详情。
  4. 修改库存数量或任何其他字段,然后点按 Save 按钮。

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

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

包含空白字段的“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 参考文档