[Android Architecture Components] - Room 詳解

こんにちは。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 を用いており、以下の特徴を持ちます。

  1. 一般名詞なので検索がしづらい
  2. One-to-oneやOne-to-many などの Entity Relations をオブジェクト表現ではサポートしない
  3. Entity でない POJO へのマッピングが可能
  4. DB内データの変更を通知する機構を搭載
  5. SQLコンパイル時静的解析が可能
  6. 任意の 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 の構成要素

引用: Room Persistence Library

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 を記述出来る @QueryInsert/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 を当てることも可能です。

db = helper.runMigrationsAndValidate("migration-db", 4, true, MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4);

任意の時点での 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. この例では入力の型と出力の型が同一のため、双方向定義のようになっていますが、双方向定義をしなければならないといった制約はありません 検証方法に間違いがありました。Select 文を発行するかどうかに関わらず、『双方向定義が必要である』ことが正しいです。また双方向定義をしない際、Room のエラーメッセージでは『保存時の型が不明』という旨が表示されますが、読み出しの方法が不明の場合でも同一のエラーメッセージが出るため注意してください。(追記: 2017/06/11 17:24)
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. 単純な差分マージでは対応できません

Jumpei Matsuda

Lv.
4
EXP.
816

Android Engineer at Quipper, Ltd.

この執筆者の記事一覧

コメントはこちらのに同意の上、投稿ください。

この記事を読んだ人はこんな記事も読んでいます