透過 Room 讀取及更新資料

1. 事前準備

在先前的程式碼研究室中,您學會了如何使用 Room 持續性程式庫儲存應用程式資料,這個程式庫是位於 SQLite 資料庫頂端的抽象層。在本程式碼研究室中,您要為商品目錄應用程式新增更多功能,並學習如何使用 Room 讀取、顯示、更新及刪除 SQLite 資料庫中的資料。您將使用 LazyColumn 顯示資料庫中的資料,並根據資料庫內的基礎資料異動情況,自動進行更新。

必要條件

  • 能夠使用 Room 程式庫建立 SQLite 資料庫並與之互動。
  • 能夠建立實體、資料存取物件 (DAO) 和資料庫類別。
  • 能夠使用 DAO 將 Kotlin 函式對應至 SQL 查詢。
  • 能夠在 LazyColumn 中顯示清單項目。
  • 完成本單元中先前的程式碼研究室:使用 Room 保存資料

課程內容

  • 如何讀取及顯示 SQLite 資料庫中的實體。
  • 如何使用 Room 程式庫更新和刪除 SQLite 資料庫中的實體。

建構項目

  • 您建構的商品目錄應用程式會顯示庫存商品清單,並可透過 Room 更新、編輯與刪除應用程式資料庫中的商品。

軟硬體需求

  • 搭載 Android Studio 的電腦

2. 範例應用程式總覽

本程式碼研究室採用先前程式碼研究室 (使用 Room 保存資料) 的商品目錄應用程式解決方案程式碼,做為範例程式碼。此範例應用程式已使用 Room 持續性程式庫儲存資料。使用者可以在「Add Item」畫面上,將資料加入應用程式資料庫。

「Add Item」畫面顯示空白文字欄位

手機螢幕顯示沒有庫存商品

在本程式碼研究室中,您要擴充應用程式,以便使用 Room 程式庫讀取與顯示資料,以及更新與刪除資料庫中的實體。

下載本程式碼研究室的範例程式碼

首先請下載範例程式碼:

$ 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 中開啟。

如要瀏覽本程式碼研究室的解決方案程式碼,請前往 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 中輸出使用者介面狀態

為了取得商品,您曾將 getItem()getAllItems() 方法加入 ItemDao,當時已指定 Flow 做為傳回類型。提醒您,Flow 代表一般的資料串流。透過傳回 Flow,您只需在指定生命週期內明確呼叫 DAO 中的方法一次。Room 會以非同步的方式處理基礎資料的更新作業。

自資料流取得資料的做法稱為「從資料流收集」。在使用者介面層中執行這項操作時,請注意以下事項。

  • 設定變更等生命週期事件 (例如旋轉裝置) 會導致重新建立活動,進而造成重組,以及重新從 Flow 收集資料。
  • 建議您將值快取為狀態,以免在生命週期事件之間遺失現有資料。
  • 如果沒有剩餘的觀察器 (例如可組合項的生命週期結束後),就應該取消資料流。

如要從 ViewModel 公開 Flow,建議您使用 StateFlow。無論使用者介面生命週期處於哪個階段,您皆可透過 StateFlow 儲存及觀測資料。如要將 Flow 轉換為 StateFlow,請使用 stateIn 運算子。

stateIn 運算子有三個參數,說明如下:

  • scopeviewModelScope 定義了 StateFlow 的生命週期。取消 viewModelScope 時,也會一併取消 StateFlow
  • started:只有在顯示使用者介面時,才應啟用管道。SharingStarted.WhileSubscribed() 可用於完成這項操作。如要設定最後一個訂閱者消失與共用協同程式停止之間的延遲時間 (以毫秒為單位),請將 TIMEOUT_MILLIS 傳入 SharingStarted.WhileSubscribed() 方法。
  • initialValue:將狀態資料流的初始值設為 HomeUiState()

Flow 轉換為 StateFlow 後,您可以使用 collectAsState() 方法從資料流收集資料,並轉換成相同類型的 State

在這個步驟中,您將擷取 Room 資料庫中的所有商品,做為使用者介面狀態的 StateFlow 可觀測 API。一旦 Room 商品目錄資料有所變更,使用者介面就會自動更新。

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

現在會收到以下錯誤:Unresolved reference: itemsRepository。如要解決這項錯誤,您需要將 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. 測試資料庫

先前的程式碼研究室說明了測試程式碼的重要性。在這項工作中,您要新增單元測試來檢測 DAO 查詢,之後按照本程式碼研究室的步驟操作時,也將添加更多測試。

如要測試資料庫的實作方式,建議您編寫在 Android 裝置上執行的 JUnit 測試。由於這類測試不需建立活動,因此執行速度比 UI 測試更快。

  1. build.gradle.kts (Module :app) 檔案中,留意下列 Espresso 和 JUnit 依附元件。
// Testing
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
  1. 切換至「Project」檢視畫面,在「src」上按一下滑鼠右鍵,然後依序點選「New」>「Directory」,建立測試的來源集。

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」畫面上讀取並顯示實體詳細資料。請使用商品目錄應用程式資料庫中的名稱、價格和數量等商品使用者介面狀態,然後透過 ItemDetailsScreen 可組合項,在「Item Details」畫面上顯示這些資訊。ItemDetailsScreen 是為您預先編寫的可組合函式,包含三個顯示商品詳細資料的 Text 可組合函式。

ui/item/ItemDetailsScreen.kt

這個畫面是範例程式碼的一部分,會顯示商品詳細資料,您將在後續程式碼研究室中看到這些內容。在本程式碼研究室中,您不會處理這個畫面。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() 中收集 UI 狀態。

  1. UI/Item/ItemDetailsScreen.kt 中,將新參數加入 ItemDetailsViewModel 類型的 ItemDetailsScreen 可組合項,然後使用工廠方法進行初始化。
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 類別中建立名為 uiStateStateFlow<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」畫面的文字欄位已不再空白,而是顯示擷取自商品目錄資料庫的實體詳細資料。

「Item Details」畫面顯示已填入商品詳細資料

  1. 輕觸「Sell」按鈕,但這個按鈕沒有任何作用。

在後續部分中,您將實作「Sell」按鈕的功能。

7. 實作商品詳細資料畫面

ui/item/ItemEditScreen.kt

範例程式碼中已提供商品編輯畫面。

這個版面配置包含文字欄位可組合函式,用於編輯新庫存商品的詳細資料。

「Edit Item」畫面顯示空白欄位

這個應用程式的程式碼還無法完全正常運作。舉例來說,如果輕觸「Item Details」畫面上的「Sell」按鈕,「Quantity in Stock」的值不會減少。當您輕觸「Delete」按鈕時,應用程式會顯示確認對話方塊,不過在您選取「Yes」按鈕後,應用程式並不會刪除商品。

確認刪除項目的彈出式視窗

最後,FAB 按鈕 be6c7ed4ac207351.png 會開啟內含空白欄位的「Edit Item」畫面。

「Edit Item」畫面顯示空白欄位

在下一個節中,您將實作「Sell」、「Delete」和 FAB 按鈕的功能。

8. 實作銷售商品的功能

在這個部分中,您要實作銷售功能來擴充應用程式。這項更新程序涵蓋以下工作:

  • 新增測試,讓 DAO 函式更新實體。
  • ItemDetailsViewModel 中新增函式,用於減少庫存數量及更新應用程式資料庫內的實體。
  • 在庫存數量為零時停用「Sell」按鈕。
  1. ItemDaoTest.kt 中新增不含參數的 daoUpdateItems_updatesItemsInDB() 函式,並加上 @Test@Throws(Exception::class) 註解。
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB()
  1. 定義函式並建立「runBlocking區塊,然後在該區塊中呼叫 addTwoItemsToDb()
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
    addTwoItemsToDb()
}
  1. 呼叫 itemDao.update,使用不同的值更新兩個實體。
itemDao.update(Item(1, "Apples", 15.0, 25))
itemDao.update(Item(2, "Bananas", 5.0, 50))
  1. 使用 itemDao.getAllItems() 擷取實體,並比較這些內容與更新後的實體及斷言。
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], Item(1, "Apples", 15.0, 25))
assertEquals(allItems[1], Item(2, "Bananas", 5.0, 50))
  1. 確認完成的函式如下所示:
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
    addTwoItemsToDb()
    itemDao.update(Item(1, "Apples", 15.0, 25))
    itemDao.update(Item(2, "Bananas", 5.0, 50))

    val allItems = itemDao.getAllItems().first()
    assertEquals(allItems[0], Item(1, "Apples", 15.0, 25))
    assertEquals(allItems[1], Item(2, "Bananas", 5.0, 50))
}
  1. 執行測試,並確認可通過測試。

ViewModel 中新增函式

  1. ItemDetailsViewModel.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(),將 uiState.value 轉換為 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」,您會發現庫存數量值減少一。

使用者輕觸「Sell」按鈕後,「Item Details」畫面上的庫存數量值減少一

  1. 在「Item Details」畫面上繼續輕觸「Sell」按鈕,直到庫存數量變成零。

庫存數量變成零後,再次輕觸「Sell」。畫面上不會發生任何變化,因為 reduceQuantityByOne() 函式會先檢查庫存數量是否大於零,再更新數量。

「Item Details」畫面顯示庫存數量為 0

為了讓使用者更清楚得知能否銷售商品,建議您在商品沒有庫存時停用「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」按鈕。

「Item Details」畫面顯示停用的「Sell」按鈕

恭喜您在應用程式中實作了販售商品的功能!

刪除商品實體

與先前的工作相同,您也必須實作刪除功能,進一步擴充應用程式。相較於銷售功能,刪除功能的實作方式簡單許多。這項程序涵蓋以下工作:

  • 新增測試,檢測用於刪除商品的 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」畫面的文字欄位填入實體詳細資料。

「Item Details」畫面顯示停用的「Sell」按鈕

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

在這個啟動區塊中,您新增了篩選器,傳回只含有非空值的資料流。接著使用 toItemUiState(),將 item 實體轉換為 ItemUiState。此外,也傳遞設為 trueactionEnabled 值,啟用「Save」按鈕。

為了解決 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. 前往「Item Details」頁面,然後輕觸 FAB 2ae4a1588eba091b.png
  3. 您會發現文字欄位已填入商品詳細資料。
  4. 編輯庫存數量或任何其他欄位的內容,然後輕觸「Save」

此按鈕沒有任何作用,這是因為您並未更新應用程式資料庫中的實體。您將在下一節中修正此問題。

「Item Details」畫面顯示停用的「Sell」按鈕

「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」按鈕。

「Item Details」畫面顯示啟用的「Sell」按鈕

「Edit Item」畫面顯示所有文字欄位和啟用的「Save」按鈕

「Edit Item」畫面顯示停用的「Save」按鈕

  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 的 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」畫面商品詳細資料

「Item Details」畫面顯示更新後的商品詳細資料

恭喜您成功建立第一個使用 Room 管理資料庫的應用程式!

9. 解決方案程式碼

本程式碼研究室的解決方案程式碼位於下列 GitHub 存放區和分支中:

10. 瞭解詳情

Android 開發人員說明文件

Kotlin 參考資料