はじめに
Realm Advent Calendar 6日目を担当する@takkeです。
私が作成したAndroid用TwitterクライアントTwitPaneで「ツイートを保存するDB」としてRealmを採用してから4ヶ月ほど経ちました。
導入当初に Twitterクライアントの内部DBをSQLiteからRealmに移行したときのノウハウまとめ という記事を書きましたが今回はその続編としてその後の運用で得られたノウハウを書いていきます。
と言いますか・・・、
世間にはRealmの性能や便利な使い方など良い側面ばかり取り上げた記事があふれていますが僕らはもっと現実的なノウハウを求めているんです!!今回はその呼び水として「Realmが原因でクラッシュすることが多すぎたのでSQLiteに自動的に縮退する仕組みを作った」件について書きます。
Realmのバージョンは少し古くて 0.84.1 です。本アプリへの導入当初は 0.81.1 でした。
Realmが原因のクラッシュとは
ユーザーから見ると「強制終了」と「停止(ANR)」が発生しますが、本記事では前者を対象としています。
例えば私が愛用しているAWAアプリでもわりと頻繁に(おそらく後者も)発生していました。ここ数週間は見ていないので改善されたのかもしれません。まじでノウハウ欲しいです。
AWAアプリがRealmのNative Crashで起動しなくなった pic.twitter.com/C1yTIMvZEd
— 竹内裕昭 (@takke) October 28, 2015
この減らないRealm起因のクラッシュはどう扱えばいいんだろうか。万策尽きたんだけど。 pic.twitter.com/lQfuQgZTsL
— 竹内裕昭 (@takke) September 29, 2015
クラッシュしたらSQLiteに縮退する仕組み
TwitPaneはツイートの永続化にだけRealmを使ってる。メタデータはSQLiteに。
— 竹内裕昭 (@takke) September 19, 2015
昨日ようやく「Realmで深刻なエラーが起きたらSQLiteに縮退する版」をリリースした。これでクラッシュ頻度が下がるか様子を見る。
— 竹内裕昭 (@takke) September 9, 2015
Realmで致命的なエラーが起きたらSQLiteにフォールバックする機能をリリースして約10日、成果がここ数日でようやく出てきた感じ。クラッシュ数が少しずつ減ってきた。 pic.twitter.com/XrWJN68yFq
— 竹内裕昭 (@takke) September 18, 2015
縮退(フォールバック)というと大げさに聞こえますが簡単に言えば「RealmがダメそうならSQLiteに切り替える」だけです。
具体的には下記の処理を行っています。
- A:例外を捕捉してSQLiteへ縮退するフラグXを立てる処理
 - B:アプリ起動時にフラグXを検出して実際にSQLite版へ切り替える処理(フラグXを下ろして切替フラグを立ててログ出力する程度)
 - C:DB操作時にRealm版/SQLite版を自動的に判定して両者に振り分ける処理
 
このうち B と C は頑張って実装するだけなのでここでは A の具体例を示します。
例外捕捉の具体例
RealmError: Unrecoverable error. mmap() failed: Out of memory in io_realm_internal_SharedGroup.cpp line 115
のような例外を捕捉して SQLite に縮退します。
有名なRealm/Java利用時の制約として「Realm.getInstanceしたら必ずcloseすること」があるのでQuery実行時は下記のようなヘルパーメソッドを介して確実にcloseするようにしています。まずこの部分で例外を検出しています。
    /*package*/ interface RunnableWithRealmInstance<T> {
        T run(Realm realm);
    }
    /*package*/ static <T> T runWithRealmInstance(Context context, RunnableWithRealmInstance<T> logic) {
        Realm realm = null;
        //noinspection TryWithIdenticalCatches
        try {
            // Realmアクセス数更新
            Stats.sRealmAccessingCount++;
            realm = MyRawDataRealm.getRealmInstance(context);
            return logic.run(realm);
        } catch (RealmError e) {
            // RealmError: Unrecoverable error. mmap() failed: Out of memory in io_realm_internal_SharedGroup.cpp line 115
            // SQLite縮退フラグを立てる
            setFallbackToSQLiteFlag(context, e);
            MyLog.e(e);
            // 深刻なエラーなのでそのままスローする
            throw e;
        } catch (RealmException e) {
            MyLog.e(e);
            // ignore
            return null;
        } finally {
            // Realmアクセス数更新
            Stats.incRealmAccessCount();
            if (realm != null) {
                realm.close();
            }
        }
    }
    private static Realm getRealmInstance(Context context) {
        final Realm realm = Realm.getInstance(config);
        ...
        return realm;
    }
    private static void setFallbackToSQLiteFlag(Context context, Throwable e) {
        // 縮退フラグを立てる
        // ※以前は OOM のみだったのでキー名はOOMとなっている
        final SharedPreferences pref = TPConfig.getSharedPreferences(context);
        final SharedPreferences.Editor editor = pref.edit();
        editor.putBoolean(C.PREF_KEY_REALM_OOM_DETECTED, true);
        TPUtil.doSharedPreferencesEditorApplyOrCommit(editor);
        ...
    }
というわけで RealmError を捕捉して setFallbackToSQLiteFlag メソッドで SharedPreference にフラグを立てています。単純ですね!
ちなみに利用方法は下記のような感じです。
        final Integer v = runWithRealmInstance(context, new RunnableWithRealmInstance<Integer>() {
            @Override
            public Integer run(Realm realm) {
                int loadedCount = 0;
                // query
                RealmQuery<RORawData> query = realm.where(RORawData.class);
                query = query.equalTo("rowType", rowType);
                // start AND (
                query = query.beginGroup();
                int idCount = 0;
                for (long did : ids) {
                    if (idCount > 0) {
                        query = query.or();
                    }
                    query = query.equalTo("did", did);
                    idCount++;
                }
                // end )
                query = query.endGroup();
                final RealmResults<RORawData> results = query.findAll();
                final int m = results.size();
                for (int i = 0; i < m; i++) {
                    final RORawData data = results.get(i);
                    final String json = data.getJson();
                    final long did = data.getDid();
                    if (json != null) {
...
                    }
                }
                return loadedCount;
            }
        });
もちろんDB更新時にも同様のtry-catchで捕捉しています。
もうひとつの例外捕捉の仕組みとして、java.lang.IllegalStateException: Cannot use ImplicitTransaction after it or its parent has been closed. を検出する仕組みも作り込んでいます。こちらは UncaughtExceptionHandler として実装しています。
public class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {
    private final Thread.UncaughtExceptionHandler mDefaultHandler;
    private final WeakReference<Context> mContextWeakReference;
    public MyUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler, Context context) {
        mDefaultHandler = handler;
        mContextWeakReference = new WeakReference<>(context);
    }
    @Override
    public void uncaughtException(Thread thread, Throwable ex) {
        try {
            ...
            if (ex instanceof IllegalStateException && ex.getMessage().contains("Cannot use ImplicitTransaction")) {
                // java.lang.IllegalStateException: Cannot use ImplicitTransaction after it or its parent has been closed.
                // io.realm.Realm$RealmCallback.handleMessage() で発生しているのでここでしか補足できない。
                // ⇒OOM検出時と同様にDBクリアやSQLiteへの縮退を行う
                final Context context = mContextWeakReference.get();
                if (context != null) {
                    final SharedPreferences pref = TPConfig.getSharedPreferences(context);
                    final SharedPreferences.Editor editor = pref.edit();
                    editor.putBoolean(C.PREF_KEY_REALM_OOM_DETECTED, true);
                    editor.apply();
                }
            }
        } finally {
            if (mDefaultHandler != null) {
                mDefaultHandler.uncaughtException(thread, ex);
            }
        }
    }
}
    @Override
    public void onCreate(final Bundle savedInstanceState) {
...
        final Thread.UncaughtExceptionHandler handler = new MyUncaughtExceptionHandler(Thread.getDefaultUncaughtExceptionHandler(), getApplicationContext());
        Thread.setDefaultUncaughtExceptionHandler(handler);
...
効果と実績
Google Analytics のカスタムディメンションで現在どの程度のユーザーが SQLite 版に切り替えているのかを調べてみました。
				tracker.setScreenName(path);
				tracker.send(new HitBuilders.AppViewBuilder()
                        .setCustomDimension(1, TPConfig.useRawDataStoreRealm ? "Realm" : "SQLite") // RawDataType
                        .build());
アプリのインストール時はRealm版で動作しますが、利用中にRealmの例外を捕捉する(もしくは後述の方法でユーザーが変更する)とSQLite版に切り替わります。現在のところ17%以上のユーザーがSQLite版で利用しています。予想以上に多いですね。
このようにOSバージョンによって特段の傾向も見られないのが興味深いです。
(例えばSQLiteに変更しているのが4.2以前ばかりであれば古い端末でOOMが発生しやすい、といった推測もできますがそうではないようです)
ということは本アプリでの利用方法に何か大きな問題がありそうな気がしています。
クラッシュ以外でもRealmのDB削除が必要になるケースがある
単なるクエリー(findAll)で、本来はデータがあるはずなのに0件が返ってくるパターンがありました。
おそらくDBが壊れているのだと思いますがよく分かりません。
(DBのコピーを保存しておくとか、そろそろ調べる仕組みをちゃんと作らないとダメですね・・・)
また、論理的にも0件の場合があるのでDBが壊れているかどうかを判定する術が今のところありません。
手動でDB削除やSQLite切替をする仕組みを用意する
Androidの「アプリケーションの管理」で呼び出せる「容量の管理」をカスタマイズすることでDB削除やSQLite版への切替をする仕組みを作りました。下記スクリーンショットの「タブデータの削除」「タブデータ保存形式」がそれに該当します。
同じことをアプリ内の設定からも呼び出せますが、アプリ自体が起動しなくなった場合に備えて「容量の管理」からも呼び出せるようにしています。実装自体はAndroidの一般的な「容量の管理」なので省略します。
まとめ
Realm導入当初はこれほどまでにクラッシュが多くなるとは想像していませんでした。
Realm自体は日々更新されていてこのあたりもずいぶんと改善されてきたようにも思いますが依然として本アプリの17%のユーザーがSQLite版でアプリを使っています。
これは全く無視できない割合です!もしフォールバックの仕組みを用意していなければこれだけのユーザーが離れていた可能性があります。恐ろしいですね。。
RealmアドベントカレンダーなのにRealmをdisる方向の記事になってしまいましたが、Realmを安心して利用するためにもこういう部分も含めてしっかりとノウハウを共有していきたいですね(まじでこういうドロドロしたバッドノウハウを求めてます!)。




