🐣

Android Roomのmigrationをやってみた

2021/12/22に公開

1 やりたいこと

タイトル通り、Androidでローカルデータベースにデータを保存するRoomのmigrationを色々試したものを共有します。
 Androidの中でデータベースを使おうとすると、SQLiteを使う方法[1]とRoomを使う方法[2]がある。後者はdata classを作って、少しコードを書くだけで使いやすい。そこで、Roomを使用したいと思う。ところが、アプリを保守したり開発を進めたりすると、データベースが変わることがあり、その際にmigrationが必要である。その、migration方法についてまとめる。
すぐに本題に入りたい方は4章までジャンプしてください。

2 Roomの導入

Roomを使うにはapp/build.gradleを下記のように書き換える。

app/build.gradle
plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
}
...中略
dependencies {
...中略
//Roomを使うために下記を追加
    implementation "androidx.room:room-runtime:2.4.0"
    implementation "androidx.room:room-ktx:2.4.0"
    kapt "androidx.room:room-compiler:2.4.0"
}

3 初期データベースの作成と利用

3.1 エンティティの作成

はじめにエンティティを作成します。このコードはAndroid公式のページ[2:1]を引用して、tableNameの指定だけ追加しています。

DBTest.kt
package email.whoto.dbmigrationtest

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "dbTest")
data class DBTest(
   @PrimaryKey val uid: Int,
   @ColumnInfo(name = "first_name") val firstName: String?,
   @ColumnInfo(name = "last_name") val lastName: String?
)
エンティティとテーブルの違い

Roomの使用方法のページ[2:2]に「エンティティ」という言葉が出てきて、エンティティとテーブルの違いについて言葉で説明できないと思い、調べた内容を書きます。TeamLAB[3]によると


エンティティ : データベースで管理する実体。言い換えると管理したい情報のこと。多くは管理したい情報の名前。
例)
エンティティ:メール
属性:送信日時、宛先、送り主、本文


テーブル : エンティティを元に設計された概念モデルを、RDB(Relational Data Base)で扱える形にしたもの。
例)

UID 送信日時 宛先 送り主 本文
1 2021-01-01 you@aaa.bbb me@aaa.bbb あけましておめでとうございます。

上の例だと有り難みがないですが、エンティティ同士の関係が1対多のとき、テーブルが複数にまたがることがあるので、エンティティとテーブルの関係は必ずしも双射(それぞれの属性が対の関係になること)ではないです。

3.2 Dao(Data Access Object)の作成

データベースにアクセスする際に使用するメソッドを格納したものを、Daoと言います。それを作っていきます。今回は、完全にAndroid公式ページ[2:3]から引用します(後で書き換えていきます)。

DBDao.kt
package email.whoto.dbmigrationtest

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query

@Dao
interface DBDao {
    @Query("SELECT * FROM dbTest")
    fun getAll(): List<DBTest>

    @Query("SELECT * FROM dbTest WHERE uid IN (:userIds)")
    fun loadAllByIds(userIds: IntArray): List<DBTest>

    @Query(
        "SELECT * FROM dbTest WHERE first_name LIKE :first AND " +
                "last_name LIKE :last LIMIT 1"
    )
    fun findByName(first: String, last: String): DBTest

    @Insert
    fun insertAll(vararg users: DBTest)

    @Delete
    fun delete(user: DBTest)

}

3.3 Databaseの作成

こちらも、Android公式ページ[2:4]から引用します。

DBDatabase.kt
package email.whoto.dbmigrationtest

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(entities = arrayOf(DBTest::class), version = 1, exportSchema = false)
abstract class DBDatabase : RoomDatabase() {
    abstract fun userDao(): DBDao
}

3.4 作成したデータベースのインスタンスを作成し、アクセスする

こちらも、基本的にはAndroid公式ページ[2:5]を参考にします。

TestDBImpl.kt
class TestDBImpl @Inject constructor(): TestDB {
    override suspend fun initialInsert(context: Context): Boolean = 
    withContext(Dispatchers.Default) {
        val db = Room.databaseBuilder(
            context,
            DBDatabase::class.java, "database-name"
        ).build()

        val testDB = db.userDao()
        testDB.insertAll(
            DBTest(
                uid = 1,
                firstName = "Taro",
                lastName = "Usami",
            ),
            DBTest(
                uid = 2,
                firstName = "Hanako",
                lastName = "Saito"
            )
        )
        val result = testDB.getAll()
        for(r in result) {
            Log.d("DB Test", "${r.lastName}, ${r.firstName}")
        }
        true
    }
}

実行結果は以下です

~dbmigrationtest D/DB Test: Usami, Taro
~dbmigrationtest D/DB Test: Saito, Hanako

無事、2つの行が挿入できていそうなことがわかりました。

databaseBuilderの中の"database-name"は何か

気になったので調べてみました。stackoverflow[4]なので正しい情報か分かりませんが、データベースを作成するときに生成されるdbファイルの名前だそうです。理論上、一つのアプリで複数のデータベースにアクセスできるそうです。

RoomDatabaseオブジェクトをインスタンス化するときに気をつけるべきこと

Android公式ページ[2:6]に書かれていますが、RoomDatabaseインスタンスは非常に高コストであるので、単一のプロセス内で複数のインスタンスにアクセスする必要はほとんど無く、シングルトン設計パターン[5]に従うことが求められるそうです。
マルチプロセスで動くアプリケーションについては、インスタンス作成時にenableMultiInstanceInvalidation()を付与すると、複数のインスタンス作成を無効にできます[2:7][6]

4 データベースのエンティティを修正し移行(migration)する

ここから本題です。

4.1 新しいエンティティを作成する

先程作ったエンティティに、OSの種類という列を追加したいと思います。新しいエンティティは以下のようになります。

DBTest.kt
@Entity(tableName = "dbTest")
data class DBTest(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?,
    @ColumnInfo(name = "os_name") val osName: String?,
)

4.2 移行するためのSQL文を書く

そして、移行するためのコードを書きます。SQL文になります。

val MIGRATION_1_2 = object : Migration(1,2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE dbTest ADD COLUMN os_name TEXT")
    }
}

4.3 Daoのバージョンを上げる

忘れがちなのですが、バージョンを上げないとエラーが出てしまいます。

DBDatabase.kt
package email.whoto.dbmigrationtest

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(entities = arrayOf(DBTest::class), version = 2, exportSchema = false)
abstract class DBDatabase : RoomDatabase() {
    abstract fun userDao(): DBDao
}

4.4 移行用のSQL文をインスタンス作成時に呼ぶ

そして、この(4.2の)SQL文をaddMigrations()でDBDatabaseのインスタンスを作成するときに呼べば良いです。したがって、コードは以下のようになります。

TestDBImpl.kt
package email.whoto.dbmigrationtest

import android.content.Context
import android.util.Log
import androidx.room.Room
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject

class TestDBImpl @Inject constructor() : TestDB {
    override suspend fun initialInsert(context: Context): Boolean =
        withContext(Dispatchers.Default) {
            val db = Room.databaseBuilder(
                context,
                DBDatabase::class.java, "database-name"
            ).addMigrations(MIGRATION_1_2)
                .build()

            val testDB = db.userDao()
            testDB.insertAll(
                DBTest(
                    uid = 3,
                    firstName = "Haru",
                    lastName = "Usami",
                    osName = "Ubuntu",
                ),
                DBTest(
                    uid = 4,
                    firstName = "Hikari",
                    lastName = "Saito",
                    osName = "macOS",
                )
            )
            val result = testDB.getAll()
            for (r in result) {
                Log.d("DB Test", "${r.lastName}, ${r.firstName}, ${r.osName}")
            }
            true
        }
}

4.5 実行結果

このコードを実行すると以下のような結果が得られ、移行が完了しました。

~dbmigrationtest D/DB Test: Usami, Taro, null
~dbmigrationtest D/DB Test: Saito, Hanako, null
~dbmigrationtest D/DB Test: Usami, Haru, Ubuntu
~dbmigrationtest D/DB Test: Saito, Hikari, macOS

5. 実は…

本当は、Migrationにテストを導入して、それを中心に書きたかったのですが、エラーを倒すことが出来ず、力尽きた形になりました(無念…)。
また、機会を見つけて追加したいと思います。
どなたか、RoomのMigrationのテストについて、わかる方がいらっしゃいましたら、ぜひzennに投稿をお願い致します。

脚注
  1. AndroidでSQLiteを使う方法 https://developer.android.com/training/data-storage/sqlite?hl=ja (2021-12-22閲覧) ↩︎

  2. AndroidでRoomを使う方法 https://developer.android.com/training/data-storage/room?hl=ja (2021-12-22閲覧) ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. データベースを設計してみよう/TeamLAB https://team-lab.github.io/skillup/step2/09-db-design.html (2021-12-22閲覧) ↩︎

  4. What is the database name in the Room.databaseBuilder? https://stackoverflow.com/questions/64560980/what-is-the-database-name-in-the-room-databasebuilder (2021-12-22閲覧) ↩︎

  5. トッププログラミングオブジェクト指向2020.9.22更新 シングルトン 【singleton】 https://e-words.jp/w/%E3%82%B7%E3%83%B3%E3%82%B0%E3%83%AB%E3%83%88%E3%83%B3.html (2021-12-22閲覧) ↩︎

  6. Room Library Reference https://developer.android.google.cn/jetpack/androidx/releases/room?hl=ja (2021-12-22閲覧) ↩︎

Discussion