Help us understand the problem. What is going on with this article?

Room を使ってローカル DB を扱う際の基本と tips

モダンな Android アプリケーション開発を勉強しようの回、続いては SQL ライブラリの Room を使いました。
アノテーションを用いてシンプルにローカル DB を扱う事ができる優秀なライブラリなので今後のアプリ開発では積極的に使っていきたいと思います。

(RxJava + Realm に疲れてきたところだったのでこういうシンプルさに涙が出ます😂)

まずは基本的な使い方から、少し踏み込んだところまで纏めたいと思います。

Environment

まずはライブラリの導入です。動作環境は以下。

  • Android Studio: 3.6
  • Kotlin: 1.3.61
  • Android: 9.0

また、Room の組み込みは以下になります。

app/build.gradle
dependencies {
    def version = "2.2.5"
    implementation "androidx.room:room-runtime:$version"
    kapt "androidx.room:room-compiler:$version"

    // coroutine を使う場合
    implementation "androidx.room:room-ktx:$version"
}

Get Started

Room では

  • Entity
  • Dao
  • Database

と呼ばれるコンポーネントを定義して DB を扱います。
Entity -> Dao -> Database の順に準備していくのが作りやすいかと思いますので、まずは Entity から定義していきます。

Entity

認識としては Entity == テーブル です。
Entity を定義する時は @Entity を class に付けます。

UserEntity.kt
@Entity(tableName = "user")
data class UserEntity(@PrimaryKey val id: String,
                      @ColumnInfo(name = "user_name") val name: String)

基本的にテーブルやカラムの名称はクラス名やプロパティ名が大文字小文字が区別されずに登録されますので、キャメルケースで書いているとフラットなテキストになると思います。
db ファイルを管理するなど、スネークケースで登録したい場合は必要に応じて、Entity.tableNameColumnInfo.name を指定してあげます。

  • @Entity 内で使用するアノテーション
annotation 役割
@PrimaryKey プライマリキーに設定するプロパティに付ける
@ColumnInfo カラムの情報を個別に指定可能
@Ignore モデルに持ちたいが DB に保存したくないカラムに付ける
このアノテーションを付けたプロパティにはデフォルト値を設定しておかないとインスタンスの生成ができない

PrimaryKey の autoGenerate

@PrimaryKey には autoGenerate と言うプロパティが存在します。
その名の通り true にすると、プライマリキーを自動生成 (オートインクリメント) してくれます。

autoGenerate が使えるのは int や long の数値だけです。
String などでは使用できません。数値以外を指定するとビルドエラーになります。

[SQLITE_ERROR] SQL error or missing database (AUTOINCREMENT is only allowed on an INTEGER PRIMARY KEY)

その際、int や long の場合は変数が 0 になっていると未設定として扱われインクリメントされる様になります。

PrimaryKey  |  Android デベロッパー  |  Android Developers

If the field type is long or int (or its TypeConverter converts it to a long or int), Insert methods treat 0 as not-set while inserting the item.

UserEntity.kt
@Entity(tableName = "user")
data class UserEntity(@PrimaryKey(autoGenerate = true) val id: Int,
                      @ColumnInfo(name = "user_name") val name: String)

// id に 0 を指定
val entity = UserEntity(0, "")

Dao

Entity が定義できたら、データベースへの操作を担う interface を定義します。
アノテーションで関数が呼ばれた時に対応するクエリを指定します。

UserDao.kt
@Dao interface UserDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insert(entity: UserEntity)

    @Update fun update(entity: UserEntity)

    @Delete fun delete(entity: UserEntity)

    @Query("SELECT * FROM user")
    fun selectAll(): List<UserEntity>
}

Insert / Update / Delete のアノテーションは用意されています。
少し複雑な処理をしようと思ったら Query を使って SQL を書きましょう。

  • onConflict
    コンフリクトした時の挙動を決めることができます。
OnConflictStrategy
REPLACE 保存されているレコードを置き換える
IGNORE レコードがない時は保存する/ある時は何もしない
ABORT ロールバックする
FAIL / ROLLBACK Deprecated

レコードの Observation

LiveDataFlow, RxJava を使っている場合は Flowable/Publisher で型を返してあげるだけで、レコードの変更を検知してデータを流してくれます。
それぞれ対応したサブライブラリをインポートする必要があります。

UserDao.kt
@Query("SELECT * FROM user")
fun observeAll(): Flow<List<UserEntity>>

Database

最後に Dao (DB) にアクセスするためのエントリーポイントを実装します。
@Database アノテーションを付けた abstract class を作成します。

AppDatabase.kt
@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase: RoomDatabase() {
    abstract fun userDao(): UserDao

    companion object {
        private const val DATABASE_FILE = "app.db"

        fun getInstance(context: Context): AppDatabase =
            Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_FILE).build()
    }
}

entities には最初に作成した Entity のクラスを定義します。Array なので複数登録することが可能です。
また、作成した Dao を返す abstract fun を定義します。

定義した Dababase のインスタンスは Room.databaseBuilder から取得します。 (上の getInstance(Context) 内の処理)
基本的に IO 処理は別スレッドで実行しないといけませんが、Database のインスタンス生成時に allowMainThreadQueries() を設定するとメインスレッドからの操作が可能になります。

以上で DB を触る準備ができましたので、Database のインスタンスを取得して事前に準備してある Dao からテーブルの操作を実行します。

MainActivity.kt
class MainActivity: AppCompatActivity() {
    ...

    fun insertUser() {
        val user = UserEntity("user_id", "user_name")
        AppDatabase.getInstance(applicationContext)
            .userDao()
            .insert(user)
    }
}

基本的な Room の使い方は以上です。

Schema export

exportSchema を true にすると、room.schemaLocation に指定したディレクトリに Schema をアウトプットしてくれます。
DB マイグレーションの時に使ったりするので定義しておくといいかもしれません。

app/build.gradle.kts
android {
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = mapOf("room.incremental" to "true", "room.schemaLocation" to "$projectDir/schemas")
            }
        }
    }
}

Advanced

実際に Room をプロジェクトで使う際に自分が遭遇した tips などを備忘録的な感じで。。。

Base Dao

実際のプロジェクトでは複数の Entity, Dao を扱います。
Insert や Delete などは基本的にどの Dao にも定義する事になると思いますので事前に interface を定義しておくと便利です。

DataAccessObject.kt
interface DataAccessObject <T> {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(entity: T)

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insertIgnore(entity: T)

    @Update suspend fun update(entity: T)

    @Delete suspend fun delete(entity: T)
}
UserDao.kt
@Dao abstract class UserDao: DataAccessObject<UserEntity> {
    @Query("SELECT * FROM user")
    fun observeAll(): Flow<List<UserEntity>>
}

Entity にモデルを持たせたい

Entity はテーブルなので 1つの Entity 内は基本的にフラットになります。
ただ、ソースコードの可読性を上げたい場合などに Entity のプロパティにモデルを持たせてまとまりを付けたい場合がでてくると思います。
そんな時に使うアノテーションが @Embedded です。

UserEntity.kt
data class Privacy(@ColumnInfo(name = "tel_number") val tel: String,
                   val mail: String,
                   val address: String)

@Entity(tableName = "user")
data class UserEntity(@PrimaryKey val id: String,
                      @ColumnInfo(name = "user_name") val name: String,
                      @Embedded(prefix = "privacy") val info: Privacy)

自動生成されたコードを見ると、@Embedded が付けられたモデルのプロパティは Entity にフラットに追加されます。
prefix は追加されるカラム名のプレフィクスを指定できます。

例の場合だと、address, tel_number, mail のカラムが user テーブルに privacy_address, privacy_tel_number, privacy_mail としてそれぞれ追加されます。

TypeConverter

Room がサポートしている以外のクラスを DB に保存する場合は @TypeConverter を使います。
例えば自作クラスを Json のテキストに変換するなど、Room がサポートしているクラスにコンバージョンする事で扱える様にしてあげる必要があります。

以下の例は Bitmap を保存しています。

BitmapConverter.kt
class BitmapConverter {
    @TypeConverter
    fun Bitmap.toEncodedString(): String {
        val bos = ByteArrayOutputStream().also {
            if (!compress(Bitmap.CompressFormat.PNG, 50, it)) return ""
        }
        return Base64.encodeToString(bos.toByteArray(), Base64.DEFAULT)
    }

    @TypeConverter
    fun String.toBitmap(): Bitmap {
        return Base64.decode(this, Base64.DEFAULT).let {
            BitmapFactory.decodeByteArray(it, 0, it.size)
        }
    }
}

作成した Converter は @TypeConverters にエントリーしてあげる必要があります。

UserEntity.kt
@Entity(tableName = "user")
data class UserEntity(@PrimaryKey val id: String,
                      @ColumnInfo(name = "user_name") val name: String,
                      @ColumnInfo(name = "user_icon") val icon: Bitmap)
AppDatabase.kt
@Database(entities = [UserEntity::class], version = 1)
@TypeConverters(BitmapConverter::class)
abstract class AppDatabase: RoomDatabase() {
    abstract fun userDao(): UserDao
}

TypeConverters はスコープを選べます。
Database に指定すると全ての Entity に影響しますが、各 Entity のプロパティに指定することでそのカラムに対してのみ作用させる事もできます。

Multi Module や OSS のクラスを使用する時の注意点

TypeConverter に限った話ではありませんが、外部モジュールにあるクラスを Entity に持たせると

Error: Entities and Pojos must have a usable public constructor. You can have an empty constructor or a constructor whose parameters match the fields (by name and type).

と、エラーが出ます。
今回 Klock と言うライブラリを使用して DateTime のプロパティを持った Entity を定義したところこのエラーに遭遇しました😂

Embedded の中に外部モジュールのクラスを持っているのがダメなのか。
[Android Architecture Components]Roomをmulti moduleプロジェクトで扱うとハマった - Qiita

対応策としてはプリミティブな型 (Long) でプロパティに持って、DateTime で取得する getter を Entity 内に定義する対応を入れましたが微妙な気がします。

UserEntity.kt
data class UserEntity(@Primarykey val id: String,
                      val name: String,
                      val timestamp: Long) {
    fun getTimeStamp(): DateTime = DateTime(timestamp)
}

ちなみに、@Ignore したプロパティのデフォルト値を指定していない場合も同じエラーが出てきます。

Relation を定義する

Entity のリレーションを ForeignKey を使って定義します。

例として、ユーザがメッセージを送信するパターンを想定します。
1人のユーザは複数のメッセージを送信できるので、ユーザとメッセージは1対多の関係になります。

UserEntity.kt
@Entity(tableName = "user")
data class UserEntity(@PrimaryKey @ColumnInfo(name = "user_id") val id: String,
                      @ColumnInfo(name = "user_name") val name: String,
                      @ColumnInfo(name = "user_icon") val icon: Bitmap)
MessageEntity.kt
@Entity(tableName = "message",
        foreignKeys = [
            ForeignKey(
                entity = UserEntity::class,
                parentColumns = ["user_id"],
                childColumns = ["message_user_id"],
                onDelete = ForeignKey.CASCADE
            )
        ])
data class MessageEntity(@PrimaryKey @ColumnInfo(name = "message_id") val id: String,
                         @ColumnInfo(name = "message_user_id") val userId: String,
                         val message: String)

ForeignKey (外部キー) によって外部のテーブル (ここでは user テーブル) と紐付ける事ができます。
外部キーに関しては以下が参考になるかと。

外部キー制約について - Qiita

  • ForeignKey のパラメータ
parameter 内容
entity 親となる Entity のクラス
parentColumns 親になる Entity の紐付けるカラム
childColumns 紐付けられた親のカラムに対応するカラム
onDelete 親が削除された時の動作
デフォルトは NO_ACTION で特に何もしません
onUpdate 親が更新された時の動作
デフォルトは onDelete と同じ

今回は onDelete = CASCADE を指定しているので、親のレコードが削除されたらそれに紐づく子のレコードが削除されます。
また、デフォルト値で埋めたり更新を禁止したりできます。

最後にリレーションをモデル化して中間クラスを作り、そのクラスを返すクエリを Dao に定義します。

UserMessageEntity.kt
data class UserMessageEntity(@Embedded val user: UserEntity,
                             @Relation(parentColumn = "user_id", 
                                       entityColumn = "message_user_id")
                             val message: List<MessageEntity>)
UserDao.kt
@Dao abstract class UserDao: DataAccessObject<UserEntity> {
    @Transaction
    @Query("SELECT * FROM user WHERE user.user_id = :id")
    fun observeUserMessage(id: String): Flow<List<UserMessageEntity>>
}

@Relation によってリレーションをマッピングします。
2つのテーブルからデータを取得して1つのモデルを返すので、クエリを同一トランザクションにするために @Transaction を付けています。

これで、特定のユーザが送信したメッセージのリストが取得できます。

OnConflictStrategy を REPLACE にしている場合

Dao の Insert の OnConflictStrategy を REPLACE にしている場合に子の ForeignKey の onDelete が CASCADE になっていると、
親の Entity を Insert したタイミングで紐付いている子 Entity が全て削除されます

REPLACE の挙動としては Delete -> Insert になるため、一度削除されるのでぶらさがっている Entity も一緒に削除されます。

それでもレコードがなければ追加・あれば更新を実現したい場合の解決策として、
Insert する前にレコードのチェックをして自前で Insert するか Update するかを判断してあげる方法をとりました。
(SQL 力が底辺なのでレコードチェックのやり方が分かりませんでした。。。)

UserDao.kt
@Dao abstract class UserDao: DataAccessObject<UserEntity> {

    @Query("SELECT COUNT(*) FROM user WHERE user_id = :id LIMIT 1")
    protected abstract fun recordCount(id: String): Int

    suspend fun insertOrUpdate(entity: UserEntity) {
        if (recordCount(entity.id) > 0)
            update(entity)
        else
            insertIgnore(entity)
    }
}

DB のマイグレーション

Database には version を指定できます。
この version を上げる場合はカラムの追加や削除に合わせてマイグレーションを定義してあげる必要があります。
(何も変更せずにバージョンアップしただけでもマイグレーションの定義は必要になります。)
マイグレーションが定義されていないとアップデートしたアプリを起動するとクラッシュするので注意です。

ユーザ情報にパスワードのカラムが新しく追加された場合を例にします。

UserEntity.kt
@Entity(tableName = "user")
data class UserEntity(@PrimaryKey @ColumnInfo(name = "user_id") val id: String,
                      @ColumnInfo(name = "user_name") val name: String,
                      // カラムを追加
                      val password: String)
AppDatabase.kt
@Database(entities = [UserEntity::class], version = 2)
abstract class AppDatabase: RoomDatabase() {
    companion object {

        private const val DATABASE_FILE = "app.db"
        private val migration_1_2 = object: Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                // user テーブルに password (テキスト) のカラムを追加
                // 既に追加されてるレコードに関しては全て empty
                database.execSQL("ALTER TABLE user ADD COLUMN password TEXT NOT NULL DEFAULT ''")
            }
        }

        fun getInstance(context: Context): AppDatabase =
            Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_FILE)
                .addMigrations(migration_1_2) // マイグレーションを定義
                .build()
    }
}

Flow で Observation してるのにリアルタイムに更新されない

Dao に Flow で返す様に指定しているとレコードの変更をトリガーにしてデータを流してくれるようになります。
しかし、なぜか画面表示中にリアルタイムに流れてこない。。。
別画面に遷移させたり時間をおいたりすると遅れて更新されているので DB にはちゃんと保存されているっぽい。。。

原因はどうやら Realm の様に Database を扱う度に毎回 Builder からインスタンスを取得していたのがいけなかった様です。
Database のインスタンスをシングルトンにすると解決しました。🤔

おそらくプロジェクトでは Dagger を使って Database を Singleton にすると思うのであまり気にしなくていいと思います...

DB ファイルに保存されているレコードを確認したい

Room では .db で保存されるので SQL のブラウザを使えば中身を確認できます。

1 . DB Browser をインストール
DB Browser for SQLite

2 . .db ファイルを取得

$ adb exec-out run-as <package> cat databases/<table>.db > <table>.db

3 . DB Browser で pull した .db ファイルを開く

Conclusion

アノテーションつよい。。。
普通に使う分に関してはちょっとした準備で簡単に DB が扱えますね。

ただ、ガッツリ使うにはもう少し SQL 力をつけないといけないなと思いました😂

今回の Room の内容も含めて勉強のために Android のアプリを作成しています。

NotificationWatcher/domain/db at develop · tick-taku/NotificationWatcher · GitHub

ご指摘がありましたらコメントいただけると嬉しいです。

参考

Room を使用してローカル データベースにデータを保存する  |  Android デベロッパー  |  Android Developers

[Android Architecture Components] – Room 詳解 – PSYENCE:MEDIA

7 Pro-tips for Room - Android Developers - Medium

Database relations with Room - Android Developers - Medium

Roomのマイグレーションまとめ - Kenji Abe - Medium

tick-taku
Android えんじにゃー。猫と星とロードバイクが好きです。 #Android #Kotlin #AWS #Python
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away