Room によるデータの読み取りと更新

1. 始める前に

前の Codelab では、Room 永続ライブラリ(SQLite データベース上の抽象化レイヤ)を使用してアプリデータを保存する方法を学習しました。この Codelab では、Inventory アプリに機能を追加し、Room を使用して SQLite データベースのデータの読み取り、表示、更新、削除を行う方法について学習します。LazyColumn を使用してデータベースのデータを表示し、データベースを構成するデータが変更されると表示データを自動的に更新します。

前提条件

  • Room ライブラリを使用して SQLite データベースの作成と操作ができること。
  • エンティティ クラス、DAO クラス、データベース クラスを作成できること。
  • データ アクセス オブジェクト(DAO)を使用して Kotlin 関数を SQL クエリにマッピングできること。
  • LazyColumn でリストアイテムを表示できること。
  • このユニットで前の Codelab(Room を使用してデータを永続化する)を修了していること。

学習内容

  • SQLite データベースからエンティティを読み取り、表示する方法。
  • Room ライブラリを使用して SQLite データベースのエンティティの更新と削除を行う方法。

作成するアプリの概要

  • 在庫アイテムのリストを表示し、Room を使用してアプリ データベースのアイテムを更新、編集、削除できる Inventory アプリ。

必要なもの

  • 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. UI 状態を更新する

このタスクではアプリに 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 で UI 状態を出力する

ItemDao にメソッド(getItem()getAllItems())を追加してアイテムを取得する際、戻り値の型として Flow を指定します。Flow は通常のデータ ストリームを表すということを思い出してください。Flow を返すと、特定のライフサイクルについて DAO からメソッドを明示的に 1 回呼び出すだけで済みます。Room は、基となるデータの更新を非同期的に処理します。

Flow からデータを取得することを「Flow からの収集」といいます。UI レイヤの Flow から収集する際は、考慮すべき点がいくつかあります。

  • 構成変更(デバイスの回転など)のようなライフサイクル イベントが発生すると、アクティビティが再作成されます。これにより、再コンポーズと Flow からの収集が再度行われます。
  • ライフサイクル イベント間で既存のデータが失われないように、値を状態としてキャッシュに保存することをおすすめします。
  • コンポーザブルのライフサイクルが終了した後など、オブザーバーが残っていない場合は、Flow をキャンセルする必要があります。

ViewModel から Flow を公開するには、StateFlow を使用することをおすすめします。StateFlow を使用すると、UI のライフサイクルに関係なくデータを保存でき、監視できます。FlowStateFlow に変換するには、stateIn 演算子を使用します。

stateIn 演算子には、次に示す 3 つのパラメータがあります。

  • scope - viewModelScope は、StateFlow のライフサイクルを定義します。viewModelScope がキャンセルされると、StateFlow もキャンセルされます。
  • started - パイプラインは、UI が表示されている場合にのみアクティブにする必要があります。そのためには SharingStarted.WhileSubscribed() を使用します。最後のサブスクライバーの消失から共有コルーチンの停止までの遅延(ミリ秒単位)を設定するには、TIMEOUT_MILLISSharingStarted.WhileSubscribed() メソッドに渡します。
  • initialValue - 状態フローの初期値を HomeUiState() に設定します。

FlowStateFlow に変換したら、collectAsState() メソッドを使用して収集し、そのデータを同じ型の State に変換できます。

このステップでは、StateFlow(UI 状態のオブザーバブル API)として、Room データベース内のアイテムをすべて取得します。Room の在庫データが変更されると、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> 型の homeUiState という val を宣言します。 初期化エラーはこの後すぐに解決します。
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 に型の不一致エラーが引き続き表示されます。このエラーは、homeUiStateStateFlow 型であり、getAllItemsStream()Flow を返すことが原因です。

  1. stateIn 演算子を使用して FlowStateFlow に変換します。StateFlow は、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. コンポーズ可能な関数 HomeScreenhomeUiState という val を追加して、HomeViewModel から UI 状態を収集します。collectAsState() を使用します。これにより、StateFlow から値を収集し、State を介してその最新の値を表します。
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue

val homeUiState by viewModel.homeUiState.collectAsState()
  1. HomeBody() 関数呼び出しを更新し、homeUiState.itemListitemList パラメータに渡します。
HomeBody(
    itemList = homeUiState.itemList,
    onItemClick = navigateToItemUpdate,
    modifier = modifier.padding(innerPadding)
)
  1. アプリを実行します。アプリ データベースにアイテムを保存した場合、在庫リストが表示されます。リストが空の場合は、在庫アイテムをアプリ データベースに追加します。

在庫アイテムが表示されたスマートフォンの画面

5. データベースをテストする

コードをテストする重要性については、これまでの Codelab で説明しました。このタスクでは、DAO クエリをテストする単体テストを追加します。その後、Codelab を進めながらテストを追加します。

データベース実装をテストするには、JUnit テストを作成して Android デバイス上で実行することをおすすめします。このテストではアクティビティの作成が必要ないため、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. 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. クラス内に、ItemDao 型と InventoryDatabase 型のプライベート 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. データベースにアイテムを 1 つ追加してから 2 つ追加するユーティリティ関数を追加します。これらの関数は後ほどテストで使用します。suspend としてマークし、コルーチン内で実行できるようにします。
private suspend fun addOneItemToDb() {
    itemDao.insert(item1)
}

private suspend fun addTwoItemsToDb() {
    itemDao.insert(item1)
    itemDao.insert(item2)
}
  1. データベースにアイテムを 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() を使用してデータベースにアイテムを 1 つ追加します。次に、データベースの最初のアイテムを読み取ります。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)
}

上記のテストでは、コルーチン内でデータベースにアイテムを 2 つ追加します。次に、その 2 つのアイテムを読み取り、想定値と比較します。

6. アイテムの詳細を表示する

このタスクでは、エンティティの詳細を読み取って [Item Details] 画面に表示します。Inventory アプリのデータベースから取得した名前、価格、数量などのアイテム UI 状態を使用し、ItemDetailsScreen コンポーザブルで [Item Details] 画面に表示します。あらかじめ用意されているコンポーズ可能な関数 ItemDetailsScreen には、アイテムの詳細を表示する 3 つの Text コンポーザブルが含まれています。

ui/item/ItemDetailsScreen.kt

この画面はスターター コードの一部であり、アイテムの詳細を表示します。これについては、後の Codelab で説明します。この Codelab では、この画面は扱いません。ItemDetailsViewModel.kt は、この画面に対応する ViewModel です。

de7761a894d1b2ab.png

  1. コンポーズ可能な関数 HomeScreenHomeBody() 関数呼び出しに注目してください。navigateToItemUpdateonItemClick パラメータに渡されています。これは、リスト内のアイテムをクリックすると呼び出されます。
// 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 関数のこの部分は、すでに実装されています。リストアイテムをクリックすると、アプリは [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() コンポーザブル内に uiState という val を作成して、UI 状態を収集します。collectAsState() を使用して uiState StateFlow を収集し、State を介して最新の値を表します。Android Studio に未解決の参照エラーが表示されます。
import androidx.compose.runtime.collectAsState

val uiState = viewModel.uiState.collectAsState()
  1. エラーを解決するには、ItemDetailsViewModel クラスに StateFlow<ItemDetailsUiState> 型の uiState という 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. ItemsRepositoryItemDetailsViewModel に渡して、Unresolved reference: itemsRepository エラーを解決します。
class ItemDetailsViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
    ) : ViewModel() {
  1. 次のコード スニペットに示すように、ui/AppViewModelProvider.ktItemDetailsViewModel のイニシャライザを更新します。
initializer {
    ItemDetailsViewModel(
        this.createSavedStateHandle(),
        inventoryApplication().container.itemsRepository
    )
}
  1. ItemDetailsScreen.kt に戻ると、ItemDetailsScreen() コンポーザブルのエラーが解決されています。
  2. ItemDetailsScreen() コンポーザブルで、ItemDetailsBody() 関数呼び出しを更新し、uiState.valueitemUiState 引数に渡します。
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. アイテムの詳細画面を実装する

ui/item/ItemEditScreen.kt

アイテム編集画面は、スターター コードの一部としてすでに用意されています。

このレイアウトには、新しい在庫アイテムの詳細を編集するテキスト フィールド コンポーザブルが含まれています。

アイテム名、アイテム価格、在庫数のフィールドを含むアイテム編集画面のレイアウト

このアプリのコードはまだ完全には機能しません。たとえば、[Item Details] 画面で [Sell] ボタンをタップしても [Quantity in Stock] は増えません。[Delete] ボタンをタップすると確認ダイアログが表示されますが、[Yes] ボタンを選択しても、そのアイテムが実際に削除されるわけではありません。

アイテム削除の確認のポップアップ

最後に、FAB ボタン aad0ce469e4a3a12.png をタップすると空の [Edit Item] 画面が開きます。

空のフィールドを含む [Edit Item] 画面

このセクションでは、[Sell] ボタン、[Delete] ボタン、FAB ボタンの機能を実装します。

8. アイテム販売機能を実装する

このセクションでは、アプリの機能を拡張して販売機能を実装します。この更新では次のタスクを行います。

  • DAO 関数のテストを追加してエンティティを更新します。
  • ItemDetailsViewModel に関数を追加して数量を減らし、アプリ データベースのエンティティを更新します。
  • 数量が 0 の場合は [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. 2 つのエンティティを異なる値で更新し、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 ブロック内で、currentItem という val を作成し、uiState.value.toItem() に設定します。
val currentItem = uiState.value.toItem()

uiState.valueItemUiState 型です。拡張関数 toItem() を使用して、Item エンティティ型に変換します。

  1. quality0 より大きいかどうかを確認する if ステートメントを追加します。
  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 ラムダで、viewModel.reduceQuantityByOne() を呼び出します。
ItemDetailsBody(
    itemUiState = uiState.value,
    onSellItem = { viewModel.reduceQuantityByOne() },
    onDelete = { },
    modifier = modifier.padding(innerPadding)
)
  1. アプリを実行します。
  2. [Inventory] 画面でリスト要素をクリックします。[Item Details] 画面が表示されたら、[Sell] をタップすると数量の値が 1 減ります。

[Item Details] 画面で [Sell] ボタンをタップすると数量が 1 減る

  1. [Item Details] 画面で、数量が 0 になるまで [Sell] ボタンをタップし続けます。

数量が 0 になったら、再度 [Sell] をタップします。数量を更新する前に関数 reduceQuantityByOne() によって数量が 0 より大きいかどうかがチェックされるため、見た目は変わりません。

在庫量が 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. アプリを実行します。在庫量が 0 になると [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. データベースにアイテムを 2 つ追加し、この 2 つのアイテムに対して 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 コンポーザブルに、coroutineScope という val を追加し、rememberCoroutineScope() に設定します。このアプローチでは、呼び出されたコンポジション(ItemDetailsScreen コンポーザブル)にバインドされているコルーチン スコープが返されます。
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. ItemDetailsBody() 関数までスクロールします。
  2. onDelete ラムダ内で 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() 関数までスクロールします。

この関数はスターター コードの一部です。このコンポーザブルは、アイテムを削除する前にユーザーの確認を得るためのアラート ダイアログを表示します。[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. 削除したエンティティがアプリ データベースに存在しなくなっていることを確認します。

これで削除機能が実装されました。

[Alerts] ダイアログ ウィンドウが表示された [Item Details] 画面。

削除したアイテムがなくなった在庫リストが表示されているスマートフォンの画面

アイテム エンティティを編集する

前のセクションと同様に、このセクションではアイテム エンティティを編集するという別の機能をアプリに追加します。

アプリ データベースのエンティティを編集する手順を簡単に示すと、次のようになります。

  • テスト用アイテム取得 DAO クエリにテストを追加します。
  • テキスト フィールドと [Edit Item] 画面にエンティティの詳細を入力します。
  • Room を使用してデータベースのエンティティを更新します。

DAO テストを追加する

  1. ItemDaoTest.kt に、daoGetItem_returnsItemFromDB() というテストを追加します。
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB()
  1. 関数を定義します。コルーチン内で、データベースにアイテムを 1 つ追加します。
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
    addOneItemToDb()
}
  1. itemDao.getItem() 関数を使用してデータベースからエンティティを取得し、item という val に設定します。
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] ボタンをタップすると数量が 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)
    }
}

この launch ブロックに、null でない値のみを含むフローを返すフィルタを追加します。toItemUiState() を使用して、item エンティティを ItemUiState に変換します。actionEnabled 値を true として渡し、[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 73b88f16638608f0.png をタップします。
  3. フィールドにアイテムの詳細が入力されます。
  4. 在庫量または他のフィールドを編集し、[Save] をタップします。

何も起こりません。アプリ データベースのエンティティを更新していないためです。これは次のセクションで修正します。

[Item Details] 画面で [Sell] ボタンをタップすると数量が 1 減る

空のフィールドを含む [Edit Item] 画面

Room を使用してエンティティを更新する

この最後のタスクでは、コードの最後の要素を追加して更新機能を実装します。ViewModel で必要な関数を定義し、ItemEditScreen で使用します。

それではまたコーディングしてみましょう。

  1. ItemEditViewModel クラスに、ItemUiState オブジェクトを受け取って何も返さない updateUiState() という関数を追加します。この関数は、ユーザーが入力した新しい値で itemUiState を更新します。
fun updateUiState(itemDetails: ItemDetails) {
    itemUiState =
        ItemUiState(itemDetails = itemDetails, isEntryValid = validateInput(itemDetails))
}

この関数では、渡された itemDetailsitemUiState に割り当て、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() を渡します。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. アプリを実行し、在庫アイテムを編集してみましょう。Inventory アプリのデータベース内のアイテムを編集できるようになりました。

[Edit Item] 画面のアイテムの詳細を編集しました

更新されたアイテムの詳細が表示された [アイテムの詳細] 画面

Room を使用してデータベースを管理する初めてのアプリを作成しました。

9. 解答コード

この Codelab の解答コードは、以下に示す GitHub リポジトリとブランチにあります。

10. 関連リンク

Android デベロッパー ドキュメント

Kotlin リファレンス