見出し画像

SQLiteで運用されてきたAndroidアプリの内部データベースをRoomに置き換えた話

こんにちは、ホンビノス五郎です。ナビタイムジャパンでビジネスナビタイム動態管理ソリューションのAndroidアプリ開発を担当しています。

現在公開しているビジネスナビタイム動態管理ソリューションのAndroidアプリは、2015年に公開されて以来、同じアーキテクチャでメンテナンスされ続けていました。
しかし、公開当初に比べ機能も増え、ビジネスロジックも複雑化したことでコードの見通しが悪くなり、改修や不具合調査が難しいという課題を抱えるようになりました。

そこで、コードの可読性・保守性を改善し、開発効率と品質の向上を促進するために、アプリのアーキテクチャを見直すことになりました。

その第一段階として、それまで SQLite で定義・更新・参照していたアプリ内データベースを、 Room に置き換えました。
今回は、 Room への置き換えを行なった理由とその方法についてお話しいたします。

なぜRoomへの置き換えをしたのか

背景

アプリのアーキテクチャを見直すにあたって、次の2点の両立を目指すことになりました。

  • ビジネスロジックがUIから分離されたアーキテクチャになっていること

  • ローカルDBの内容を監視し、その内容を逐次 UI に反映する機構

前者については、アーキテクチャを見直す目的たるコードの可読性・保守性向上のために必要です。
後者についても、永続化したデータとUIの状態にずれがあるような不具合を生まないために必要な機構であると判断しました。

そして上記2つの要素を両立するための方法として、 Kotlin Flow を利用することとしました。
Flow を利用することとした理由は以下です。

  • Android公式ドキュメントにも記載があり、参考資料が豊富

  • 社内で利用事例がある

  • LiveDataと異なり Android SDK に依存しないためUIレイヤーの変更に強い

Flowを使う上での課題

既存コードではデータベースの内容をUIに逐次反映するために、SQLiteOpenHelper + ContentResolver + Loader の組み合わせを利用していました。
しかしその部分ををそのままデータレイヤーで使い続けた上で、 Flow を使ってUIレイヤーに公開することは、次の理由から現実的ではないことがわかりました。

  • Cursor という実装難度の高いAPIを使っているため保守性が低い

  • LoaderはFragmentのAPIを介して利用しており、FlowをI/Fとしたビジネスロジックの分離が困難

この「既存のデータベース監視機構とFlowを組み合わせられない」という課題を解決するために、データベースを Room に置き換えることにしました。

Roomを選んだ理由

置き換え先としてRoomを選んだのは、次のようなメリットがあったからです。

  • Room でデータの入出力を行う Dao は Flow を標準でサポートしており、データベースの更新を Flow に伝達する機構を簡単に実装できる

  • 公式推奨ライブラリなので、安定して利用し続けられる可能性が高い

  • SQLiteから段階的に移行するガイドラインが整備されている

  • データベースの実体はSQLiteなので、既存のデータベースをそのまま使える

Flowとの相性だけでなく、安全かつ着実に移行ができる環境が整っていることも大きなポイントでした。

置き換えの方針

置き換えは次のような方針で進めることにしました。

  • データベース定義の実装を SQLiteOpenHelper から Room に置き換える

  • 既存コードでSQLiteOpenHelper を参照していた箇所を、 Room で取得できる SupportSQLiteOpenHelper に差し替える

  • ビジネスロジックとUIの分離に合わせて徐々にDaoを使った更新・参照に切り替える

このような方針とした理由は3つあります。

  • 既存コードのほとんどが Java で書かれており Flow が使えない

  • 複数の既存 Fragment が Loader に強く依存しており、全ての変更監視をすぐに Room + Flow に移行するのには開発コストがかかりすぎる

  • 先に定義のみ移行しておけば、Dao を作成するだけで Flow を使った監視の仕組みが簡単に構築できる

並行して新機能開発も行われていたため、既存コードへの影響を最小限にしつつ、できるだけ技術的負債を減らすことを重視しました。

実装

置き換えの実装は、公式ドキュメントの移行ガイドに従って行いました。

既存コードは Java でしたが、新しく作成したクラスは Kotlin で記述しました。
また、先述した置き換えの方針に従い、 Dao と Flow による変更監視は実装せず、データベース定義の移行部分のみに絞って説明します。

ここでは、次のような SQLiteOpenHelper があったときの置き換え方法を説明します。

public class UserDatabaseOpenHelper extends SQLiteOpenHelper {

    public interface Columns {
        String ID = "id";
        String NAME = "name";
        String AGE = "age";
    }

    public interface Databases {
        String USER = "user.db";
    }

    public interface Tables {
        String USER = "user_t";
    }

    private static final int DATABASE_VERSION = 2;

    public UserDatabaseOpenHelper(Context context) {
        super(context, Databases.USER, null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.beginTransaction();
        try {
            db.execSQL(createSearchHistoryTableSql());
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    }

    private String createSearchHistoryTableSql() {
        return "create table if not exists " + Tables.USER + " (" +
                       Columns.ID + " TEXT primary key not null," +
                       Columns.NAME + " TEXT" +
                       Columns.AGE + " INTEGER" +
                       ");";
    }
}

① 依存関係を追加

build.gradle に依存関係を追加します。

dependencies {
    def room_version = "2.4.3"

    implementation "androidx.room:room-runtime:$room_version"
    implementation "androidx.room:room-ktx:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
    androidTestImplementation "androidx.room:room-testing:$room_version"

    testImplementation "junit:junit:4.13.2"
    androidTestImplementation "androidx.test:core:1.4.0"
    androidTestImplementation "androidx.test.ext:junit:1.1.3"
    androidTestImplementation "androidx.test:runner:1.4.0"
    androidTestImplementation "androidx.test:rules:1.4.0"
}

② SQLiteOpenHelper で実装しているテーブル作成と同じ定義の Entity を作成

既存の SQLiteOpenHelper 継承クラスでデータベース定義を行なっているSQLと同じ定義になるように Entity を作成します

@Entity(tableName = UserTableInfo.TABLE_NAME)
data class UserEntity(
        @PrimaryKey
        @ColumnInfo(name = UserTableInfo.Columns.ID)
        val id: String,
        @ColumnInfo(name = UserTableInfo.Columns.NAME)
        val name: String?,
        @ColumnInfo(name = UserTableInfo.Columns.AGE)
        val age: Int?
)

object UserTableInfo {

    const val TABLE_NAME = "user_t"

    object Columns {
        const val ID = "id"
        const val NAME = "name"
        const val AGE = "age"
    }
}

置き換えにあたって、カラム名やテーブル名を既存コードから参照する必要がありました。
そのため、 UserTableInfo という object を作成し、既存のテーブル定義と同じ文字列を定数として持つようにしています。

③ RoomDatabase 継承クラスをシングルトンパターンで作成

②で作成したEntityを使い、 RoomDatabase を作成します。
この時、 version は既存 SQLiteOpenHelperで指定したものより一つ上の値を設定します。
既存の SQLite データベースを Room で参照できる形に更新する必要があるためです。

// 元々のバージョンが2だったので、一つ上の3に設定する
@Database(entities = [UserEntity::class], version = 3)
abstract class UserRoomDatabase : RoomDatabase() {
    companion object {

        const val USER_DB_NAME = "user.db"

        @Volatile
        private var INSTANCE: UserRoomDatabase? = null

        @JvmStatic
        fun getInstance(context: Context): UserRoomDatabase {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context,
                    UserRoomDatabase::class.java,
                    USER_DB_NAME
                )
                    .build()
                    .also { INSTANCE = it }
            }
        }
    }
}

④ Migration オブジェクトを設定

SQLite データベースを Room で参照できる形に更新する場合、バージョンのインクリメントだけではなく、以下コードのように Migration オブジェクトを設定する必要があります。

@Database(entities = [UserEntity::class], version = 3)
abstract class UserRoomDatabase : RoomDatabase() {
    companion object {

        const val USER_DB_NAME = "user.db"

        @Volatile
        private var INSTANCE: UserRoomDatabase? = null

        @JvmStatic
        fun getInstance(context: Context): UserRoomDatabase {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context,
                    UserRoomDatabase::class.java,
                    USER_DB_NAME
                )
                    .addMigrations(MIGRATION_2_3) // ←下で定義したオブジェクトを追加
                    .build()
                    .also { INSTANCE = it }
            }
        }

        // Migration継承objectを定義
        val MIGRATION_2_3 = object : Migration(2, 3) {
            override fun migrate(database: SupportSQLiteDatabase) {
                // テーブル定義に変更がないので実装は空で良い
            }
        }
    }
}


テーブル定義に変更がない場合、Migration オブジェクトの実装は空で問題ありませんが、注意が必要なケースもあります。

④-1 DBバージョンの古いアプリが市場で動いている場合は旧バージョンからの移行も実装が必要

Roomへの置き換えを行い、 SQLiteOpenHelper を参照しなくなったアプリでは、 SQLiteOpenHelper で発行していた更新SQLは全て RoomDatabaseの Migration オブジェクトで実行する必要があります。

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    if (oldVersion == 1 && newVersion == 2) {
        db.execSQL("ALTER TABLE " + Tables.USER + " ADD age INTEGER");
    }
}

上記のような SQLiteOpenHelper.onUpgrade を実装していた場合、次のように複数の Migration オブジェクトを定義して RoomDatabase.Builder に渡します。

@JvmStatic
fun getInstance(context: Context): UserRoomDatabase {
    return INSTANCE ?: synchronized(this) {
        INSTANCE ?: Room.databaseBuilder(
                context,
                UserRoomDatabase::class.java,
                USER_DB_NAME
        )
                .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
                .build()
                .also { INSTANCE = it }
    }
}

// カラム追加のMigration
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE user_t ADD age INTEGER")
    }
}

// SQLite→RoomのMigration
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
    }
}

こうすることで、データベースバージョンが1のアプリでも2のアプリでも Room への置き換えが成功します。

④-2 プライマリキーがNull許容の場合は、Null制約にしてテーブルを作り直す

SQLiteでは、初期バージョンの不具合に対する後方互換性維持のためプライマリキーがNull許容でもいい仕様になっています。 (参考)
しかし、Roomのテーブル定義ではプライマリキーはNull非許容でなければならないため、テーブル定義の変更が必要です。
ただし、SQLiteではカラムの定義を修正することができないため、テーブルを作り直さなければなりません。

プライマリキーがNull許容になっているテーブルのあるデータベースの初期化時に渡す Migration オブジェクトにおいて、テーブルを作り直すSQLを実行します。

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.beginTransaction()
        try {
            // 新しいテーブルを一時テーブルとして構築
            database.execSQL("""
                CREATE TABLE user_t_tmp(
                    id TEXT PRIMARY KEY NOT NULL,
                    name TEXT,
                    age INTEGER
                )
                """.trimIndent()
            )
            // 旧テーブルのデータを全て一時テーブルに追加
            database.execSQL("""
                INSERT INTO user_t_tmp (id,name,age)
                SELECT id,name,age FROM user_t
                """.trimIndent()
            )
            // 旧テーブルを削除
            database.execSQL("DROP TABLE user_t")
            // 新テーブルをリネーム
            database.execSQL("ALTER TABLE user_t_tmp RENAME TO user_t")

            database.setTransactionSuccessful()
        } finally {
            database.endTransaction()
        }
    }
}

④-1 で説明したような定義変更が他にある場合も、既存の最新バージョンから Room 対応版への Migration オブジェクトでだけテーブルの作り直しをすれば置き換えられます。

⑤ SQLiteOpenHelper 利用箇所を SupportSQLiteOpenHelper に

継承クラスのコンストラクタで初期化していたところを、 RoomDatabase.getOpenHelper()で取得したインスタンスを使うようにします。

UserDatabaseOpenHelper databaseOpenHelper = new UserDatabaseOpenHelper(getContext());

SupportSQLiteOpenHelper databaseOpenHelper = UserRoomDatabase.getInstance(getContext()).getOpenHelper();

getWritableDatabase(), getReadableDatabase() で取得できるクラスは SupportSQLiteDatabase になります。

SQLiteDatabase db = databaseOpenHelper.getWritableDatabase();

SupportSQLiteDatabase db = databaseOpenHelper.getWritableDatabase();

SupportSQLiteDatabase になったことで、 insert や query の書き方が変わるので対応します。
テーブル名やカラム名の参照も、 UserDatabaseOpenHelper 内で定義していたものから UserTableInfo で定義したものを使うように変更します。

// insert
ContentValues contentValues = new ContentValues();
contentValues.put(UserDatabaseOpenHelper.Columns.ID, "1");
contentValues.put(UserDatabaseOpenHelper.Columns.NAME, "ホンビノス五郎");
contentValues.put(UserDatabaseOpenHelper.Columns.AGE, 25);
long rowId = db.insert(UserDatabaseOpenHelper.Tables.USER, null, contentValues);

// query
Cursor result = db.query(
        UserDatabaseOpenHelper.Tables.USER,
        new String[]{UserTableInfo.Columns.NAME},
        UserTableInfo.Columns.ID + "=?",
        new String[] {"1"},
        null,
        null,
        null
);

// insert
ContentValues contentValues = new ContentValues();
contentValues.put(UserTableInfo.Columns.ID, "1");
contentValues.put(UserTableInfo.Columns.NAME, "ホンビノス五郎");
contentValues.put(UserTableInfo.Columns.AGE, 25);
long rowId = db.insert(UserTableInfo.TABLE_NAME, SQLiteDatabase.CONFLICT_ABORT, contentValues);

// query
SupportSQLiteQuery query = SupportSQLiteQueryBuilder.builder(UserTableInfo.TABLE_NAME)
        .columns(new String[]{UserTableInfo.Columns.NAME})
        .selection(UserTableInfo.Columns.ID + "=?", new String[] {"1"})
        .create();
Cursor result = db.query(query);

⑥ 移行テストを書いて Room への移行が正常に行われることを検証

SQLiteOpenHelper で構築されたデータベースを Room 対応したものに更新し、更新後もデータが取得できることをテストします。
テストコードは androidTest ディレクトリ下に配置し、エミュレータまたは実機上で実行します。

@RunWith(AndroidJUnit4::class)
class UserRoomDatabaseMigrationTest {

    @Rule
    @JvmField
    val testHelper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        UserRoomDatabase::class.java,
    )

    private lateinit var context: Context

    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()

        // 以前のテストでmigrateしたDBが残っていた場合削除する
        SQLiteDatabase.deleteDatabase(context.getDatabasePath(UserDatabaseOpenHelper.Databases.USER))
    }

    @Test
    fun migrationSqliteToRoom_containsCorrectData() {
        // 旧DB構築
        val db = UserDatabaseOpenHelper(context).writableDatabase
        val contentValues = ContentValues().apply {
            put(UserDatabaseOpenHelper.Columns.ID, "1")
            put(UserDatabaseOpenHelper.Columns.NAME, "ホンビノス五郎")
            put(UserDatabaseOpenHelper.Columns.AGE, 25)
        }
        db.replace(UserDatabaseOpenHelper.Tables.USER, null, contentValues)
        db.close()

        // migration実行
        testHelper.runMigrationsAndValidate(
                UserRoomDatabase.USER_DB_NAME,
                3,
                true,
                UserRoomDatabase.MIGRATION_2_3
        )

        // Room DB構築
        val roomDatabase = Room.databaseBuilder(
                context,
                UserRoomDatabase::class.java,
                UserRoomDatabase.USER_DB_NAME
        )
                .addMigrations(UserRoomDatabase.MIGRATION_2_3)
                .build()
        testHelper.closeWhenFinished(roomDatabase)

        val query = SupportSQLiteQueryBuilder.builder(UserTableInfo.TABLE_NAME)
                .columns(null)
                .selection(null, null)
                .orderBy(null)
                .create()

        val resultCursor = roomDatabase.openHelper.readableDatabase.query(query)

        assertNotNull(resultCursor)

        resultCursor?.use { cursor ->
            assertTrue(cursor.moveToFirst())
            assertEquals("1", cursor.getString(cursor.getColumnIndex(UserTableInfo.Columns.ID)))
            assertEquals("ホンビノス五郎", cursor.getString(cursor.getColumnIndex(UserTableInfo.Columns.NAME)))
            assertEquals(25, cursor.getInt(cursor.getColumnIndex(UserTableInfo.Columns.AGE)))
        }
    }
}

終わりに

以上のような方法でデータベースを SQLite から Room に置き換えることができました。

引き続き、UIとビジネスロジックの分離、及び Flow を使ったデータベース監視への移行を進めています。