Realm Java 3.0: コレクション通知、スナップショット、関連によるソート

本日、Realm Java 3.0をリリースしました。このリリースでは、関連をつかった RealmResults のソートや、RealmResultsRealmList などのコレクションが更新された際の通知で更新内容の詳細(追加/削除/更新)を受け取ることができる機能を追加しています。これらの機能によりRealmが提供するライブなコレクションの活用の幅が大きく広がります。

コレクションの詳細な変更通知(Fine-grained Collection Notification)

これまでRealmでは、RealmRealmObjectRealmResultsクラスに対してRealmChangeListenerインターフェイスをつかった通知機能を提供してきました。このインターフェイスには一つ大きな制限があります。それは、呼び出されることにより 何か が変わったということはわかるのですが、何がどのように変わったかについてはわからないという点です。変更があったことがわかれば十分という場合も多いのですが、もっと詳しい情報が必要になるケースもあります。たとえば、RecyclerViewで変更内容に応じたアニメーションを行いたい場合などです。これまでは、realm.copyFromRealm()とAndroid Support LibraryのDiffUtilを組み合わせることで実現することもできましたが、望ましいものではありませんでした。

本リリースでは、コレクションの詳細な変更通知(Fine-grained Collection Notification)機能を追加しました。RealmResultsに登録するための新たなインターフェイスにより実現されるもので、どの要素が追加、削除、変更がされたかについての情報を受け取ることができます。これにより、実際に変更があった要素についてのみ更新を行うことができるようになります。

併せて、Android AdaptersライブラリについてもRealmRecyclerViewAdapterが詳細な変更通知を利用するように更新し、スムーズなアニメーションを行うようになっています。次の動画で、実際にどのような違いが出るかを見ることができます。

private final OrderedRealmCollectionChangeListener<RealmResults<Person>> changeListener = new OrderedRealmCollectionChangeListener() {
    @Override
    public void onChange(RealmResults<Person> collection, OrderedCollectionChangeSet changeSet) {
        // `null`  means the async query returns the first time.
        if (changeSet == null) {
            notifyDataSetChanged();
            return;
        }
        // For deletions, the adapter has to be notified in reverse order.
        OrderedCollectionChangeSet.Range[] deletions = changeSet.getDeletionRanges();
        for (int i = deletions.length - 1; i >= 0; i--) {
            OrderedCollectionChangeSet.Range range = deletions[i];
            notifyItemRangeRemoved(range.startIndex, range.length);
        }

        OrderedCollectionChangeSet.Range[] insertions = changeSet.getInsertionRanges();
        for (OrderedCollectionChangeSet.Range range : insertions) {
            notifyItemRangeInserted(range.startIndex, range.length);
        }

        OrderedCollectionChangeSet.Range[] modifications = changeSet.getChangeRanges();
        for (OrderedCollectionChangeSet.Range range : modifications) {
            notifyItemRangeChanged(range.startIndex, range.length);
        }
    }
};

コレクションの詳細な変更通知は、従来の変更通知のためのリスナーと同じように動作します。たとえばfindAllAsync()などにより非同期クエリが実行された場合変更内容を計算する処理はバックグラウンドスレッドで実行され、その後呼び出したスレッドで通知が行われます。DiffUtilを使った場合のような余分なメモリのオーバーヘッドはありません。

コレクションスナップショット

Realmの重要なデザインコンセプトの一つに、オブジェクトやコレクションがライブであるというものがあります。これは、たとえば検索結果を保持するRealmResultsは、データベース内のデータが変更されるとそれが自動的に反映されるということです。これはリアクティブアーキテクチャにとってとても効果的で、RealmChangeListenerを登録するだけで検索結果に影響する変更に対する通知を受け取ることができます。

しかし、このライブであるという特性が問題を引き起こす場合もあります。たとえばライブなコレクションに対してループで順番に要素にアクセスする場合です。次にそのような場合のコード例を示します。

RealmResults<Guest> uninvitedGuests = realm.where(Guests.class)
  .equalTo("inviteSent", false)
  .findAll(); 

for (i = 0; i < uninvitedGuests.size(); i++) {
  realm.beginTransaction()
  uninvitedGuests.get(i).sendInvite();
  realm.commitTransaction();
}

ここでは、招待されていない全てのゲストに対して招待を行うため未招待のゲストを取得しています。ここでuninvitedGuestsが通常のArrayListであれば、期待通り動作し全てのゲストに招待が行われます。

ところが、RealmResultsの場合は変更が自動的に反映されるため、ループの中でトランザクションを実行するとループが回る度にuninvitedGuestsに変更が反映されてしまいます。これにより招待が行われたゲストはリストから取り除かれ、要素が1つずつずれます。今回のコードでfor文はルーブのたびにインデックスを増やしているため、意図しない要素へアクセスすることになってしまします。結果として、全体の半分の人にしか招待が行われず、あきらかに意図した動作とは違います。

これに対しては2つの解決策が存在します。

// 1. ループ自体を一つのトランザクションの中で実行する
realm.beginTransaction()
for (i = 0; i < uninvitedGuests.size(); i++) {
  uninvitedGuests.get(i).sendInvite();
}
realm.commitTransaction();

// 2. リストに対して後ろから逆順にアクセスする
for (int i = uninvitedGuests.size() - 1; i >= 0; i--) {
  realm.beginTransaction()
  uninvitedGuests.get(i).sendInvite();
  realm.commitTransaction();
}

これらの方法はわかりやすいものではないため、Realm Java 0.89のリリースの際にRealmResultsに対するすべての更新を次のイベントループまで遅延させることを決めました(Handler.postAtFrontOfQueueを用いて実装されています)。

これはシンプルなforループが意図通りに動作するという利点をもたらしましたが、RealmResultが最新ではない状態が発生し、たとえばオブジェクトを削除した場合にもRealmResult中に無効なオブジェクトとして次のイベントループまで残り続けてしまうという欠点ももたらしました。

realm.beginTransaction();
guests.get(0).deleteFromRealm(); // Delete the object from Realm
realm.commitTransaction();
guests.get(0).isValid() == false; // You could now get a reference to a deleted object.

3.0においてこの判断について再度検討を行い、RealmResultsを以前のように完全にライブなものに戻すことを決めました。ただし、コレクションに対して通常通りforループを使えるようにするため、createSnapshot()メソッドを追加しています。このメソッドが返すOrderedRealmCollectionSnapshotはこれまで通りの変更の影響を受けないコレクションとして使用することができます。for文の中で要素を変更する場合は、このメソッドを用いて作成したスナップショットを使用してください(ただしループの中でトランザクションを作成することはほとんどの場合においてアンチパターンであることに変わりはありません)。

RealmResults<Person> uninvitedGuests = realm.where(Person.class).equalTo("inviteSent", false).findAll();
OrderedRealmCollectionSnapshot<Person> uninvitedGuestsSnapshot = uninvitedGuests.createSnapshot();
for (int i = 0; uninvitedGuestsSnapshot.size(); i++) {
    realm.beginTransaction();
    uninvitedGuestsSnapshot.get(i).setInvited(true);
    realm.commitTransaction();
}

イテレーターはこのスナップショットを内部で使用しているためfor-eachは意図通りに動作します。

realm.beginTransaction();
RealmResults<Person> uninvitedGuests = realm.where(Person.class).equalTo("inviteSent", false).findAll();
for (Person guest : guests) {
    realm.beginTransaction();
    uninvitedGuests.setInvited(true); 
    realm.commitTransaction();
}

この変更を行った理由はいくつかありますが、そのうちの一つはコレクションの詳細な変更通知を実装するにあたって内部のリファクタリングが必要だったということがあります。他には、RealmResultsRealmListの両方が完全にライブなものになるため、Realmの全てのクラスおいて完全に同じセマンティクスを提供できるというものがあります。これにより、ドキュメントも理解しやすいものにできます。以前の動作は混乱を招く場合もありましたが、完全にライブであることとそれに対するドキュメントの改善により、今まで以上に強力なAPIを提供できると考えています。

今回の変更が既存のコードベースに影響する場合があることは理解してますが、その影響はforループの中でトランザクションを作成している場合に限られるはずです。それ以外のコードについてはこれまで通り動作するので、長い目で見ればよい変更であったと思っていただけることを期待します。

関連プロパティを用いたソート

これまで、RealmResultsの要素のソートは対象クラスが直接持つプロパティでのみ行うことができました。Realm Java 3.0では、対象クラスが関連 関連として持っているクラスのプロパティを用いてソートできるようになりました。

たとえば以下のように、Personのコレクションをそれぞれが関連として持っているdogオブジェクトの年齢プロパティによってソートできるようになります。

RealmResults<Person> persons = realm.where(Person.class).findAllSorted(dog.age, Sort.ASCENDING);

これら以外についても、直近のリリースでさまざまなバグフィックスを行っています。変更の完全なリストについては、changelogをご確認ください。


お読みいただきありがとうございます。 Realm で素晴らしいアプリケーションを作りましょう!お困りの際はStack Overflow(日本語)Slack(日本語)Twitter(日本語)GitHub(英語)でご相談ください。


Realm Java Team

Realm Java Team

The Realm Java Team is working around the clock (litterally, we span 17 timezones) in order to create the best possible persistence solution on mobile. Easy-to-use, powerful features and first class performance is possible, and we are committed to building that.