Room を使用してデータを永続化する

1. 始める前に

ほとんどの製品版品質のアプリには、保持する必要のあるデータがあります。たとえば、曲のプレイリスト、To-Do リストの項目、収支の記録、星座表、個人データの履歴などが挙げられます。そのようなユースケースでは、この永続データの保存にデータベースを使用します。

Room は、Android Jetpack の一部である永続ライブラリで、SQLite データベースの上に位置する抽象化レイヤです。SQLite は専門言語(SQL)を使用してデータベース操作を行います。SQLite を直接使用する代わりに Room を使用すると、データベースのセットアップ、構成、アプリの操作が簡単になります。Room には、SQLite ステートメントのコンパイル時チェック機能もあります。

抽象化レイヤは、基となる実装や複雑さを隠す機能のセットです。この場合、SQLite のような既存の機能セットに対するインターフェースを提供します。

下図に、このコースで推奨されているアーキテクチャ全体におけるデータソースとしての Room の位置付けを示します。Room はデータソースです。

リポジトリとデータソースを含むデータレイヤ

前提条件

  • Jetpack Compose を使用して Android アプリの基本的なユーザー インターフェース(UI)を作成できること。
  • TextIconIconButtonLazyColumn などのコンポーザブルを使用できること。
  • NavHost コンポーザブルを使用して、アプリのルートと画面を定義できること。
  • NavHostController を使用して画面間を移動できること。
  • Android アーキテクチャ コンポーネント ViewModel に精通していること。ViewModelProvider.Factory を使用して ViewModel をインスタンス化できること。
  • 同時実行の基本に精通していること。
  • 長時間実行タスクにコルーチンを使用できること。
  • SQLite データベースと SQL 言語に関する基本的な知識があること。

学習内容

  • Room ライブラリを使用して SQLite データベースを作成し、操作する方法。
  • エンティティ、データ アクセス オブジェクト(DAO)、データベース クラスを作成する方法。
  • DAO を使用して Kotlin 関数を SQL クエリにマッピングする方法。

作成するアプリの概要

  • インベントリ アイテムを SQLite データベースに保存する Inventory アプリを作成します。

必要なもの

  • Inventory アプリのスターター コード
  • Android Studio がインストールされているパソコン
  • API レベル 26 以降を搭載しているデバイスまたはエミュレータ

2. アプリの概要

この Codelab では、Inventory アプリのスターター コードを扱い、Room ライブラリを使用してアプリにデータベース レイヤを追加します。アプリの最終バージョンでは、在庫データベースからアイテムのリストを表示します。ユーザーは、新しいアイテムの追加、既存のアイテムの更新、在庫データベースからのアイテムの削除を行えます。この Codelab では、アイテムデータを Room データベースに保存します。アプリの残りの機能については、次の Codelab で説明します。

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

スマートフォンに表示された [Add Item] 画面

アイテムの詳細が入力された [Add Item] 画面

3. スターター アプリの概要

この Codelab のスターター コードをダウンロードする

まず、スターター コードをダウンロードします。

または、GitHub リポジトリのクローンを作成してコードを入手することもできます。

$ 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 starter

コードは Inventory app GitHub リポジトリで確認できます。

スターター コードの概要

  1. Android Studio でスターター コードのプロジェクトを開きます。
  2. Android デバイスまたはエミュレータでアプリを実行します。エミュレータまたは接続済みのデバイスが API レベル 26 以降で動作していることを確認します。Database Inspector は、API レベル 26 以降を搭載したエミュレータやデバイスで機能します。
  1. アプリに在庫データが表示されていないことを確認します。
  2. フローティング アクション ボタン(FAB)をタップすると、データベースに新しいアイテムを追加できます。

自動で新しい画面に移動し、新しいアイテムの詳細情報を入力できるようになります。

在庫が空であることを示すスマートフォンの画面

スマートフォンに表示された [Add Item] 画面

スターター コードの問題点

  1. [Add Item] 画面で、アイテムの詳細(名前、価格、数量など)を入力します。
  2. [Save] をタップします。[Add Item] 画面は閉じませんが、戻るキーを使用して戻ることができます。保存機能が実装されていないため、アイテムの詳細は保存されません。

アプリは未完成であり、[Save] ボタンの機能が実装されていません。

アイテムの詳細が入力された [Add Item] 画面

この Codelab では、Room を使用してインベントリの詳細を SQLite データベースに保存するコードを追加します。Room 永続ライブラリを使用して SQLite データベースを操作します。

コードのチュートリアル

ダウンロードしたスターター コードには、画面のレイアウトがあらかじめ用意されています。ここでは、データベース ロジックの実装に焦点を当てます。作業の土台とするファイルの一部について簡単に説明します。

ui/home/HomeScreen.kt

このファイルはホーム画面、つまりアプリの最初の画面であり、在庫リストを表示するコンポーザブルが含まれています。新しいアイテムをリストに追加する FAB + があります。後ほどこのアイテムをリスト表示します。

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

ui/item/ItemEntryScreen.kt

この画面は ItemEditScreen.kt に似ています。どちらにもアイテムの詳細のテキスト フィールドがあります。この画面は、ホーム画面で FAB をタップすると表示されます。ItemEntryViewModel.kt は、この画面に対応する ViewModel です。

アイテムの詳細が入力された [Add Item] 画面

ui/navigation/InventoryNavGraph.kt

このファイルは、アプリ全体のナビゲーション グラフです。

4. Room の主なコンポーネント

Kotlin では、データクラスを通じてデータを簡単に扱えます。データクラスを使用してインメモリ データを扱うのは簡単ですが、データを永続化するとなると、そのデータをデータベース ストレージと互換性のある形式に変換する必要があります。そのためには、データを格納するためのテーブルと、データにアクセスして変更するためのクエリが必要です。

以下に示す Room の 3 つのコンポーネントを使用すると、こうしたワークフローがシームレスになります。

  • Room エンティティは、アプリのデータベースのテーブルを表します。テーブルの行に格納されているデータの更新や、挿入するための新しい行の作成に使用します。
  • Room DAO は、データベース内のデータを取得、更新、挿入、削除するためにアプリで使用するメソッドを提供します。
  • Room Database クラスは、データベースに関連付けられている DAO のインスタンスをアプリに提供するデータベース クラスです。

これらのコンポーネントの実装と詳細については、この Codelab で後ほど説明します。下図に、Room のコンポーネントが連携してデータベースを操作する仕組みを示します。

a3288e8f37250031.png

Room の依存関係を追加する

このタスクでは、必要な Room コンポーネント ライブラリを Gradle ファイルに追加します。

  1. モジュール レベルの Gradle ファイル build.gradle.kts (Module: InventoryApp.app) を開きます。
  2. dependencies ブロックに、次のコードに示す Room ライブラリの依存関係を追加します。
//Room
implementation("androidx.room:room-runtime:${rootProject.extra["room_version"]}")
ksp("androidx.room:room-compiler:${rootProject.extra["room_version"]}")
implementation("androidx.room:room-ktx:${rootProject.extra["room_version"]}")

KSP は、Kotlin アノテーションを解析するための強力かつシンプルな API です。

5. アイテム エンティティを作成する

Entity クラスはテーブルを定義します。このクラスの各インスタンスは、データベース テーブルの行を表します。エンティティ クラスには、データベース内の情報の表示方法や操作方法を Room に伝えるためのマッピングがあります。今回のアプリでは、エンティティはアイテム名、アイテム価格、アイテムの残り数量など、在庫アイテムに関する情報を保持します。

8c9f1659ee82ca43.png

@Entity アノテーションは、クラスをデータベースの Entity クラスとしてマークします。アプリは Entity クラスごとに、アイテムを保持するデータベース テーブルを作成します。Entity の各フィールドは、特に明記されていない限り、データベースの列として表されます(詳細については Entity のドキュメントをご覧ください)。データベースに格納されるすべてのエンティティ インスタンスに主キーが必要です。主キーは、データベース テーブルのすべてのレコードやエントリを一意に識別するために使用します。アプリが割り当てた後に主キーを変更することはできません。データベースに存在する限り、エンティティ オブジェクトを表します。

このタスクでは、Entity クラスを作成し、アイテムごとに在庫情報を格納するフィールドを定義します(主キーの格納は Int、アイテム名の格納は String、アイテム価格の格納は double、在庫数の格納は Int)。

  1. Android Studio でスターター コードを開きます。
  2. com.example.inventory 基本パッケージの下にある data パッケージを開きます。
  3. data パッケージ内で、アプリのデータベース エンティティを表す Item Kotlin クラスを開きます。
// No need to copy over, this is part of the starter code
class Item(
    val id: Int,
    val name: String,
    val price: Double,
    val quantity: Int
)

データクラス

データクラスは、主に Kotlin でデータを保持するために使用します。data キーワードで定義します。Kotlin データクラス オブジェクトには、いくつかの利点があります。たとえば、コンパイラが比較、出力、コピーのためのユーティリティ(toString()copy()equals() など)を自動的に生成します。

例:

// Example data class with 2 properties.
data class User(val firstName: String, val lastName: String){
}

生成されるコードに一貫性を持たせ、有意義な動作をさせるために、データクラスは次の要件を満たす必要があります。

  • プライマリ コンストラクタには少なくとも 1 つのパラメータが必要です。
  • プライマリ コンストラクタのパラメータはすべて、val または var にする必要があります。
  • データクラスを abstractopensealed にすることはできません。

データクラスの詳細については、データクラスのドキュメントをご覧ください。

  1. Item クラスの定義の前に data キーワードを付けて、データクラスに変換します。
data class Item(
    val id: Int,
    val name: String,
    val price: Double,
    val quantity: Int
)
  1. Item クラス宣言の上で、データクラスに @Entity アノテーションを付けます。tableName 引数を使用して、items を SQLite テーブル名として設定します。
import androidx.room.Entity

@Entity(tableName = "items")
data class Item(
   ...
)
  1. id プロパティに @PrimaryKey アノテーションを付けて、id を主キーにします。主キーは、Item テーブルのすべてのレコードやエントリを一意に識別する ID です。
import androidx.room.PrimaryKey

@Entity(tableName = "items")
data class Item(
    @PrimaryKey
    val id: Int,
    ...
)
  1. id にデフォルト値として 0 を割り当てます。これは、idid 値を自動生成するために必要です。
  2. autoGenerate パラメータを @PrimaryKey アノテーションに追加して、主キー列を自動生成するかどうかを指定します。autoGeneratetrue に設定されている場合、新しいエンティティ インスタンスがデータベースに挿入されると、Room は主キー列の一意の値を自動生成します。これにより、各エンティティ インスタンスに一意の識別子が割り当てられ、主キー列に手動で値を割り当てる必要がなくなります。
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    // ...
)

これで、Entity クラスが作成されたので、データベースにアクセスするためのデータアクセス オブジェクト(DAO)を作成できます。

6. アイテム DAO を作成する

データ アクセス オブジェクト(DAO)は、抽象インターフェースを提供することで永続化レイヤをアプリの残りの部分から分離するために使用できるパターンです。この分離は、これまでの Codelab で見てきた単一責任の原則に則したものです。

DAO の機能は、基となる永続化レイヤでのデータベース操作に関連するすべての複雑さを隠し、アプリの残りの部分から分離することです。これにより、データを使用するコードから独立してデータレイヤを変更できます。

8b91b8bbd7256a63.png

このタスクでは、Room の DAO を定義します。DAO は、データベースにアクセスするインターフェースを定義する Room の主要コンポーネントです。

作成する DAO はカスタム インターフェースです。データベースのクエリ、取得、挿入、削除、更新を簡単に行うことができます。Room はコンパイル時にこのクラスの実装を生成します。

Room ライブラリには、@Insert@Delete@Update などの便利なアノテーションが用意されており、SQL ステートメントを記述せずに簡単な挿入、削除、更新を行うメソッドを定義できます。

より複雑な挿入、削除、更新のオペレーションを定義する必要がある場合や、データベース内のデータにクエリを行う必要がある場合は、代わりに @Query アノテーションを使用します。

さらに、Android Studio でクエリを記述すると、コンパイラが SQL クエリの構文エラーをチェックします。

Inventory アプリの場合、次のことを行える必要があります。

  • 新しいアイテムを挿入または追加する。
  • 既存のアイテムを更新して、名前、価格、数量を更新する。
  • 主キーである id に基づいて、特定のアイテムを取得する。
  • すべてのアイテムを取得して、表示できるようにする。
  • データベースのエントリを削除する。

59aaa051e6a22e79.png

アプリにアイテム DAO を実装する手順は次のとおりです。

  1. data パッケージで、Kotlin インターフェース ItemDao.kt を作成します。

name フィールドはアイテム DAO として入力されます

  1. ItemDao インターフェースに @Dao アノテーションを付けます。
import androidx.room.Dao

@Dao
interface ItemDao {
}
  1. インターフェースの本体内に @Insert アノテーションを追加します。
  2. @Insert の下に、Entity クラスの item のインスタンスを引数として取る insert() 関数を追加します。
  3. 関数を suspend キーワードでマークすると、別のスレッドで実行できます。

データベース操作は実行に時間がかかる可能性があるため、別のスレッドで実行する必要があります。Room ではメインスレッドでのデータベース アクセスができません。

import androidx.room.Insert

@Insert
suspend fun insert(item: Item)

データベースにアイテムを挿入する際、競合が発生することがあります。たとえば、コード内の複数の箇所で、異なる競合する値(同じ主キーなど)でエンティティを更新しようとする場合です。エンティティはデータベース内の行です。Inventory アプリでは、[Add Item] 画面からでないとエンティティを挿入できないため、競合は想定されていません。競合戦略は [Ignore] に設定できます。

  1. 引数 onConflict を追加し、値 OnConflictStrategy.IGNORE を割り当てます。

引数 onConflict は、競合が発生した場合の処理を Room に伝えます。OnConflictStrategy.IGNORE 戦略は、新しいアイテムを無視します。

利用可能な競合戦略について詳しくは、OnConflictStrategy のドキュメントをご覧ください。

import androidx.room.OnConflictStrategy

@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(item: Item)

これで、item をデータベースに挿入するために必要なすべてのコードが Room によって生成されるようになりました。Room アノテーションが付いている DAO 関数を呼び出すと、Room はデータベースに対して対応する SQL クエリを実行します。たとえば、上記のメソッド insert() を Kotlin コードから呼び出すと、Room は SQL クエリを実行してエンティティをデータベースに挿入します。

  1. Item をパラメータとして受け取る、@Update アノテーションを付けた新しい関数を追加します。

渡されるエンティティと同じ主キーを持つエンティティが更新されます。エンティティの他のプロパティの一部または全部を更新できます。

  1. insert() メソッドと同様に、この関数に suspend キーワードを付けます。
import androidx.room.Update

@Update
suspend fun update(item: Item)

@Delete アノテーションを付けた別の関数を追加してアイテムを削除し、suspend 関数にします。

import androidx.room.Delete

@Delete
suspend fun delete(item: Item)

残りの機能には便利なアノテーションがないため、@Query アノテーションを使用して SQLite クエリを指定する必要があります。

  1. 指定した id に基づいてアイテム テーブルから特定のアイテムを取得する SQLite クエリを記述します。次のコードは、items から、id が特定の値に一致する列をすべて選択するサンプルクエリを示しています。id は一意の識別子です。

例:

// Example, no need to copy over
SELECT * from items WHERE id = 1
  1. @Query アノテーションを追加します。
  2. 前のステップの SQLite クエリを、@Query アノテーションの文字列パラメータとして使用します。
  3. String パラメータを @Query に追加します。これはアイテム テーブルからアイテムを取得する SQLite クエリです。

このクエリでは、id が :id 引数と一致する items からすべての列が選択されることになります。:id はクエリ内でコロン表記を使用して、関数内の引数を参照しています。

@Query("SELECT * from items WHERE id = :id")
  1. @Query アノテーションの後に、Int 引数を受け取って Flow<Item> を返す getItem() 関数を追加します。
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Query("SELECT * from items WHERE id = :id")
fun getItem(id: Int): Flow<Item>

永続化レイヤでは Flow を使用することをおすすめします。戻り値の型として Flow を指定すると、データベース内のデータが変更されるたびに通知が届きます。Room がこの Flow を最新の状態に維持します。つまり、データを明示的に取得する必要があるのは一度だけです。このセットアップは次の Codelab で実装する在庫リストを更新する際に役立ちます。戻り値の型が Flow であるため、Room はバックグラウンド スレッドでクエリを実行します。明示的に suspend 関数にしてコルーチン スコープ内で呼び出す必要はありません。

  1. @QuerygetAllItems() 関数を追加します。
  2. SQLite クエリが item テーブルのすべての列を昇順で返すようにします。
  3. getAllItems()Item エンティティのリストを Flow として返すようにします。Room がこの Flow を最新の状態に維持します。つまり、データを明示的に取得する必要があるのは一度だけです。
@Query("SELECT * from items ORDER BY name ASC")
fun getAllItems(): Flow<List<Item>>

完成した ItemDao:

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ItemDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(item: Item)

    @Update
    suspend fun update(item: Item)

    @Delete
    suspend fun delete(item: Item)

    @Query("SELECT * from items WHERE id = :id")
    fun getItem(id: Int): Flow<Item>

    @Query("SELECT * from items ORDER BY name ASC")
    fun getAllItems(): Flow<List<Item>>
}
  1. 目に見える変化はありませんが、アプリをビルドしてエラーがないことを確認します。

7. データベース インスタンスを作成する

このタスクでは、これまでのタスクで扱った Entity と DAO を使用する RoomDatabase を作成します。データベース クラスは、エンティティのリストと DAO を定義します。

Database クラスは、定義した DAO のインスタンスをアプリに提供します。アプリはこの DAO を使用して、関連するデータ エンティティ オブジェクトのインスタンスとしてデータベースからデータを取得できます。また、定義されたデータ エンティティを使用して、対応するテーブルの行を更新したり、挿入用の新しい行を作成したりできます。

抽象 RoomDatabase クラスを作成し、@Database アノテーションを付ける必要があります。このクラスには、データベースが存在しない場合に RoomDatabase の既存のインスタンスを返すメソッドが 1 つあります。

RoomDatabase インスタンスを取得する一般的なプロセスは次のとおりです。

  • RoomDatabase を拡張する public abstract クラスを作成します。定義した新しい抽象クラスは、データベース ホルダーとして機能します。Room が実装を作成するため、定義したクラスは抽象クラスです。
  • クラスに @Database アノテーションを付けます。引数で、データベースのエンティティをリストしてバージョン番号を設定します。
  • ItemDao インスタンスを返す抽象メソッドまたはプロパティを定義すると、Room が実装を生成します。
  • アプリ全体で必要な RoomDatabase のインスタンスは 1 つのみであるため、RoomDatabase をシングルトンにします。
  • RoomRoom.databaseBuilder を使用して、存在しない場合にのみ(item_database)データベースを作成します。それ以外の場合は、既存のデータベースを返します。

データベースを作成する

  1. data パッケージで、Kotlin クラス InventoryDatabase.kt を作成します。
  2. InventoryDatabase.kt ファイルで、InventoryDatabase クラスを、RoomDatabase を拡張する abstract クラスにします。
  3. クラスに @Database アノテーションを付けます。パラメータ欠落エラーは、次のステップで修正するため無視してください。
import androidx.room.Database
import androidx.room.RoomDatabase

@Database
abstract class InventoryDatabase : RoomDatabase() {}

@Database アノテーションには、Room がデータベースを構築できるように、複数の引数が必要です。

  1. entities のリストを持つ唯一のクラスとして Item を指定します。
  2. version1 に設定します。データベース テーブルのスキーマを変更するたびに、バージョン番号を増やす必要があります。
  3. スキーマのバージョン履歴のバックアップを保持しないように、exportSchemafalse に設定します。
@Database(entities = [Item::class], version = 1, exportSchema = false)
  1. クラスの本体内で、ItemDao を返す抽象関数を宣言し、データベースが DAO を認識できるようにします。
abstract fun itemDao(): ItemDao
  1. 抽象関数の下で、companion object を定義します。これにより、データベースを作成または取得するメソッドにアクセスできるようにし、クラス名を修飾子として使用します。
 companion object {}
  1. companion オブジェクト内で、データベース用に null 許容のプライベート変数 Instance を宣言し、null に初期化します。

Instance 変数は、データベースの作成時に、データベースに対する参照を保持します。これは、ある時点で開かれているデータベースのインスタンス(作成と維持にコストのかかるリソース)を 1 つだけ維持する際に役立ちます。

  1. Instance@Volatile アノテーションを付けます。

volatile 変数の値はキャッシュに保存されません。読み取りと書き込みはすべてメインメモリとの間で行われます。こうした機能により、Instance の値が常に最新になり、すべての実行スレッドで同じになります。つまり、あるスレッドが Instance に加えた変更が、すぐに他のすべてのスレッドに反映されます。

@Volatile
private var Instance: InventoryDatabase? = null
  1. Instance の下、companion オブジェクト内で、データベース ビルダーに必要な Context パラメータを持つ getDatabase() メソッドを定義します。
  2. InventoryDatabase 型を返します。getDatabase() はまだ何も返していないため、エラー メッセージが表示されます。
import android.content.Context

fun getDatabase(context: Context): InventoryDatabase {}

複数のスレッドがデータベース インスタンスを同時に要求し、結果的に 1 つではなく 2 つのデータベースが作成される可能性があります。この現象を競合状態と呼びます。データベースを取得するコードを synchronized ブロックで囲むと、このコードブロックには一度に 1 つの実行スレッドしか入ることができず、データベースは一度だけ初期化されます。競合状態を回避するため、synchronized{} ブロックを使用します。

  1. getDatabase() 内で Instance 変数を返します。または、Instance が null の場合は synchronized{} ブロック内で初期化します。これにはエルビス演算子(?:)を使用します。
  2. コンパニオン オブジェクトの this を渡します。エラーは後ほど修正します。
return Instance ?: synchronized(this) { }
  1. synchronized ブロック内で、データベース ビルダーを使用してデータベースを取得します。エラーは次のステップで修正するため無視してください。
import androidx.room.Room

Room.databaseBuilder()
  1. synchronized ブロック内で、データベース ビルダーを使用してデータベースを取得します。アプリ コンテキスト、データベース クラス、データベースの名前 item_databaseRoom.databaseBuilder() に渡します。
Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")

Android Studio が型の不一致エラーを生成します。このエラーを解消するには、以降のステップで build() を追加する必要があります。

  1. 必要な移行戦略をビルダーに追加します。. fallbackToDestructiveMigration() を使用します。
.fallbackToDestructiveMigration()
  1. データベース インスタンスを作成するために、.build() を呼び出します。この呼び出しにより、Android Studio のエラーが解消されます。
.build()
  1. build() の後に、also ブロックを追加し、Instance = it を割り当てて、最近作成された db インスタンスへの参照を保持します。
.also { Instance = it }
  1. synchronized ブロックの最後で instance を返します。コードは最終的に次のようになります。
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

/**
* Database class with a singleton Instance object.
*/
@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class InventoryDatabase : RoomDatabase() {

    abstract fun itemDao(): ItemDao

    companion object {
        @Volatile
        private var Instance: InventoryDatabase? = null

        fun getDatabase(context: Context): InventoryDatabase {
            // if the Instance is not null, return it, otherwise create a new database instance.
            return Instance ?: synchronized(this) {
                Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")
                    .build()
                    .also { Instance = it }
            }
        }
    }
}
  1. コードをビルドして、エラーがないことを確認します。

8. リポジトリを実装する

このタスクでは、ItemsRepository インターフェースと OfflineItemsRepository クラスを実装して、データベースの getinsertdeleteupdate エンティティを提供します。

  1. data パッケージの ItemsRepository.kt ファイルを開きます。
  2. DAO 実装にマッピングする次の関数をインターフェースに追加します。
import kotlinx.coroutines.flow.Flow

/**
* Repository that provides insert, update, delete, and retrieve of [Item] from a given data source.
*/
interface ItemsRepository {
    /**
     * Retrieve all the items from the the given data source.
     */
    fun getAllItemsStream(): Flow<List<Item>>

    /**
     * Retrieve an item from the given data source that matches with the [id].
     */
    fun getItemStream(id: Int): Flow<Item?>

    /**
     * Insert item in the data source
     */
    suspend fun insertItem(item: Item)

    /**
     * Delete item from the data source
     */
    suspend fun deleteItem(item: Item)

    /**
     * Update item in the data source
     */
    suspend fun updateItem(item: Item)
}
  1. data パッケージの OfflineItemsRepository.kt ファイルを開きます。
  2. ItemDao 型のコンストラクタ パラメータを渡します。
class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository
  1. OfflineItemsRepository クラスで、ItemsRepository インターフェースで定義された関数をオーバーライドし、ItemDao から対応する関数を呼び出します。
import kotlinx.coroutines.flow.Flow

class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository {
    override fun getAllItemsStream(): Flow<List<Item>> = itemDao.getAllItems()

    override fun getItemStream(id: Int): Flow<Item?> = itemDao.getItem(id)

    override suspend fun insertItem(item: Item) = itemDao.insert(item)

    override suspend fun deleteItem(item: Item) = itemDao.delete(item)

    override suspend fun updateItem(item: Item) = itemDao.update(item)
}

AppContainer クラスを実装する

このタスクでは、データベースをインスタンス化し、DAO インスタンスを OfflineItemsRepository クラスに渡します。

  1. data パッケージの AppContainer.kt ファイルを開きます。
  2. ItemDao() インスタンスを OfflineItemsRepository コンストラクタに渡します。
  3. データベース インスタンスをインスタンス化するには、InventoryDatabase クラスで getDatabase() を呼び出してコンテキストを渡し、.itemDao() を呼び出して Dao のインスタンスを作成します。
override val itemsRepository: ItemsRepository by lazy {
    OfflineItemsRepository(InventoryDatabase.getDatabase(context).itemDao())
}

これで、Room を扱うためのビルディング ブロックが揃いました。このコードはコンパイルされて実行されますが、実際に機能するかどうかを確認する方法はありません。そのため、ここでデータベースをテストすることをおすすめします。テストを実施するには、ViewModel がデータベースとやりとりする必要があります。

9. 保存機能を追加する

これまでに、データベースを作成し、UI クラスはスターター コードに含まれていました。アプリの一時的なデータを保存し、データベースにアクセスするには、ViewModel を更新する必要があります。ViewModel は DAO を介してデータベースを操作し、UI にデータを提供します。データベース操作はすべてメイン UI スレッドから切り離す必要があるため、コルーチンと viewModelScope を使用します。

UI 状態クラスのチュートリアル

ui/item/ItemEntryViewModel.kt ファイルを開きます。ItemUiState データクラスは、アイテムの UI 状態を表します。ItemDetails データクラスは 1 つのアイテムを表します。

スターター コードには拡張関数が 3 つ用意されています。

  • ItemDetails.toItem() 拡張関数は、ItemUiState UI 状態オブジェクトを Item エンティティ型に変換します。
  • Item.toItemUiState() 拡張関数は、Item Room エンティティ オブジェクトを ItemUiState UI 状態型に変換します。
  • Item.toItemDetails() 拡張関数は、Item Room エンティティ オブジェクトを ItemDetails に変換します。
// No need to copy, this is part of starter code
/**
* Represents Ui State for an Item.
*/
data class ItemUiState(
    val itemDetails: ItemDetails = ItemDetails(),
    val isEntryValid: Boolean = false
)

data class ItemDetails(
    val id: Int = 0,
    val name: String = "",
    val price: String = "",
    val quantity: String = "",
)

/**
* Extension function to convert [ItemDetails] to [Item]. If the value of [ItemDetails.price] is
* not a valid [Double], then the price will be set to 0.0. Similarly if the value of
* [ItemDetails.quantity] is not a valid [Int], then the quantity will be set to 0
*/
fun ItemDetails.toItem(): Item = Item(
    id = id,
    name = name,
    price = price.toDoubleOrNull() ?: 0.0,
    quantity = quantity.toIntOrNull() ?: 0
)

fun Item.formatedPrice(): String {
    return NumberFormat.getCurrencyInstance().format(price)
}

/**
* Extension function to convert [Item] to [ItemUiState]
*/
fun Item.toItemUiState(isEntryValid: Boolean = false): ItemUiState = ItemUiState(
    itemDetails = this.toItemDetails(),
    isEntryValid = isEntryValid
)

/**
* Extension function to convert [Item] to [ItemDetails]
*/
fun Item.toItemDetails(): ItemDetails = ItemDetails(
    id = id,
    name = name,
    price = price.toString(),
    quantity = quantity.toString()
)

ビューモデルで上記のクラスを使用して、UI の読み取りと更新を行います。

ItemEntry ViewModel を更新する

このタスクでは、リポジトリを ItemEntryViewModel.kt ファイルに渡します。また、[Add Item] 画面で入力したアイテムの詳細をデータベースに保存します。

  1. ItemEntryViewModel クラスの validateInput() プライベート関数に注目してください。
// No need to copy over, this is part of starter code
private fun validateInput(uiState: ItemDetails = itemUiState.itemDetails): Boolean {
    return with(uiState) {
        name.isNotBlank() && price.isNotBlank() && quantity.isNotBlank()
    }
}

上記の関数は、namepricequantity が空かどうかを確認します。この関数を使用してユーザー入力を検証してから、データベースのエンティティを追加または更新します。

  1. ItemEntryViewModel クラスを開き、ItemsRepository 型の private デフォルト コンストラクタ パラメータを追加します。
import com.example.inventory.data.ItemsRepository

class ItemEntryViewModel(private val itemsRepository: ItemsRepository) : ViewModel() {
}
  1. ui/AppViewModelProvider.kt のアイテム エントリ ビューモデルの initializer を更新し、パラメータとしてリポジトリ インスタンスを渡します。
object AppViewModelProvider {
    val Factory = viewModelFactory {
        // Other Initializers
        // Initializer for ItemEntryViewModel
        initializer {
            ItemEntryViewModel(inventoryApplication().container.itemsRepository)
        }
        //...
    }
}
  1. ItemEntryViewModel.kt ファイルに移動し、ItemEntryViewModel クラスの最後に saveItem() という suspend 関数を追加して、Room データベースにアイテムを挿入します。この関数は、データをブロック以外の方法でデータベースに追加します。
suspend fun saveItem() {
}
  1. 関数内で itemUiState が有効であるかどうかを確認し、Item 型に変換して、Room がデータを認識できるようにします。
  2. itemsRepository に対して insertItem() を呼び出し、データを渡します。UI はこの関数を呼び出して、アイテムの詳細をデータベースに追加します。
suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}

エンティティをデータベースに追加するために必要な関数がすべて追加されました。次のタスクでは、上記の関数を使用するように UI を更新します。

ItemEntryBody() コンポーザブルのチュートリアル

  1. ui/item/ItemEntryScreen.kt ファイルでは、ItemEntryBody() コンポーザブルがスターター コードの一部として部分的に実装されています。ItemEntryScreen() 関数呼び出しの ItemEntryBody() コンポーザブルに注目してください。
// No need to copy over, part of the starter code
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = { },
    modifier = Modifier
        .padding(innerPadding)
        .verticalScroll(rememberScrollState())
        .fillMaxWidth()
)
  1. UI 状態と updateUiState ラムダが関数パラメータとして渡されています。関数の定義を調べて、UI 状態がどのように更新されるかを確認します。
// No need to copy over, part of the starter code
@Composable
fun ItemEntryBody(
    itemUiState: ItemUiState,
    onItemValueChange: (ItemUiState) -> Unit,
    onSaveClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        // ...
    ) {
        ItemInputForm(
             itemDetails = itemUiState.itemDetails,
             onValueChange = onItemValueChange,
             modifier = Modifier.fillMaxWidth()
         )
        Button(
             onClick = onSaveClick,
             enabled = itemUiState.isEntryValid,
             shape = MaterialTheme.shapes.small,
             modifier = Modifier.fillMaxWidth()
         ) {
             Text(text = stringResource(R.string.save_action))
         }
    }
}

このコンポーザブルに ItemInputForm と [Save] ボタンが表示されています。ItemInputForm() コンポーザブルにはテキスト フィールドが 3 つ表示されています。[Save] は、テキスト フィールドにテキストが入力されている場合にのみ有効になります。isEntryValid 値は、すべてのテキスト フィールドのテキストが有効である場合(空白ではない場合)に true になります。

アイテムの詳細が部分的に入力され、保存ボタンが無効になっているスマートフォンの画面

アイテムの詳細が入力され、保存ボタンが有効になっているスマートフォンの画面

  1. コンポーズ可能な関数 ItemInputForm() の実装を見ると、onValueChange 関数パラメータがあります。itemDetails の値を、ユーザーがテキスト フィールドに入力した値に置き換えます。[Save] ボタンが有効になるまでに、itemUiState.itemDetails には保存する必要がある値が設定されます。
// No need to copy over, part of the starter code
@Composable
fun ItemEntryBody(
    //...
) {
    Column(
        // ...
    ) {
        ItemInputForm(
             itemDetails = itemUiState.itemDetails,
             //...
         )
        //...
    }
}
// No need to copy over, part of the starter code
@Composable
fun ItemInputForm(
    itemDetails: ItemDetails,
    modifier: Modifier = Modifier,
    onValueChange: (ItemUiState) -> Unit = {},
    enabled: Boolean = true
) {
    Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        OutlinedTextField(
            value = itemUiState.name,
            onValueChange = { onValueChange(itemDetails.copy(name = it)) },
            //...
        )
        OutlinedTextField(
            value = itemUiState.price,
            onValueChange = { onValueChange(itemDetails.copy(price = it)) },
            //...
        )
        OutlinedTextField(
            value = itemUiState.quantity,
            onValueChange = { onValueChange(itemDetails.copy(quantity = it)) },
            //...
        )
    }
}

Save ボタンにクリック リスナーを追加する

すべてをまとめるために、[Save] ボタンにクリック ハンドラを追加します。クリック ハンドラ内でコルーチンを開始し、saveItem() を呼び出してデータを Room データベースに保存します。

  1. ItemEntryScreen.kt のコンポーズ可能な関数 ItemEntryScreen 内で、コンポーズ可能な関数 rememberCoroutineScope() を使用して、coroutineScope という val を作成します。
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. ItemEntryBody() 関数呼び出しを更新し、onSaveClick ラムダ内でコルーチンを開始します。
ItemEntryBody(
   // ...
    onSaveClick = {
        coroutineScope.launch {
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. ItemEntryViewModel.kt ファイルの saveItem() 関数実装で、itemUiState が有効で、itemUiStateItem 型に変換し、itemsRepository.insertItem() を使用してデータベースに挿入しているかどうかを確認します。
// No need to copy over, you have already implemented this as part of the Room implementation

suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}
  1. ItemEntryScreen.kt のコンポーズ可能な関数 ItemEntryScreen にあるコルーチン内で、viewModel.saveItem() を呼び出してアイテムをデータベースに保存します。
ItemEntryBody(
    // ...
    onSaveClick = {
        coroutineScope.launch {
            viewModel.saveItem()
        }
    },
    //...
)

ItemEntryViewModel.kt ファイルでは saveItem()viewModelScope.launch() が使用されていませんが、これはリポジトリ メソッドを呼び出すとき ItemEntryBody () に必要です。suspend 関数は、コルーチンまたは別の suspend 関数からしか呼び出せません。関数 viewModel.saveItem() は suspend 関数です。

  1. アプリをビルドして実行します。
  2. + FAB をタップします。
  3. [Add Item] 画面でアイテムの詳細を追加して [Save] をタップします。[Save] ボタンをタップしても [Add Item] 画面は閉じません。

アイテムの詳細が入力され、保存ボタンが有効になっているスマートフォンの画面

  1. onSaveClick ラムダで、viewModel.saveItem() の呼び出しの後に navigateBack() の呼び出しを追加して、前の画面に戻るようにします。ItemEntryBody() 関数は次のコードのようになります。
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = {
        coroutineScope.launch {
            viewModel.saveItem()
            navigateBack()
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. アプリを再度実行し、同じ手順でデータを入力して保存します。今回は [Inventory] 画面に戻ります。

この操作を行うとデータは保存されますが、アプリで在庫データを確認することはできません。次のタスクでは Database Inspector を使用して、保存したデータを表示します。

空の在庫リストが表示されたアプリ画面

10. Database Inspector を使用してデータベースの内容を表示する

Database Inspector を使用すると、アプリを動作させながらアプリのデータベースの検査、クエリ、変更を行えます。これは、データベースのデバッグで特に役立ちます。Database Inspector は、プレーン SQLite と、Room などの SQLite の上に構築されたライブラリで動作します。Database Inspector は、API レベル 26 を搭載したエミュレータやデバイスで最適に機能します。

  1. API レベル 26 以降を搭載した接続済みデバイスまたはエミュレータでアプリを実行します。
  2. Android Studio で、メニューバーから [View] > [Tool Windows] > [App Inspection] を選択します。
  3. [Database Inspector] タブを選択します。
  4. [Database Inspector] ペインのプルダウン メニューから com.example.inventory を選択します(選択されていない場合)。Inventory アプリの item_database が [Databases] ペインに表示されます。

76408bd5e93c3432.png

  1. [Databases] ペインで item_database のノードを開き、[Item] を選択して調べます。[Databases] ペインが空の場合はエミュレータを使用し、[Add Item] 画面からデータベースにアイテムを追加します。
  2. Database Inspector の [Live updates] チェックボックスをオンにすると、エミュレータまたはデバイス上で実行中のアプリを操作したときに、表示されるデータが自動的に更新されます。

9e21d9f7eb426008.png

おめでとうございます!Room を使用してデータを保持するアプリを作成できました。次の Codelab では、アプリに lazyColumn を追加してデータベースのアイテムを表示し、エンティティの削除や更新などの新機能をアプリに追加します。ご参加をお待ちしております。

11. 解答コードを取得する

この Codelab の解答コードは GitHub リポジトリにあります。この Codelab の完成したコードをダウンロードするには、次の git コマンドを使用します。

$ 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 で表示します。

12. まとめ

  • テーブルを、@Entity アノテーション付きのデータクラスとして定義する。@ColumnInfo アノテーション付きのプロパティを、テーブルの列として定義する。
  • データ アクセス オブジェクト(DAO)を、@Dao アノテーション付きのインターフェースとして定義する。DAO は、Kotlin 関数をデータベース クエリにマッピングする。
  • アノテーションを使用して、@Insert@Delete@Update 関数を定義する。
  • SQLite クエリ文字列の @Query アノテーションを、他のクエリのパラメータとして使用する。
  • Database Inspector を使用して、Android SQLite データベースに保存されているデータを表示する。

13. 関連リンク

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

ブログ投稿

動画

その他のドキュメントと記事