[Android Architecture Components] - Room 詳解
Jumpei Matsuda
こんにちは。Quipper で Android Developer をしている daruma です。
今回は Android Architecture Components の Room について深掘っていきたいと思います。
引用: Android Architecture Components
Room とは
Google I/O 2017 Architecture Components - Persistence and Offline で発表のあった SQLite の Object Mapper です。ORMではありません。Annotation Processing Tool を用いており、以下の特徴を持ちます。
一般名詞なので検索がしづらい- One-to-oneやOne-to-many などの Entity Relations をオブジェクト表現ではサポートしない
- Entity でない POJO へのマッピングが可能
- DB内データの変更を通知する機構を搭載
- SQLコンパイル時静的解析が可能
- 任意の Schema Version からの Migration Test が可能
準備
Maven レポジトリの追加
repositories {
maven { url 'https://maven.google.com' }
}
依存の追加
compile "android.arch.persistence.room:runtime"
annotationProcessor "android.arch.persistence.room:compiler"
Room の構成要素
Entity、Dao(Data Access Object)、Database の3つ です。
@Entity
public class User {
@PrimaryKey
private long id;
// getter and setter
}
@Dao
public interface UserDao {
@Query("select * from user")
List<user> findAll();
}
@Database(entities = {User.class}, version = 1, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao getUserDao();
}
以上、3つを準備し、利用するときは以下のように RoomDatabase を継承したクラス(今回は AppDatabase)を作成し、操作します。
AppDatabase db = Room.databaseBuilder(getApplicationContext(),
AppDatabase.class, "database-name")
.allowMainThreadQueries() // Main thread でも動作させたい場合
.build();
List<user> users = db.getUserDao().findAll();
1つずつ利用方法を見ていきましょう。説明の都合上、Databaseから説明します。
Database
@Database(entities = {User.class}, version = 1, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao getUserDao();
}
@Database(
Class[] entities,
int version,
boolean exportSchema = true
)
Entity や Dao のエントリーポイントとなるクラスで、Roomの構成要素で唯一基底クラス(RoomDatabase)の制限が存在します。また abstract クラスである必要があります。
exportSchemaがtrueになっている場合、後述する設定をしないと警告が出て精神衛生上良くありません。また Migration Test も行えません。詳しくは Migration Test の節で紹介します。
@Database
とそのクラスには処理したい Entity、現在の Schema Version、処理したい Dao の3つを登録します。指定されない Entity と Dao は APT の処理対象にならないため、警告もなく想定したスキーマが生成されていない場合は最初にチェックするといいでしょう。
- 指定される Entity は後述する
@Entity
を持つ - Schema Version は1以上である
- 指定される Dao は後述する
@Dao
を持つ
Entity
@Entity
アノテーションをつけたクラスがDBに保存されるエンティティです。@PrimaryKey
は必ず1つのフィールドにつける必要があります。
@Entity
public class User {
@PrimaryKey
private long id;
// getter and setter for `id`
@Ignore
private long ignored; // カラム変換されない
private long withoutGetterAndSetter; // コンパイルエラー
}
@Ignore
アノテーションをつけない限り、Entity のインスタンス変数は自動的に Column に変換されます。Column は後述する Dao からアクセスできなければなりません。したがってインスタンス変数は直接あるいはSetter/Getter1)Getter/Setterの命名規則はJava Beansの命名規則に従いますを介してDao から視える必要があります。前文の制約を満たしていればSetter/Getter同士は同じ可視性である必要はありません。またコンストラクタを利用すれば immutable entity の構成もサポートされています。
@Entity
public class User {
@PrimaryKey
public final long id; // private field + getter でも可
public User(long id) {
this.id = id;
}
}
他にも Column には以下のアノテーションが設定できます。
@PrimaryKey(boolean autoGenerate = false)
@ColumnInfo(
String name = "${Use field name}",
SQLiteTypeAffinity typeAffinity = UNDEFINED,
boolean index = false
)
@Ignore
@Embeded(String prefix = "") // あとで扱います
用途は名前から推測できるかと思いますが、以下の点に注意しましょう。
- autoGenerate は AUTOINCREMENT を発行する
- Index のデフォルト命名規則は
index_${table_name}_${column_name}
である - SQLiteTypeAffinity == UNDEFINED の場合、Column type を Room が自動判定する (e.g. boolean は true が 1, false が 0 として展開)
Embeded アノテーションによるオブジェクトマッピング
Room では Entity 間の Relation をオブジェクトで表現することができません。Room が RDB を覆う層でありながら、Object-relational mapping ではない理由がこの点にあります。つまり他 ORM ライブラリでよく見られる以下の記述はサポートされていません。
class A {
@OneToOne
public B b;
}
それでも Entity に構造オブジェクトを持たせたい場合、 @Embeded
が利用できます。これは 構造オブジェクトを Entity が持てるようになる機能であり、Entity が Entity を持てるようにするものではない点に注意する必要があります。
class A {
public int fieldOfA;
}
@Entity
class B {
@PrimaryKey public long id;
@Embeded public A a1; // => fieldOfA が B に追加される
@Embeded(prefix = "prefix_") public A a2; // => prefix_fieldOfA が B に追加される
}
@Embeded
が付与されたクラスのインスタンス変数を Column と見なし、flatten します2)Entity の id などを無理なく型付できる点が個人的に嬉しいです。。またアノテーションに prefix を渡すことで、接頭辞を追加することが可能です。この構造オブジェクトを保持する機構は後述する Dao で有用です。
また Embeded アノテーションでは以下の制約があります。
- inner class は Insert 時のみ利用できます。後述する Dao で Selectの SQL を発行するとコンパイラがエラーを吐きます
- Column を持たないクラスを指定した場合は警告なしに無視され、結果的に
@Ignore
と同等の動きになります
TypeConverter による特定オブジェクトの変換
例えば DB 上では INTEGER として保存したい値でも、取り出したときは Date クラスで扱いたいケースがあります。そのような場合には @TypeConverter
とエントリー用の @TypeConverters
が利用できます。
public class Converter {
@TypeConverter
public static Date fromTimeToDate(@Nullable Long time) {
return value == null ? null : new Date(time);
}
@TypeConverter
public static Long fromDateToTime(@Nullable Date date) {
return date == null ? null : date.getTime();
}
}
上記のように 入力の型 → DB上での型 → 出力の型 に対応する TypeConverter を書くことになります3)この例では入力の型と出力の型が同一のため、双方向定義のようになっていますが、双方向定義をしなければならないといった制約はありません 検証方法に間違いがありました。Select 文を発行するかどうかに関わらず、『双方向定義が必要である』ことが正しいです。また双方向定義をしない際、Room のエラーメッセージでは『保存時の型が不明』という旨が表示されますが、読み出しの方法が不明の場合でも同一のエラーメッセージが出るため注意してください。(追記: 2017/06/11 17:24)。これもまた自動では APT 対象にはならず、@TypeConverters
を Database クラスなどに付与する必要があります。@TypeConverters
はその影響範囲を細かく指定することができます。詳しくは公式ドキュメントを御覧ください。
外部キー制約や複合 Index
Room は Entity 間の Relation をオブジェクト表現には落とせませんが、外部キー制約はサポートされています。単一/複合外部キー制約や複合 Index などは @Entity
のパラメータで指定します。
@Entity(
String tableName = "${Use class name}",
Index[] indices = [],
boolean inheritSuperIndices = false,
String[] primaryKeys = [],
ForeignKey[] foreignKeys = []
)
これも名前から推測可能かと思いますが、以下の点に注意する必要があります。
- primaryKeys で指定した場合、autoGenerate は false。すでに PrimaryKey が指定されたフィールドは上書きされない
- inheritSuperIndices は自分の親・先祖クラス(それぞれのEntity#inheritSuperIndicesに関わらず)全ての index を引き継ぐ
- フィールド名ではなく、Column 名を指定する必要がある
- ForeignKey アノテーション自体はフィールドにも指定可能だが、その場合は動かず、警告無しに無視される
Dao
Data Access Object です。オブジェクトと SQL の変換層であり、SQLiteDatabase に対して SQL を走らせて結果を取得します。interface で定義し、その実体は APT によって作成されます。
@Dao
interface ADao {
@Query("select * from a where id = :id LIMIT 1")
A findOne(long id);
@Insert(onConflict = OnConflictStrategy.ROLLBACK)
void insert(A a);
@Insert(onConflict = OnConflictStrategy.ABORT)
void update(A a);
@Delete
void delete(A a);
}
// @Insert の生成物
INSERT OR ROLLBACK INTO `A`(`id`) VALUES (nullif(?, 0))
// @Update の生成物
UPDATE OR ABORT `A` SET `id` = ? WHERE `id` = ?";
// @Delete の生成物
DELETE FROM `A` WHERE `id` = ?
直接 SQL を記述出来る @Query
と Insert/Update/Delete
という3つのヘルパー用アノテーションが用意されています。INSERT/UPDATE/DELETE の SQL を発行した場合は transaction 内で実行されるように Room が自動で処理します4)Queryアノテーション内で Delete 文を手書きしても transaction で実行されるようにラップされますが、Select 文はラップされません。記述できる SQL は SQLite の記法に準拠します。
レコード変更検知
Dao で返す型として Lifecycle で出てきた LiveData
や RxJava2 の Flowable/Publisher
を返すだけで、レコードの変更をトリガーとして新しい値を流してくれます。
RxJava2 のオブジェクトを返したい場合、コンパイル依存として android.arch.persistence.room:rxjava2
を追加する必要があります。
InvalidationTracker
と呼ばれるクラスが テーブルの変更
を検知します5)正確には Dao ではなく Database 内に実装が存在する。このクラスはテンポラリテーブルを作成し、テーブルごとにシンプルなバージョン管理を行なっています。レコードを取得する際に触った全てのテーブルを監視するため、テーブル結合が多いとその分通知候補対象になります。また UPDATE/DELETE/INSERT の3種類の SQL がトリガーになりますが、変更監視対象はテーブルではなく行変更検知ではありません。LiveData などのクラス内部、あるいはそれにデータを流す際に値比較を行うことで、行変更に対する検知を実現しています6)LiveData の場合、inactive であればそもそもレコードを取得しない作りになっています。
Entity を持つオブジェクトがどうしても欲しい場合
さて、前節で Entity は Entity を持てないと書きました7)Room は Entity の持つオブジェクト構造を Entity 間の Relation として解釈しないという理解が正しいです。それでも Entity が Entity を持つようなデータ構造が必要になる場面は存在します。Entity が Entity を持つことはできないため、POJO と JOIN クエリを使うことで Entity
をメンバーに持つオブジェクトを構築することが可能です8)Dao の機能であって Entity の機能ではありません。OneToMany/ManyToMany の場合は Repository層を提供するなどして Aggregate する必要がありますが、OneToOne を表現したい場合は下記のように @Embedded
を利用すれば1文 SQL で実行可能です。
@Entity
public class A { ... }
@Entity
public class B {
...
public String fieldOfB;
}
public class C {
public long a_id;
@Embedded(prefix = "b_") public B b;
}
@Dao
public interface CDao {
@Query("select a.id as a_id,"
+ " b.id as b_id, b.fieldOfB as b_fieldOfB,"
+ " from a inner join b on a.id = b.id"
+ " where a.id = :id")
C findByIdOfA(long a);
}
コンパイル時の静的解析
Room は以下の状態を静的解析の結果、警告またはエラーとして出力します。
Entity
- getter/setter といった accessor が解決不可
- Index name 重複
- Table name 重複
- Index 無しの ForeignKey
Dao
- 存在しない Table name の参照
- 存在しない引数マッピング
- 誤った構文の SQL
上記のエラー例はソースコードを読んだわけではないため、他にも存在すると思います。
Migration Test
Room は任意の Schema Version から任意の Schema Version に対する Migration Test をサポートしています。Migration Test 機能の有効化のためには以下の設定が必要です。
@Database(..., exportSchema = true) // true はデフォルト値です
public abstract class AppDatabase extends RoomDatabase...
android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments += ["room.schemaLocation":"$projectDir/schemas".toString()]
// app/schemas/${databaseクラスのFQDN}/$schemaVersion.json が生成されるようになる
}
}
}
dependencies {
androidTestCompile "android.arch.persistence.room:testing"
}
}
この設定により、Room はその時点での Schema 定義ファイル をビルドの度に出力します9)書式は CREATE SQL文だけでなく、Entityの構造なども記録した json です。テストではこの出力された Schema 情報を利用することで、任意の Schema Version を再現することができます。次にテストで使う設定を行ないます。
android {
...
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
Java テストコードで Migration Test用のヘルパークラスを呼び出します。
@RunWith(AndroidJUnit4.class)
public class MigrationTest {
@Rule
public MigrationTestHelper helper;
public MigrationTest() {
helper = new MigrationTestHelper(InstrumentationRegistry.getContext(),
YourRoomDatabaseClass.class.getName(), // or getCanonicalName()
new FrameworkSQLiteOpenHelperFactory());
}
}
第一引数に Context、第二引数に Database のFQDN、第三引数に Android Framework 用の SQLiteOpenHelperFactory を渡します。
InstrumentationRegistry.getTargetContext()
ではなく、 InstrumentationRegistry.getContext()
を渡す必要があります。つまりテストアプリケーション側の Context を渡す必要があります。これは androidTest の assets に登録しているためで、万が一 main などの assets にスキーマを入れた場合は getTargetContext()
にする必要があります。
あとは各 Schema でのデータのセットアップと Migration Script を明示的に走らせればテストが可能になります。
// DB を指定する Schema Version で作成
SupportSQLiteDatabase db = helper.createDatabase(/* db name */ "migration-db", /* schema version */ 1);
// Dao は最新 Schema に依存しているので使えない。したがって手でデータを挿入する必要がある。
db.execSQL(...);
// Migration に備えて、dbを閉じる
db.close();
指定の Schema Version で DB を開く → データを挿入 → DBを閉じる という行為により、指定の Schema Version でのDB状態が再現できます。これに対して Migration をかけていきます。
// Schema Version 1から2へあげる Migration Script (MIGRATION_1_2) を当てる
db = helper.runMigrationsAndValidate("migration-db", 2, /* validateDroppedTables */ true, MIGRATION_1_2);
// 同様に Schema Version 2に対してSQLを発行する
db.execSQL(...);
// 次のMigration に備えて、dbを閉じる
db.close();
validateDroppedTables を true にすると、予期せぬテーブルの存在を検知したときテストを fail させます。これらの流れで、Migration Test は実現されます。MigrationTestHelper が自動で Schema の検証を行ってくれますが、SELECT文を発行するなどし、自分で正しいデータを検証することが推奨されています。
また一度に複数の Migration を当てることも可能です。
任意の時点での Schema を再現できるという点が非常に協力な Migration Test ですが、以下の注意点が存在します。
- Schema 定義ファイルはチーム全員で共通化されるため、常に最新の状態に保つ必要がある10)単純な差分マージでは対応できません
- Schema Version をあげなくても export された Schema 定義ファイルは上書きされる
- SQL を手で発行しないと過去の Schema にデータを追加できない
したがって、Branch model や Seed ファイルなど、運用でカバーする側面もあるでしょう。Migration Test の実行タイミングなども肝になりそうです。
In-memory database によるテスト
前節では Migration Test に触れました。実際に Test をする上では Migration だけでなく、実際に DB にデータを入れて取り出すといった行為が必要です。ただテストケースそれぞれで新しい DB を作成するとエミュレータのストレージを圧迫したり、逐一 drop all しているとかなりの時間を消費することになります。
そこで Room はこの問題に対して in-memory database を用いるアプローチを提供しています。
AppDatabase database = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getTargetContext(),
AppDatabase.class).build();
また Room の Dao は interface であるため、Data Access 層に関しては全て Test Double でテストが可能になります。
脚注
↑1 | Getter/Setterの命名規則はJava Beansの命名規則に従います |
---|---|
↑2 | Entity の id などを無理なく型付できる点が個人的に嬉しいです。 |
↑3 | |
↑4 | Queryアノテーション内で Delete 文を手書きしても transaction で実行されるようにラップされますが、Select 文はラップされません |
↑5 | 正確には Dao ではなく Database 内に実装が存在する |
↑6 | LiveData の場合、inactive であればそもそもレコードを取得しない作りになっています |
↑7 | Room は Entity の持つオブジェクト構造を Entity 間の Relation として解釈しないという理解が正しいです |
↑8 | Dao の機能であって Entity の機能ではありません |
↑9 | 書式は CREATE SQL文だけでなく、Entityの構造なども記録した json です |
↑10 | 単純な差分マージでは対応できません |