Android Room で relationships を扱う

Android アプリ開発においてデータの永続化を考えたとき、2021年12月現在において、最初の選択肢に挙げられる候補は Room です。

Room は SQLite の抽象化レイヤを提供する一種の ORM であり、Android Jetpack で提供されるライブラリの一つです。

基本的には Android Kotlin FundamentalsLesson 6: Room database and coroutines を通読すれば、おおよその操作感は理解できるのですが、実際のアプリ開発においては1つ問題が残ります。

Room がラップしている SQLite は関係データベースであり、そこに蓄積される情報は当然ながら正規化されています。

言い換えると、エンティティ単体で用いられることは、ほとんどありません。

もっと分かりやすく言うと INNER/OUTER JOIN 使いたいんだけど、どうするの?と思われるはずです。

そのあたりが知りたかったのに、とっ散らかっていて分かりづらいと個人的には不満を覚えました。




結論から述べると、(A) 埋め込みオブジェクトを作成する方法 と (B) ビューを作成する方法の二つがあります。

(A) の方法は data class を作成した後に、それに @Embedded アノテーションをつけて他の data class 内の変数にしてしまうか、両者を相互参照するレファレンスクラスを作成する方法です。

data class の入れ子構造になり、コードの見た目はシンプルになりますが、個人的にはあまり好きではありません。

(B) の方法は関係データベースでお馴染みのビュー(Android の View クラスとは別物)を表現する data class を作成し、そのビューの DAO に対して操作を行う方法となります。

SQL に精通していると直感的には分かりやすいです。

例として、ここに講義とその講師を保持するテーブルがあったとします。

講義 (courses) 講師 (instructors)
id 講義ID id 講師ID
title 講義名 name 氏名
instructorId 講師ID

これを Room の data class で表現したものが以下です。

@Entity(tableName = "instructors")
data class Instructor(
    @PrimaryKey(autoGenerate = true) var id: Long = 0L,
    @ColumnInfo var name: String = ""
)

@Entity(
    tableName = "courses", foreignKeys = [ForeignKey(
        entity = Instructor::class,
        parentColumns = arrayOf("id"),
        childColumns = arrayOf("instructorId")
    )]
)
data class Course(
    @PrimaryKey(autoGenerate = true) var id: Long = 0L,
    @ColumnInfo(name = "instructorId") @NotNull var instructorId: Long = 0L,
    @ColumnInfo(name = "title") var title: String = ""
)

通常であれば、ジャンクションテーブルの利用を考えることもありますが、簡略化するためにここでは2つのテーブルを直接的に JOIN するものとします。

これを (A) の方法で実装する場合、次のような data class を作成することになります。

data class CourseAndInstructor(
        @Embedded val instructor: Instructor,
        @Relation(
             parentColumn = "id",
             entityColumn = "instructorId"
        )
        val course: Course
)

(B) の場合、次のようなデータベースビューを表現する data class を作成します。

@DatabaseView(
    viewName = "CourseInstructor",
    value = "SELECT courses.id AS courseId, courses.title, instructors.name " +
            "FROM courses INNER JOIN instructors ON courses.instructorId = instructors.id"
)
data class CourseInstructor(
    val courseId: Long,
    val title: String,
    val name: String)

@Dao
interface CourseInstructorDao {
    @Query("SELECT * FROM CourseInstructor")
    fun getAll(): LiveData<List<CourseInstructor>>
}

これで想定どおりに動作してくれれば良いのですが、Room では data class をエンティティとして扱おうとするので以下のようなエラーがでます。

error: Entity class must be annotated with @Entity

ここで data class CourseInstructor に対して @Entity アノテーションを付けると、データベースビューはエンティティではないので、またエラーが表示されます。

error: There is a problem with the query: [SQLITE_ERROR] SQL error or missing database (no such table: CourseInstructor) 
public abstract androidx.lifecycle.LiveData > getAll();

それではどうすれば良いのかと言えば、 RoomDatabase の実装時に @Database アノテーションでビューを定義したクラスを示してやれば良いわけです。

@Database(
    entities = arrayOf(Instructor::class, Course::class),
    views = arrayOf(CourseInstructor::class),
    version = 2
)
abstract class Database : RoomDatabase() {
} 

ドキュメントが散逸しているために取っ付き難い印象がありますが、Room は Google が推進しているだけあって安心して使えるので、積極的に使っていきたいものです。


話は変わりますが Android Kotlin Fundamentals のコードを流用しているとメモリリークに対する警告が表示されることがあります。

W/DataBinding: Setting the fragment as the LifecycleOwner might cause memory leaks because views lives shorter than the Fragment. Consider using Fragment’s view lifecycle

こういうときは binding の lifecycleOwner を変更すると解消します。

 override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = DataBindingUtil.inflate(inflater, layoutId, container, false)
        binding.lifecycleOwner = this
//        binding.setLifecycleOwner(this)

        return binding.root
    }
override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = DataBindingUtil.inflate(inflater, layoutId, container, false)
        binding.lifecycleOwner = viewLifecycleOwner
        return binding.root
    }

Leave a Reply

Your email address will not be published. Required fields are marked *