クックパッドAndroidアプリにおける最近のDB運用事情

モバイルファースト室の @rejasupotaro です。

Androidフレームワークには端末内にSQLiteでデータを保存するしくみがありますが、みなさんはどのようにしてますか? クックパッドのAndroidアプリでは、ActiveAndroidを使ってDBにデータを保存しています。

ActiveAndroidとは

ActiveAndroid とは、Active Recordパターンを採用したAndroidのORMです。

テーブルのCREATEを行うときに、SQLiteOpenHeleperを継承したクラスでonCreateをOverrideしてdb.execQueryでCREATEクエリを実行…としなくても、ActiveAndroidを使えば、

public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        ActiveAndroid.initialize(this);
    }
}

このようにアプリの起動時に一行、initializeメソッドを呼ぶだけで定義されているモデルの構造を読み取ってテーブルが作成されます。

モデルの定義は、Modelクラスを継承してアノテーションで設定をしていきます。

@Table(name = "recipes")
public class Recipe extends Model {
    @Column(name = "recipe_id")
    private int recipeId;

    @Column(name = "category")
    private Category category;

        public Recipe(){
                super();
        }
        public Recipe(int recipeId, Category category){
                super();
                this.recipeId = recipeId;
                this.category = category;
        }
}

NOT NULL制約やUNIQUE制約に違反したときの振る舞いもアノテーションで設定することもできます。

モデルのDBへの保存と削除は Model.saveModel.delete で行うことができて、問い合わせはクエリビルダーを使うか Model.query で行うことができます。

マイグレーションをするときは assets/migrations ディレクトリの中に 3.sql のようにDBのバージョンを記入したファイルにクエリを書いておくと、バージョンアップ時に差分のクエリを実行することができます。

ActiveAndroidを使うことでDBの管理を楽にしたり、抽象化された表現でSQLiteにアクセスできるので、アプリの開発効率が上がりました。一方で、しばらく運用してみて気を付けるべきことや課題も見えてきました。

モデルをParcelableにすることができない

ActiveAndroidで扱うモデルは、Modelクラスを継承する必要がありますが、Modelクラスはidをprivateなフィールドに持っているのでParcelableにすることができません。 そのため、画面間でモデルをやりとりするにはidを渡してDBに問い合わせるか、別の方法でシリアライズして渡す必要があります。

マイグレーションに気を付ける

ロールバックについて

クックパッドアプリではリリースをする前に、深刻な障害が起こったときすぐに安定したバージョンに差し替えられるように、ロールバック用のapkを用意しています。ロールバック用のapkといっても、安定したバージョンからバージョンコードを上げたものですが、最新のapkでDBのバージョンを上げていた場合は、ロールバック用のapkのDBのバージョンをその一つ上にしておかないとクラッシュしてしまいます。

テーブルのスキーマ変更について

SQLiteの制限で、テーブル作成後はカラムの追加はできますが、カラムの変更や削除はできません。そのため、始めからできないものとしてカラムの変更や削除は避けるべきですが、どうしても行う必要がある場合もあります。

SQLiteOpenHelperを直接使っている場合は、onVersionChangedが呼ばれたときにデータを一度メモリに退避させて、テーブルをDROP AND CREATEして戻すということもできます。

しかし、ActiveAndroidを使っていた場合はマイグレーションファイルに書かれているSQLの実行しかできません。データを削除しないでカラムの変更をしたいというときには、ActiveAndroidの初期化の前にSQLiteOpenHelperでDBに接続してデータを取得して、ActiveAndroidの初期化を実行して(ここでマイグレーションが走る)、そのあとにデータを保存し直すなどの方法が考えられます。 このようにアドホックな解決方法になりがちなので、やはりカラムの変更はできないものとして設計するのが良いです。

マイグレーションファイルについて

マイグレーションは、デフォルトだと一行でコメントなしの単純なクエリしか実行することができませんでした。 あるバージョンからはSqlParserが実装されて、コメントや複数行に渡るクエリが解釈できるようになりましたが、デフォルトで使用されるSqlParserは SQL_PARSER_LEGACY になっているため、明示的にConfigurationに SQL_PARSER_DELIMITED を指定しないといけないので、注意が必要です。

ActiveAndroidは初期化に時間がかかる

クラスのスキャンは遅い

機能追加をするたびに Application.onCreate が肥大化して、初期化のパフォーマンスが低下していくということはよくあります。 クックパッドアプリで初期化処理を見直していたところ、DBの初期化に時間がかかっているということがわかり、ActiveAndroidの初期化のボトルネックがどこにあるか調べるためにTraceViewで見てみました。

f:id:rejasupotaro:20140917110704p:plain

この結果から ModelInfo.scanForModel というメソッドが全体の80%の時間を使っているということが分かります。 ソースを読んでみると、テーブルを作るためにDexFileをフルスキャンしてModelクラスのサブクラスを探索していました。

ドキュメントにはActiveAndroidの初期化はContextを渡すと書いてありますが、

public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        ActiveAndroid.initialize(this);
    }
}

Configurationを渡すことも可能です。明示的に設定値を渡すことで、フルスキャンしたりマニフェストにメタデータを読みにいくような重い処理をスキップできます。 また、SqlParserの設定もここで渡します。

public class DbConfiguration {
    ...

    public static void initialize(Context context) {
        Configuration configuration = new Configuration.Builder(context)
                .setDatabaseName(DATABASE_NAME)
                .setDatabaseVersion(DATABASE_VERSION)
                .setTypeSerializers(getTypeSerializersAsArray())
                .setModelClasses(getModelsAsArray())
                .setSqlParser(Configuration.SQL_PARSER_DELIMITED)
                .create();
        ActiveAndroid.initialize(configuration);

パッケージ内の探索はアプリの規模が大きくなればなるほど時間がかかります。 クックパッドアプリはクラス数が多いため探索に時間がかかっていましたが、モデルを明示的に指定することで1000ミリ秒以上速くなりました。

クラスの定義漏れを検出する

ActiveAndroidの初期化時のパラメータを明示的に指定することで初期化時間を短くすることができましたが、人間がモデル定義を指定すると新しくモデルを追加したときに漏れが発生して、定義されていないテーブルからデータを取得しようとしてクラッシュする、ということが考えられます。 モデルを追加するときは注意してレビューする、のような運用でカバーする方法もありますが、開発するときに気を付けないといけないことを増やすのはあまり良くありません。

定義漏れをなくすために、ビルド時にモデルの定義クラスを生成する方法と、テストでモデルの定義漏れを検出する方法を検討しましたが、ビルド時間に影響を与えないために、テストでモデルの定義漏れを検出できるようにしました。

public class DbConfiguration {
    ...

    public static List<Class<? extends Model>> MODELS = new ArrayList<Class<? extends Model>>() {{
        add(Recipe);
        add(SearchHistory.class);
        add(VisitedHistory.class);
        ...
    }};

このように、Modelのサブクラスをリストで定義して、テストでtargetContextのDexFileをフルスキャンして、クラスの定義を比較して、定義に漏れがあった場合はテストがfailするようにしました。

今後の課題

クエリのインタフェースを改善したい、ネットワークの先にあるデータとローカルのデータを透過的に扱えるようにしたい、などの要求も出てきましたが、ActiveAndroidの開発は現在あまり活発でないので、新しいアプリを作るときにはどうしようかなと思っているところです。

Androidアプリのデータをうまく扱えているという皆様、情報をお待ちしております。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/