Room を使用して複雑なデータを参照する

Room はプリミティブ型とボックス化型を変換できますが、エンティティ間のオブジェクト参照はサポートしていません。型コンバーターの使用方法と、Room がオブジェクト参照をサポートしない理由について説明します。

型コンバータを使用する

アプリでカスタムデータ型を単一のデータベース列に保存する必要が生じることがあります。カスタム型をサポートするには、型コンバータを提供します。これらは、Room が永続化できる既知の型とカスタム型との間での変換方法を Room に伝える関数です。型コンバータは、@ColumnTypeConverter アノテーションを使用して特定します。

Room データベースで Date のインスタンスを永続化する必要があるとします。Room は Date オブジェクトをネイティブに永続化できないため、型コンバータを定義する必要があります。

object Converters {
    @ColumnTypeConverter
    fun fromTimestamp(value: Long?): Date? {
        return value?.let { Date(it) }
    }

    @ColumnTypeConverter
    fun dateToTimestamp(date: Date?): Long? {
        return date?.time
    }
}

この例では、Date オブジェクトを Long オブジェクトに変換する関数と、Long オブジェクトを Date オブジェクトに変換する関数という 2 つの型コンバータ関数を定義しています。Room は Long オブジェクトを永続化できるため、これらのコンバータを使用して Date オブジェクトを永続化できます。

次に、@ColumnTypeConverters アノテーションを AppDatabase クラスに追加して、定義したコンバータ クラスを Room が使用できるようにします。

@Database(entities = [User::class], version = 1)
@ColumnTypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

これらの型コンバータを定義すると、プリミティブ型を使用する場合と同様に、エンティティと DAO でカスタム型を使用できます。

@Entity
data class User(
    @PrimaryKey val id: Long,
    val name: String,
    val birthday: Date?
)

@Dao
interface UserDao {
    @Query("SELECT * FROM user WHERE birthday = :targetDate")
    suspend fun findUsersBornOnDate(targetDate: Date): List<User>
}

この例では AppDatabase@ColumnTypeConverters アノテーションを付けているため、Room は定義済みの型コンバータをどこでも使用できます。型コンバータを特定のエンティティまたは DAO にスコープ設定するには、@Entity クラスまたは @Dao クラスに @ColumnTypeConverters アノテーションを付けます。

型コンバータの初期化を制御する

通常、Room は型コンバータをインスタンス化します。ただし、型コンバータのクラスに追加の依存関係を渡す必要がある場合は、アプリで初期化を直接制御する必要があります。その場合は、コンバータ クラスに @ProvidedColumnTypeConverter アノテーションを付けます。

@ProvidedColumnTypeConverter
class ExampleConverter {
    @ColumnTypeConverter
    fun stringToExample(string: String?): ExampleType? {
        return string?.let { ExampleType() }
    }

    @ColumnTypeConverter
    fun exampleToString(example: ExampleType?): String? {
        return example?.toString()
    }
}

@ColumnTypeConverters でコンバータ クラスを宣言するだけでなく、RoomDatabase.Builder.addColumnTypeConverter 関数を使用してコンバータ クラスのインスタンスを RoomDatabase ビルダーに渡します。

val db = Room.databaseBuilder<MyDatabase>(applicationContext, "database-name")
    .addColumnTypeConverter(exampleConverterInstance)
    .build()

Room がオブジェクト参照をサポートしない理由を理解する

重要ポイント: Room は、エンティティ クラス間のオブジェクト参照を許可していません。代わりに、アプリが必要とするデータを明示的にリクエストする必要があります。

データベースと各オブジェクト モデルとのリレーションをマッピングするのは一般的な方法であり、サーバーサイドでは効果的に機能します。プロパティがアクセスされたときにプログラムがプロパティを読み込む場合でも、サーバーは依然として効果的に動作します。

しかし、クライアント サイドでは、このような遅延読み込みは通常は UI スレッド上で発生するため、適しておらず、UI スレッドでディスク上の情報をクエリすると、パフォーマンスに大きな問題が発生します。通常、UI スレッドがアクティビティの更新後のレイアウトを計算して描画する際に与えられる時間は約 16 ミリ秒です。そのため、クエリに 5 ミリ秒しかかからなかったとしても、アプリによるフレーム描画が時間切れになり、明確に認識できる視覚的不具合を引き起こすことがあります。別のトランザクションが並列実行されている場合や、デバイスが別のディスク集中型タスクを実行している場合、クエリの完了にはさらに時間がかかる可能性があります。他方、遅延読み込みを利用しない場合、アプリは必要以上のデータを取得するため、メモリ消費上の問題が発生します。

通常、オブジェクト リレーショナル マッピングでは、アプリのユースケースに応じて最適化できるように、この決定をデベロッパーに任せています。デベロッパーは通常、アプリと UI の間でモデルを共有することを決定します。ただし、時間の経過とともに UI が変化していくと、デベロッパーにとって予測やデバッグが難しい問題が発生するため、この共有モデル ソリューションはあまり拡張性がありません。

たとえば、Book オブジェクトのリストを読み込む UI について考えてみましょう。各書籍には Author オブジェクトがあります。まず、遅延読み込みを使用して Book のインスタンスが著者を取得するようにクエリを設計したとします。author プロパティを最初に取得する際に、データベースをクエリします。しばらくして、アプリの UI にも著者名を表示する必要があることに気づきました。次のコード スニペットに示すように、この名前にアクセスできます。

Text(text = book.author.name)

しかし、この一見無害な変更により、メインスレッド上で Author テーブルがクエリされることになります。

事前に著者情報を照会したものの、その情報が不要になった場合、データの読み込み方法を変更することは困難です。たとえば、アプリの UI が Author 情報を表示する必要がなくなった場合でも、表示しなくなったデータをアプリは依然としてロードし、貴重なメモリ容量を無駄にすることになります。Author クラスが Books などの別のテーブルを参照する場合、アプリの効率はさらに低下します。

Room を使用して複数のエンティティを同時に参照する場合は、各エンティティを含むデータ オブジェクトを作成し、対応するテーブルを結合するクエリを記述します。適切に構造化されたこのモデルと、Room の堅牢なクエリ検証機能を組み合わせることで、アプリがデータを読み込む際に使用するリソースが減り、アプリのパフォーマンスとユーザー エクスペリエンスが向上します。