Androidアプリ開発

アプリのリリース後に
Roomのテーブルを変更する

この記事は約25分で読めます。
記事内に広告が含まれています。
スポンサーリンク

この記事は Android スマホ用のアプリ開発の中で、
今後の開発で再使用性が高いと思われるコーディングをまとめたものです。
Java での開発経験、XML 構文規則、Android のアプリ開発経験がある方を対象としています。
Android のアプリ開発でお役にたててれば、嬉しいです。
(これから Android のアプリ開発や Java での開発を始めたい方への案内は、
記事の最後で紹介します)

この記事のテーマ


 アプリのリリース後にRoomデータベースのテーブルを変更する

Roomは、オープンソースデータベースのSQLiteを抽象化レイヤとして提供するライブラリです。
Andoridアプリで
SQLiteを使用する場合のベストプラクティスといっても過言ではありません。

Roomを使用することで、データベースの操作や定義、SQLの実行など、SQLiteを最大限に活用することが可能です。
今回は、Roomデータベースを組み込んだアプリをリリースした後に、データベースを変更するマイグレーションにフォーカスを当てて、開発したアプリのソースを参考に説明したいと思います。

移行(マイグレーション)

アプリをリリースした後にテーブルを変更する場合、変更前テーブルのデータを変更後テーブルに移すことが必要です。
テーブルの変更以外にも、テーブルの追加や削除するケースもあります。

アプリをリリースした後のテーブルの変更は、モジュールの入れ替えと合わせて、データベースの変更を行う必要があります。
Roomでは、データベースを変更する手順(移行パス)を組み込むことで、アプリにマイグレーションを実装できます。

マイグレーションするために必要な設定

マイグレーションでは、変更前テーブルのスキーマが必要になります。
変更後テーブルはアプリに含まれていますが、変更前テーブルのスキーマがありません。
変更前テーブルのスキーマを出力する仕組みがエキスポートスキーマ(
exportSchema)です。
エキスポートスキーマの指定は、データベース構成を定義する
RoomDatabaseを拡張する抽象クラスで行います。

Database

データベース構成を定義するRoomDatabaseを拡張する抽象クラスです。
@Databaseアノテーションを付けて、データベースに関連付けられたテーブル(エンティティ)をentities配列に含めます。
データベースに関連付けられた
Daoクラスのインスタンスを返す抽象メソッドを定義します。

@Database(entities = {Movie.class,
                      Chapter.class,
                      History.class }, version = 1, exportSchema = false)
public abstract class DuelDatabase extends RoomDatabase {
    public abstract MovieDao movieDao();
    public abstract ChapterDao chapterDao();
    public abstract HistoryDao historyDao();
}

versionはデータベースのバージョンを定義し、エンティティの追加や変更があった場合にカウントアップします。
スキーマを出力しない場合は、
exportSchema = falseを指定します。

<strong>重要ポイント</strong>
重要ポイント

exportSchema = false指定した場合、

アプリリリース後のエンティティ変更やautoMigrationが使用できません。

◎スキーマを出力する場合

スキーマを出力する場合、exportSchemaの出力先フォルダ(プロジェクトフォルダ配下にschemasというフォルダ)を作成し、モジュールのbuild.gradleファイルに出力先フォルダの定義を追加します。

android {
    :
    defaultConfig {
        :
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
            }
        }
    }

exportSchema = falseを削除します。

@Database(entities = {Movie.class,
                      Chapter.class,
                      History.class }, version = 1)
public abstract class DuelDatabase extends RoomDatabase {
    public abstract MovieDao movieDao();
    public abstract ChapterDao chapterDao();
    public abstract HistoryDao historyDao();
}

exportSchema=falseでマイグレーションすると…

exportSchemaのデフォルト値(省略時の値)は、Trueです。
このため、スキーマの出力先フォルダの指定がない、出力先フォルダがない場合にエラーが発生します。
このエラーは、
exportSchema=falseを指定することで回避できます。
しかし、テーブルの変更が発生した場合のマイグレーションで躓くことになります。

exportSchema=falseでマイグレーションすると、エラーが発生して、マイグレーションが失敗します。
変更前テーブルのスキーマがないため、テーブル構造が期待(Expected)と実際(Found)に違いが生じています。
(テーブル構造がExpectedとFoundが一致する場合、マイグレーションが成功する可能性があります)
このエラーは、テーブルの変更のみで発生するわけではなく、後述の手動マイグレーションで記述する移行パスに変更のないテーブルが含まれる場合でも発生します。

:
getContentResolver:Migration didn't properly handle: Movie(com.jiseifirm.duel.entity.Movie).
Expected:
TableInfo{name='Movie', columns={movie=Column{name='movie', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=1, defaultValue='undefined'}, point8=Column{name='point8', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point9=Column{name='point9', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point4=Column{name='point4', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point5=Column{name='point5', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, archive=Column{name='archive', type='TEXT', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point6=Column{name='point6', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, source=Column{name='source', type='TEXT', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point7=Column{name='point7', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, title=Column{name='title', type='TEXT', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point1=Column{name='point1', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point2=Column{name='point2', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point3=Column{name='point3', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point10=Column{name='point10', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}}, foreignKeys=[], indices=[Index{name='index_Movie_source', unique=true, columns=[source], orders=[ASC]'}]}
Found:
TableInfo{name='Movie', columns={movie=Column{name='movie', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=1, defaultValue='undefined'}, source=Column{name='source', type='TEXT', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, title=Column{name='title', type='TEXT', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point1=Column{name='point1', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point2=Column{name='point2', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point3=Column{name='point3', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point4=Column{name='point4', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, point5=Column{name='point5', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, archive=Column{name='archive', type='TEXT', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, pointer6=Column{name='pointer6', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='0'}, pointer7=Column{name='pointer7', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='0'}, pointer8=Column{name='pointer8', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='0'}, pointer9=Column{name='pointer9', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='0'}, pointer10=Column{name='pointer10', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='0'}}, foreignKeys=[], indices=[Index{name='index_Movie_source', unique=true, columns=[source], orders=[ASC]'}]}
:

自動マイグレーションと手動マイグレーション

Roomは、自動と手動のマイグレーションに対応しています。
自動はテーブル追加、テーブルの項目追加などの移行で、移行パスが生成できない場合、コンパイル時にエラーが発生します。
エラーがある場合、移行パスを正しく生成するために必要な追加情報を
AutoMigrationSpecで指定します。
基本的に手動での実装をオススメします。

◎自動マイグレーション

データベース構成を定義するRoomDatabaseを拡張する抽象クラスに、自動マイグレーションの実行を記述します。

@Database(entities = {Movie.class,
                      Chapter.class,
                      History.class }, version = 2,
                autoMigrations = {@AutoMigration(from = 1, to = 2)})
public abstract class DuelDatabase extends RoomDatabase {
    public abstract MovieDao movieDao();
    public abstract ChapterDao chapterDao();
    public abstract HistoryDao historyDao();
}

AutoMigrationSpecの指定方法は、こちらで確認できます。

◎手動マイグレーション

データベースのインスタンスを作成するクラスに、手動マイグレーションの移行パスを記述します。

public class DuelDatabaseSingleton {
    private static DuelDatabase instance = null;
    public static DuelDatabase getInstance(Context context) {
        if (instance != null) { return instance; }
        instance = Room.databaseBuilder(context, DuelDatabase.class, context.getString(R.string.db_name))
                       .addMigrations(MIGRATION_1_2).build();
        return instance;
    }
    static final Migration MIGRATION_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase duelDatabase) {
            duelDatabase.execSQL("CREATE TABLE `Chapter` (`movie` INTEGER PRIMARY KEY AUTOINCREMENT, `point1` INTEGER, `point2` INTEGER, `point3` INTEGER, `point4` INTEGER, `point5` INTEGER)");
        }
    };
}

移行パスをSQL(DDLまたは、DML)で記述します。
サンプルでは、テーブル(Chapter)を追加しています。

移行パスの記述方法は、こちらで確認できます。

exportSchema=falseを指定した場合のマイグレーション

exportSchema=falseを指定した場合、変更前テーブルのスキーマがありません。
このため、変更前テーブルのスキーマを必要としない移行パスを検討する必要があります。

変更前テーブルのスキーマを必要としない移行パスの一つとして、テーブルの追加があります。

マイグレーションのシナリオ

exportSchema=falseを指定していたため、マイグレーションでテーブル(Movie)に項目を追加できません。
追加する項目とテーブル(Movie)のプライマリキー(movie)をもつテーブル(Chapter)を追加します。

エンティティの追加

追加するテーブルをエンティティクラスとして実装します。

@Entity(tableName = "Chapter")
public class Chapter {
    @PrimaryKey(autoGenerate = true)
    public Integer movie;// シーケンス
    @ColumnInfo(name = "point1")
    public Long point1;  // 再生位置6
    @ColumnInfo(name = "point2")
    public Long point2;  // 再生位置7
    @ColumnInfo(name = "point3")
    public Long point3;  // 再生位置8
    @ColumnInfo(name = "point4")
    public Long point4;  // 再生位置9
    @ColumnInfo(name = "point5")
    public Long point5;  // 再生位置10
    public Chapter(Integer movie,
                   Long point1,
                   Long point2,
                   Long point3,
                   Long point4,
                   Long point5) {
        this.movie = movie;
        this.point1 = point1 != null ? point1 : 0;
        this.point2 = point2 != null ? point2 : 0;
        this.point3 = point3 != null ? point3 : 0;
        this.point4 = point4 != null ? point4 : 0;
        this.point5 = point5 != null ? point5 : 0;
    }
 }

データアクセスオブジェクト(Dao)の追加

追加するテーブルを操作するデータアクセスオブジェクトクラス(Dao)を実装します。

@Dao
public interface ChapterDao {
    // 参照
    @Query("SELECT * FROM Chapter WHERE movie = :movie")
    Chapter select(Integer movie);
    // 追加
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    long insert(Chapter chapter);
    // 更新
    @Update(onConflict = OnConflictStrategy.REPLACE)
    void update(Chapter chapter);
    // 削除
    @Delete
    void delete(Chapter chapter);
    @Query("DELETE FROM Chapter")
    void deleteAll();
    @Query("DELETE FROM sqlite_sequence WHERE name='Chapter'")
    void resetSequence();
}

RoomDatabaseを拡張する抽象クラスの変更

データベース構成を定義するRoomDatabaseを拡張する抽象クラスに、追加するテーブル(Chapter)の追記と、versionをカウントアップ(1 → 2)します。

@Database(entities = {Movie.class,
                      Chapter.class,
                      History.class }, version = 2)
public abstract class DuelDatabase extends RoomDatabase {
    public abstract MovieDao movieDao();
    public abstract ChapterDao chapterDao();
    public abstract HistoryDao historyDao();
}

データベースのインスタンスを作成するクラスの変更

データベースのインスタンスを作成するクラスに、手動マイグレーションの移行パスを記述します。

public class DuelDatabaseSingleton {
    private static DuelDatabase instance = null;
    public static DuelDatabase getInstance(Context context) {
        if (instance != null) { return instance; }
        instance = Room.databaseBuilder(context, DuelDatabase.class, context.getString(R.string.db_name))
                       .addMigrations(MIGRATION_1_2).build();
        return instance;
    }
    static final Migration MIGRATION_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase duelDatabase) {
            duelDatabase.execSQL("CREATE TABLE `Chapter` (`movie` INTEGER PRIMARY KEY AUTOINCREMENT, `point1` INTEGER, `point2` INTEGER, `point3` INTEGER, `point4` INTEGER, `point5` INTEGER)");
        }
    };
}

今回は、ここまでです。

アプリのリリース後にRoomのマイグレーションを実装した Androidアプリです。

誤字脱字、意味不明でわかりづらい、
もっと詳しく知りたいなどのご意見は、
このページの最後にある
コメントか、
こちら
から、お願いいたします♪

ポチッとして頂けると、
次のコンテンツを作成する励みになります♪

ブログランキング・にほんブログ村へ

これからAndroidのアプリ開発やJavaでの開発を始めたい方へ

初めての Android のアプリ開発では、アプリケーション開発経験がない方や、
アプリケーション開発経験がある方でも、Java や C# などのオブジェクト指向言語が初めての方は、
書籍などによる独学ではアプリ開発できるようになるには、かなりの時間がかかります。
オン