モダンな Android アプリケーション開発を勉強しようの回、続いては SQL ライブラリの Room を使いました。
アノテーションを用いてシンプルにローカル DB を扱う事ができる優秀なライブラリなので今後のアプリ開発では積極的に使っていきたいと思います。
(RxJava + Realm に疲れてきたところだったのでこういうシンプルさに涙が出ます😂)
まずは基本的な使い方から、少し踏み込んだところまで纏めたいと思います。
Environment
まずはライブラリの導入です。動作環境は以下。
- Android Studio: 3.6
- Kotlin: 1.3.61
- Android: 9.0
また、Room の組み込みは以下になります。
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 に付けます。
@Entity(tableName = "user")
data class UserEntity(@PrimaryKey val id: String,
@ColumnInfo(name = "user_name") val name: String)
基本的にテーブルやカラムの名称はクラス名やプロパティ名が大文字小文字が区別されずに登録されますので、キャメルケースで書いているとフラットなテキストになると思います。
db ファイルを管理するなど、スネークケースで登録したい場合は必要に応じて、Entity.tableName
や ColumnInfo.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.
@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 を定義します。
アノテーションで関数が呼ばれた時に対応するクエリを指定します。
@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
LiveData
や Flow
, RxJava を使っている場合は Flowable/Publisher
で型を返してあげるだけで、レコードの変更を検知してデータを流してくれます。
それぞれ対応したサブライブラリをインポートする必要があります。
@Query("SELECT * FROM user")
fun observeAll(): Flow<List<UserEntity>>
Database
最後に Dao (DB) にアクセスするためのエントリーポイントを実装します。
@Database
アノテーションを付けた abstract class を作成します。
@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 からテーブルの操作を実行します。
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 マイグレーションの時に使ったりするので定義しておくといいかもしれません。
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 を定義しておくと便利です。
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)
}
@Dao abstract class UserDao: DataAccessObject<UserEntity> {
@Query("SELECT * FROM user")
fun observeAll(): Flow<List<UserEntity>>
}
Entity にモデルを持たせたい
Entity はテーブルなので 1つの Entity 内は基本的にフラットになります。
ただ、ソースコードの可読性を上げたい場合などに Entity のプロパティにモデルを持たせてまとまりを付けたい場合がでてくると思います。
そんな時に使うアノテーションが @Embedded
です。
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 を保存しています。
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
にエントリーしてあげる必要があります。
@Entity(tableName = "user")
data class UserEntity(@PrimaryKey val id: String,
@ColumnInfo(name = "user_name") val name: String,
@ColumnInfo(name = "user_icon") val icon: Bitmap)
@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 内に定義する対応を入れましたが微妙な気がします。
data class UserEntity(@Primarykey val id: String,
val name: String,
val timestamp: Long) {
fun getTimeStamp(): DateTime = DateTime(timestamp)
}
ちなみに、@Ignore したプロパティのデフォルト値を指定していない場合も同じエラーが出てきます。
Relation を定義する
Entity のリレーションを ForeignKey
を使って定義します。
例として、ユーザがメッセージを送信するパターンを想定します。
1人のユーザは複数のメッセージを送信できるので、ユーザとメッセージは1対多の関係になります。
@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)
@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 テーブル) と紐付ける事ができます。
外部キーに関しては以下が参考になるかと。
- ForeignKey のパラメータ
parameter | 内容 |
---|---|
entity | 親となる Entity のクラス |
parentColumns | 親になる Entity の紐付けるカラム |
childColumns | 紐付けられた親のカラムに対応するカラム |
onDelete | 親が削除された時の動作 デフォルトは NO_ACTION で特に何もしません |
onUpdate | 親が更新された時の動作 デフォルトは onDelete と同じ |
今回は onDelete = CASCADE
を指定しているので、親のレコードが削除されたらそれに紐づく子のレコードが削除されます。
また、デフォルト値で埋めたり更新を禁止したりできます。
最後にリレーションをモデル化して中間クラスを作り、そのクラスを返すクエリを Dao に定義します。
data class UserMessageEntity(@Embedded val user: UserEntity,
@Relation(parentColumn = "user_id",
entityColumn = "message_user_id")
val message: List<MessageEntity>)
@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 力が底辺なのでレコードチェックのやり方が分かりませんでした。。。)
@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 を上げる場合はカラムの追加や削除に合わせてマイグレーションを定義してあげる必要があります。
(何も変更せずにバージョンアップしただけでもマイグレーションの定義は必要になります。)
マイグレーションが定義されていないとアップデートしたアプリを起動するとクラッシュするので注意です。
ユーザ情報にパスワードのカラムが新しく追加された場合を例にします。
@Entity(tableName = "user")
data class UserEntity(@PrimaryKey @ColumnInfo(name = "user_id") val id: String,
@ColumnInfo(name = "user_name") val name: String,
// カラムを追加
val password: String)
@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