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] 画面を使用してアプリ データベースにデータを追加できます。
この 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)
- コンポーズ可能な関数
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 のライフサイクルに関係なくデータを保存でき、監視できます。Flow
を StateFlow
に変換するには、stateIn
演算子を使用します。
stateIn
演算子には、次に示す 3 つのパラメータがあります。
scope
-viewModelScope
は、StateFlow
のライフサイクルを定義します。viewModelScope
がキャンセルされると、StateFlow
もキャンセルされます。started
- パイプラインは、UI が表示されている場合にのみアクティブにする必要があります。そのためにはSharingStarted.WhileSubscribed()
を使用します。最後のサブスクライバーの消失から共有コルーチンの停止までの遅延(ミリ秒単位)を設定するには、TIMEOUT_MILLIS
をSharingStarted.WhileSubscribed()
メソッドに渡します。initialValue
- 状態フローの初期値をHomeUiState()
に設定します。
Flow
を StateFlow
に変換したら、collectAsState()
メソッドを使用して収集し、そのデータを同じ型の State
に変換できます。
このステップでは、StateFlow
(UI 状態のオブザーバブル API)として、Room データベース内のアイテムをすべて取得します。Room の在庫データが変更されると、UI が自動的に更新されます。
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())
HomeViewModel
クラス内で、StateFlow<HomeUiState>
型のhomeUiState
というval
を宣言します。 初期化エラーはこの後すぐに解決します。
val homeUiState: StateFlow<HomeUiState>
itemsRepository
に対してgetAllItemsStream()
を呼び出し、宣言したhomeUiState
に割り当てます。
val homeUiState: StateFlow<HomeUiState> =
itemsRepository.getAllItemsStream()
「Unresolved reference: itemsRepository」というエラーが発生します。この未解決の参照エラーを解決するには、ItemsRepository
オブジェクトを HomeViewModel
に渡す必要があります。
ItemsRepository
型のコンストラクタ パラメータをHomeViewModel
クラスに追加します。
import com.example.inventory.data.ItemsRepository
class HomeViewModel(itemsRepository: ItemsRepository): ViewModel() {
ui/AppViewModelProvider.kt
ファイルのHomeViewModel
イニシャライザで、次のようにItemsRepository
オブジェクトを渡します。
initializer {
HomeViewModel(inventoryApplication().container.itemsRepository)
}
HomeViewModel.kt
ファイルに戻ります。型の不一致エラーが発生します。これを解決するには、次のように変換マップを追加します。
val homeUiState: StateFlow<HomeUiState> =
itemsRepository.getAllItemsStream().map { HomeUiState(it) }
Android Studio に型の不一致エラーが引き続き表示されます。このエラーは、homeUiState
が StateFlow
型であり、getAllItemsStream()
が Flow
を返すことが原因です。
stateIn
演算子を使用してFlow
をStateFlow
に変換します。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()
)
- アプリをビルドして、コードにエラーがないことを確認します。表示は変わりません。
4. 在庫データを表示する
このタスクでは、HomeScreen
で UI 状態を収集し、更新します。
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)
)
- コンポーズ可能な関数
HomeScreen
にhomeUiState
というval
を追加して、HomeViewModel
から UI 状態を収集します。collectAsState
()
を使用します。これにより、StateFlow
から値を収集し、State
を介してその最新の値を表します。
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
val homeUiState by viewModel.homeUiState.collectAsState()
HomeBody()
関数呼び出しを更新し、homeUiState.itemList
をitemList
パラメータに渡します。
HomeBody(
itemList = homeUiState.itemList,
onItemClick = navigateToItemUpdate,
modifier = modifier.padding(innerPadding)
)
- アプリを実行します。アプリ データベースにアイテムを保存した場合、在庫リストが表示されます。リストが空の場合は、在庫アイテムをアプリ データベースに追加します。
5. データベースをテストする
コードをテストする重要性については、これまでの Codelab で説明しました。このタスクでは、DAO クエリをテストする単体テストを追加します。その後、Codelab を進めながらテストを追加します。
データベース実装をテストするには、JUnit テストを作成して Android デバイス上で実行することをおすすめします。このテストではアクティビティの作成が必要ないため、UI テストよりも高速に実行できます。
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")
- [Project] ビューに切り替え、[src] > [New] > [Directory] を右クリックして、テスト用にテスト ソースセットを作成します。
- [New Directory] ポップアップで [androidTest/kotlin] を選択します。
ItemDaoTest.kt
という Kotlin クラスを作成します。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 {
}
- クラス内に、
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
- データベースを作成するための関数を追加して
@Before
アノテーションを付け、どのテストよりも前に実行できるようにします。 - メソッド内で、
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 クエリを実行しています。
- データベースを閉じるために別の関数を追加します。
@After
アノテーションを付けて、データベースを閉じ、それぞれのテストの後に実行します。
import org.junit.After
import java.io.IOException
@After
@Throws(IOException::class)
fun closeDb() {
inventoryDatabase.close()
}
- 次のコード例に示すように、使用するデータベース用にクラス
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 つ追加してから 2 つ追加するユーティリティ関数を追加します。これらの関数は後ほどテストで使用します。
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()
を使用してデータベースにアイテムを 1 つ追加します。次に、データベースの最初のアイテムを読み取ります。assertEquals()
を使用して想定値と実際値を比較します。runBlocking{}
を使用して新しいコルーチンでテストを実行します。このためにユーティリティ関数を suspend
としてマークします。
- テストを実行し、合格することを確認します。
- データベースから
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
です。
- コンポーズ可能な関数
HomeScreen
のHomeBody()
関数呼び出しに注目してください。navigateToItemUpdate
がonItemClick
パラメータに渡されています。これは、リスト内のアイテムをクリックすると呼び出されます。
// No need to copy over
HomeBody(
itemList = homeUiState.itemList,
onItemClick = navigateToItemUpdate,
modifier = modifier
.padding(innerPadding)
.fillMaxSize()
)
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] 画面に移動します。
- 在庫リストのアイテムをクリックすると、フィールドが空の状態で [Item Details] 画面が表示されます。
テキスト フィールドにアイテムの詳細を入力するには、ItemDetailsScreen()
で UI 状態を収集する必要があります。
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)
)
ItemDetailsScreen()
コンポーザブル内にuiState
というval
を作成して、UI 状態を収集します。collectAsState()
を使用してuiState
StateFlow
を収集し、State
を介して最新の値を表します。Android Studio に未解決の参照エラーが表示されます。
import androidx.compose.runtime.collectAsState
val uiState = viewModel.uiState.collectAsState()
- エラーを解決するには、
ItemDetailsViewModel
クラスにStateFlow<ItemDetailsUiState>
型のuiState
というval
を作成します。 - アイテム リポジトリからデータを取得し、拡張関数
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()
)
ItemsRepository
をItemDetailsViewModel
に渡して、Unresolved reference: itemsRepository
エラーを解決します。
class ItemDetailsViewModel(
savedStateHandle: SavedStateHandle,
private val itemsRepository: ItemsRepository
) : ViewModel() {
- 次のコード スニペットに示すように、
ui/AppViewModelProvider.kt
でItemDetailsViewModel
のイニシャライザを更新します。
initializer {
ItemDetailsViewModel(
this.createSavedStateHandle(),
inventoryApplication().container.itemsRepository
)
}
ItemDetailsScreen.kt
に戻ると、ItemDetailsScreen()
コンポーザブルのエラーが解決されています。ItemDetailsScreen()
コンポーザブルで、ItemDetailsBody()
関数呼び出しを更新し、uiState.value
をitemUiState
引数に渡します。
ItemDetailsBody(
itemUiState = uiState.value,
onSellItem = { },
onDelete = { },
modifier = modifier.padding(innerPadding)
)
ItemDetailsBody()
とItemInputForm()
の実装を確認します。現在選択されているitem
をItemDetailsBody()
から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()
)
//...
}
- アプリを実行します。[Inventory] 画面のリスト要素をクリックすると、[Item Details] 画面が表示されます。
- 空白画面にはならず、在庫データベースから取得したエンティティの詳細が表示されます。
- [Sell] ボタンをタップします。何も起こりません。
次のセクションでは、[Sell] ボタンの機能を実装します。
7. アイテムの詳細画面を実装する
ui/item/ItemEditScreen.kt
アイテム編集画面は、スターター コードの一部としてすでに用意されています。
このレイアウトには、新しい在庫アイテムの詳細を編集するテキスト フィールド コンポーザブルが含まれています。
このアプリのコードはまだ完全には機能しません。たとえば、[Item Details] 画面で [Sell] ボタンをタップしても [Quantity in Stock] は増えません。[Delete] ボタンをタップすると確認ダイアログが表示されますが、[Yes] ボタンを選択しても、そのアイテムが実際に削除されるわけではありません。
最後に、FAB ボタン をタップすると空の [Edit Item] 画面が開きます。
このセクションでは、[Sell] ボタン、[Delete] ボタン、FAB ボタンの機能を実装します。
8. アイテム販売機能を実装する
このセクションでは、アプリの機能を拡張して販売機能を実装します。この更新では次のタスクを行います。
- DAO 関数のテストを追加してエンティティを更新します。
ItemDetailsViewModel
に関数を追加して数量を減らし、アプリ データベースのエンティティを更新します。- 数量が 0 の場合は [Sell] ボタンを無効にします。
ItemDaoTest.kt
に、パラメータのないdaoUpdateItems_updatesItemsInDB()
という関数を追加します。@Test
アノテーションと@Throws(Exception::class)
アノテーションを付けます。
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB()
- 関数を定義して、
runBlocking
ブロックを作成します。内部でaddTwoItemsToDb()
を呼び出します。
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
addTwoItemsToDb()
}
- 2 つのエンティティを異なる値で更新し、
itemDao.update
を呼び出します。
itemDao.update(Item(1, "Apples", 15.0, 25))
itemDao.update(Item(2, "Bananas", 5.0, 50))
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))
- 完成した関数が次のようになっていることを確認します。
@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))
}
- テストを実行し、合格することを確認します。
ViewModel
に関数を追加する
ItemDetailsViewModel.kt
のItemDetailsViewModel
クラス内に、パラメータのないreduceQuantityByOne()
という関数を追加します。
fun reduceQuantityByOne() {
}
- この関数内で、
viewModelScope.launch{}
を使用してコルーチンを開始します。
import kotlinx.coroutines.launch
import androidx.lifecycle.viewModelScope
viewModelScope.launch {
}
launch
ブロック内で、currentItem
というval
を作成し、uiState.value.toItem()
に設定します。
val currentItem = uiState.value.toItem()
uiState.value
は ItemUiState
型です。拡張関数 toItem
()
を使用して、Item
エンティティ型に変換します。
quality
が0
より大きいかどうかを確認するif
ステートメントを追加します。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))
}
}
}
ItemDetailsScreen.kt
に戻ります。ItemDetailsScreen
コンポーザブルで、ItemDetailsBody()
関数呼び出しに移動します。onSellItem
ラムダで、viewModel.reduceQuantityByOne()
を呼び出します。
ItemDetailsBody(
itemUiState = uiState.value,
onSellItem = { viewModel.reduceQuantityByOne() },
onDelete = { },
modifier = modifier.padding(innerPadding)
)
- アプリを実行します。
- [Inventory] 画面でリスト要素をクリックします。[Item Details] 画面が表示されたら、[Sell] をタップすると数量の値が 1 減ります。
- [Item Details] 画面で、数量が 0 になるまで [Sell] ボタンをタップし続けます。
数量が 0 になったら、再度 [Sell] をタップします。数量を更新する前に関数 reduceQuantityByOne()
によって数量が 0 より大きいかどうかがチェックされるため、見た目は変わりません。
適切なフィードバックをユーザーに提供するために、販売するアイテムがないときは [Sell] ボタンを無効にすることをおすすめします。
ItemDetailsViewModel
クラスで、map
変換のit
.quantity
に基づいてoutOfStock
値を設定します。
val uiState: StateFlow<ItemDetailsUiState> =
itemsRepository.getItemStream(itemId)
.filterNotNull()
.map {
ItemDetailsUiState(outOfStock = it.quantity <= 0, itemDetails = it.toItemDetails())
}.stateIn(
//...
)
- アプリを実行します。在庫量が 0 になると [Sell] ボタンが無効になります。
これで、アイテム販売機能がアプリに実装されました。
アイテム エンティティを削除する
前のタスクと同様に、削除機能を実装して、アプリの機能をさらに拡張する必要があります。この機能は、販売機能よりはるかに簡単に実装できます。このプロセスでは次のタスクを行います。
- 削除 DAO クエリのテストを追加します。
- データベースからエンティティを削除する関数を
ItemDetailsViewModel
クラスに追加します。 ItemDetailsBody
コンポーザブルを更新します。
DAO テストを追加する
ItemDaoTest.kt
に、daoDeleteItems_deletesAllItemsFromDB()
というテストを追加します。
@Test
@Throws(Exception::class)
fun daoDeleteItems_deletesAllItemsFromDB()
runBlocking {}
を使用してコルーチンを開始します。
fun daoDeleteItems_deletesAllItemsFromDB() = runBlocking {
}
- データベースにアイテムを 2 つ追加し、この 2 つのアイテムに対して
itemDao.delete()
を呼び出してデータベースから削除します。
addTwoItemsToDb()
itemDao.delete(item1)
itemDao.delete(item2)
- データベースからエンティティを取得し、リストが空であることを確認します。完成したテストは次のようになります。
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
に削除関数を追加する
ItemDetailsViewModel
に、deleteItem()
という新しい関数を追加します。これはパラメータを受け取らず、何も返しません。deleteItem()
関数内にitemsRepository.deleteItem()
関数呼び出しを追加して、uiState.value.
toItem
()
を渡します。
suspend fun deleteItem() {
itemsRepository.deleteItem(uiState.value.itemDetails.toItem())
}
この関数では、toItem
()
拡張関数を使用して、uiState
を itemDetails
型から Item
エンティティ型に変換します。
ui/item/ItemDetailsScreen
コンポーザブルに、coroutineScope
というval
を追加し、rememberCoroutineScope()
に設定します。このアプローチでは、呼び出されたコンポジション(ItemDetailsScreen
コンポーザブル)にバインドされているコルーチン スコープが返されます。
import androidx.compose.runtime.rememberCoroutineScope
val coroutineScope = rememberCoroutineScope()
ItemDetailsBody()
関数までスクロールします。onDelete
ラムダ内でcoroutineScope
を使用してコルーチンを開始します。launch
ブロック内で、viewModel
に対してdeleteItem()
メソッドを呼び出します。
import kotlinx.coroutines.launch
ItemDetailsBody(
itemUiState = uiState.value,
onSellItem = { viewModel.reduceQuantityByOne() },
onDelete = {
coroutineScope.launch {
viewModel.deleteItem()
}
modifier = modifier.padding(innerPadding)
)
- アイテムを削除したら、在庫画面に戻ります。
deleteItem()
関数呼び出しの後でnavigateBack()
を呼び出します。
onDelete = {
coroutineScope.launch {
viewModel.deleteItem()
navigateBack()
}
- 引き続き
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()
関数によって次のアラートが表示されます。
- アプリを実行します。
- [Inventory] 画面でリスト要素を選択します。
- [Item Details] 画面で、[Delete] をタップします。
- アラート ダイアログで [Yes] をタップすると [Inventory] 画面に戻ります。
- 削除したエンティティがアプリ データベースに存在しなくなっていることを確認します。
これで削除機能が実装されました。
アイテム エンティティを編集する
前のセクションと同様に、このセクションではアイテム エンティティを編集するという別の機能をアプリに追加します。
アプリ データベースのエンティティを編集する手順を簡単に示すと、次のようになります。
- テスト用アイテム取得 DAO クエリにテストを追加します。
- テキスト フィールドと [Edit Item] 画面にエンティティの詳細を入力します。
- Room を使用してデータベースのエンティティを更新します。
DAO テストを追加する
ItemDaoTest.kt
に、daoGetItem_returnsItemFromDB()
というテストを追加します。
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB()
- 関数を定義します。コルーチン内で、データベースにアイテムを 1 つ追加します。
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
addOneItemToDb()
}
itemDao.getItem()
関数を使用してデータベースからエンティティを取得し、item
というval
に設定します。
val item = itemDao.getItem(1)
assertEquals()
を使用して実際値と取得値を比較し、アサートします。完成したテストは次のようになります。
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
addOneItemToDb()
val item = itemDao.getItem(1)
assertEquals(item.first(), item1)
}
- テストを実行し、合格することを確認します。
テキスト フィールドに入力する
アプリを実行して [Item Details] 画面に移動し、次に FAB をクリックすると、画面のタイトルが [Edit Item] になったことを確認できます。ただし、テキスト フィールドはすべて空です。このステップでは、[Edit Item] 画面のテキスト フィールドにエンティティの詳細を入力します。
ItemDetailsScreen.kt
で、ItemDetailsScreen
コンポーザブルまでスクロールします。FloatingActionButton()
で、onClick
引数を変更してuiState.value.itemDetails.id
(選択したエンティティのid
)を含めます。このid
を使用してエンティティの詳細を取得します。
FloatingActionButton(
onClick = { navigateToEditItem(uiState.value.itemDetails.id) },
modifier = /*...*/
)
ItemEditViewModel
クラスに、init
ブロックを追加します。
init {
}
init
ブロック内で、viewModelScope
.
launch
を使用してコルーチンを開始します。
import kotlinx.coroutines.launch
viewModelScope.launch { }
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
を渡す必要があります。
ItemEditViewModel
クラスにコンストラクタ パラメータを追加します。
class ItemEditViewModel(
savedStateHandle: SavedStateHandle,
private val itemsRepository: ItemsRepository
)
AppViewModelProvider.kt
ファイルのItemEditViewModel
イニシャライザで、ItemsRepository
オブジェクトを引数として追加します。
initializer {
ItemEditViewModel(
this.createSavedStateHandle(),
inventoryApplication().container.itemsRepository
)
}
- アプリを実行します。
- [Item Details] に移動し、FAB をタップします。
- フィールドにアイテムの詳細が入力されます。
- 在庫量または他のフィールドを編集し、[Save] をタップします。
何も起こりません。アプリ データベースのエンティティを更新していないためです。これは次のセクションで修正します。
Room を使用してエンティティを更新する
この最後のタスクでは、コードの最後の要素を追加して更新機能を実装します。ViewModel で必要な関数を定義し、ItemEditScreen
で使用します。
それではまたコーディングしてみましょう。
ItemEditViewModel
クラスに、ItemUiState
オブジェクトを受け取って何も返さないupdateUiState()
という関数を追加します。この関数は、ユーザーが入力した新しい値でitemUiState
を更新します。
fun updateUiState(itemDetails: ItemDetails) {
itemUiState =
ItemUiState(itemDetails = itemDetails, isEntryValid = validateInput(itemDetails))
}
この関数では、渡された itemDetails
を itemUiState
に割り当て、isEntryValid
値を更新します。itemDetails
が true
の場合、[Save] ボタンが有効になります。ユーザーが入力した内容が有効である場合にのみ、この値を true
に設定します。
ItemEditScreen.kt
ファイルに移動します。ItemEditScreen
コンポーザブルで、ItemEntryBody()
関数呼び出しまで下にスクロールします。onItemValueChange
引数の値を、新しい関数updateUiState
に設定します。
ItemEntryBody(
itemUiState = viewModel.itemUiState,
onItemValueChange = viewModel::updateUiState,
onSaveClick = { },
modifier = modifier.padding(innerPadding)
)
- アプリを実行します。
- [Edit Item] 画面に移動します。
- 無効になるようにエンティティ値のいずれかを空にします。[Save] ボタンが自動的に無効になります。
ItemEditViewModel
クラスに戻り、何も受け取らないupdateItem()
というsuspend
関数を追加します。この関数を使用して、更新したエンティティを Room データベースに保存します。
suspend fun updateItem() {
}
getUpdatedItemEntry()
関数内で、if
条件を追加し、validateInput()
関数を使用してユーザー入力を検証します。itemsRepository
に対してupdateItem()
関数を呼び出し、itemUiState.itemDetails.
toItem
()
を渡します。Room データベースに追加するには、エンティティをItem
型にする必要があります。完成した関数は次のようになります。
suspend fun updateItem() {
if (validateInput(itemUiState.itemDetails)) {
itemsRepository.updateItem(itemUiState.itemDetails.toItem())
}
}
ItemEditScreen
コンポーザブルに戻ります。updateItem()
関数を呼び出すためにコルーチン スコープが必要です。coroutineScope
という val を作成し、rememberCoroutineScope()
に設定します。
import androidx.compose.runtime.rememberCoroutineScope
val coroutineScope = rememberCoroutineScope()
ItemEntryBody()
関数呼び出しで、onSaveClick
関数の引数を更新し、coroutineScope
内のコルーチンを開始します。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)
)
- アプリを実行し、在庫アイテムを編集してみましょう。Inventory アプリのデータベース内のアイテムを編集できるようになりました。
Room を使用してデータベースを管理する初めてのアプリを作成しました。
9. 解答コード
この Codelab の解答コードは、以下に示す GitHub リポジトリとブランチにあります。
10. 関連リンク
Android デベロッパー ドキュメント
- Database Inspector を使用してデータベースをデバッグする
- Room を使用してローカル データベースにデータを保存する
- データベースをテストしてデバッグする | Android デベロッパー
Kotlin リファレンス