RealmObjectを独自ルールでソートする
2015/04から担当しているプロダクトでRealmを使用しております。
RealmObjectのソートは RealmQuery#findAllSorted
や RealmResult#sort
等で行うのが普通だと思いますが、独自のソートルールなどで単純なソートでは対応できない場合があります。
その場合は Collections#sort
等を使用することになりますが、Realmには「取得したThread以外からのアクセスは不可」という制約があるため、UIで使用する場合はUI Threadでソート処理を実行することになります。
普通に書くとこんな感じになります。
public class RealmItem extends RealmObject {
@PrimaryKey
private long id;
private String name;
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class ItemModel {
/**
* 独自ルールでのソート済みRealmItemのリストを返却。
*/
public List<RealmItem> findAllSorted(Realm realm) {
RealmResults<RealmItem> items = realm.where(RealmItem.class).findAll();
return sort(items);
}
/**
* 独自ルールでソート。
*/
private List<RealmItem> sort(List<RealmItem> items) {
List<RealmItem> sortableList = new ArrayList<>(items);
Collections.sort(sortableList, (lhs, rhs) -> {
// アルファベット > 平仮名カタカナ > 数字でソート。
// 大文字小文字は区別しない。平仮名カタカナは区別しない。
// ソート用に平仮名→カタカナに変換してアルファベットは大文字に統一。
// ※StringUtilsは自作のutilityクラスです。
String lhsName = StringUtils.hiraganaToKatakana(lhs.getName()).toUpperCase();
String rhsName = StringUtils.hiraganaToKatakana(rhs.getName()).toUpperCase();
int lhsLength = lhsName.length();
int rhsLength = rhsName.length();
int length = Math.min(lhsLength, rhsLength);
for (int i = 0 ; i < length ; i++) {
int result = compareChars(lhsName.charAt(i), rhsName.charAt(i));
if (result == 0) {
continue;
}
return result;
}
return lhsLength - rhsLength;
});
return sortableList;
}
private int compareChars(char c, char another) {
// 数字が最後
if ((c < '0' || c > '9') && (another >= '0' && another <= '9')) {
return -1;
}
if ((c >= '0' && c <= '9') && (another < '0' ||another > '9')) {
return 1;
}
return c - another;
}
}
なお、このソートルールでランダムな文字列10,000件をソートしたところ、Nexus6(Android M)端末で 4,500〜4,800ms かかりました。UI Threadをこの単位で止めてしまうのは大変まずいです。
I/O Threadでソート可能にする
ということで、I/O Threadでソートする方法を考えます。
RealmObjectのThread制約は、取得したThread以外からRealmProxyを通す処理を呼び出せないということだと理解しています。上記の例で言うと、 RealmItem#getName
を別Threadでコールした時点で IllegalStateException
が発生します。
ですので、別ThreadからRealmObjectのgetterにアクセスしないように、ソート用のクラスをかぶせることにします。
public class ComparableItem implements Comparable<ComparableItem> {
private final RealmItem mRealmItem;
private final String mName;
public ComparableItem(RealmItem item) {
mRealmItem = item;
// ソートに使用する項目をここで取得しておく
mName = item.getName();
}
public RealmItem getRealmItem() {
return mRealmItem;
}
@Override
public int compareTo(ComparableItem another) {
// アルファベット > 平仮名カタカナ > 数字でソート。
// 大文字小文字は区別しない。平仮名カタカナは区別しない。
// ソート用に平仮名→カタカナに変換してアルファベットは大文字に統一。
String lhsName = StringUtils.hiraganaToKatakana(this.mName).toUpperCase();
String rhsName = StringUtils.hiraganaToKatakana(another.mName).toUpperCase();
int lhsLength = lhsName.length();
int rhsLength = rhsName.length();
int length = Math.min(lhsLength, rhsLength);
for (int i = 0 ; i < length ; i++) {
int result = compareChars(lhsName.charAt(i), rhsName.charAt(i));
if (result == 0) {
continue;
}
return result;
}
return lhsLength - rhsLength;
}
private int compareChars(char c, char another) {
// 数字が最後
if ((c < '0' || c > '9') && (another >= '0' && another <= '9')) {
return -1;
}
if ((c >= '0' && c <= '9') && (another < '0' ||another > '9')) {
return 1;
}
return c - another;
}
}
ソート用のルールも ComparableItem#compareTo
で定義したので、ソートをI/O Threadで実行する処理は以下のようにかけます。
public class ItemModel {
/**
* 独自ルールでのソート済みRealmItemリストをObservableとして返却。
*/
public Observable<List<RealmItem>> findAllSorted(Realm realm) {
RealmResults<RealmItem> items = realm.where(RealmItem.class).findAll();
return sort(items);
}
/**
* I/O Threadで独自ルールでソート。
*/
private Observable<List<RealmItem>> sort(List<RealmItem> items) {
return Observable.just(toComparableItems(items))
.subscribeOn(Schedulers.io())
.map(comparableItems -> {
Collections.sort(comparableItems);
return comparableItems;
})
.map(this::toRealmItems)
.observeOn(AndroidSchedulers.mainThread());
}
/**
* Convert RealmItems into ComparableItems.
*/
private List<ComparableItem> toComparableItems(List<RealmItem> items) {
return Observable.from(items)
.map(item -> new ComparableItem(item))
.toList().toBlocking().single();
}
/**
* Convert ComparableItems into RealmItems.
*/
private List<RealmItem> toRealmItems(List<ComparableItem> items) {
return Observable.from(items)
.map(item -> item.getRealmItem())
.toList().toBlocking().single();
}
}
UIで使用するRealmObjectをI/O Threadでソートすることができました。なお、ここで返却されるListはRealmResultやRealmListではないので、その点は注意が必要です。
また、ソート処理自体は変更されておりませんが、I/O Threadでの実行とComparableの使用により、同環境での処理時間が約半分(2,200〜2,400ms)ほどになりました。
比較処理を簡略化できる
RealmObjectにはプロパティとgetter/setter以外は原則定義できないため、RealmObject同士を比較する場合、各プロパティの編集や型変換は比較処理時に行わなければなりませんでした。
今回、比較自体はComparableインターフェースを実装したPOJOで行いますので、 Comparable#compareTo
の処理を簡略化すれば処理時間の短縮も図れます。
こんな感じで。
public class ComparableItem implements Comparable<ComparableItem> {
private final RealmItem mRealmItem;
private final String mCasedName;
private final int mLength;
public ComparableItem(RealmItem item) {
mRealmItem = item;
// ソートに使用する項目をここで取得しておく(型変換込み)
mCasedName = StringUtils.hiraganaToKatakana(item.getName()).toUpperCase();
mLength = mCasedName.length();
}
public RealmItem getRealmItem() {
return mRealmItem;
}
@Override
public int compareTo(ComparableItem another) {
// アルファベット > 平仮名カタカナ > 数字でソート。
// 大文字小文字は区別しない。平仮名カタカナは区別しない。
int length = Math.min(this.mLength, another.mLength);
for (int i = 0 ; i < length ; i++) {
int result = compareChars(this.mCasedName.charAt(i), another.mCasedName.charAt(i));
if (result == 0) {
continue;
}
return result;
}
return this.mLength - another.mLength;
}
private int compareChars(char c, char another) {
// 数字が最後
if ((c < '0' || c > '9') && (another >= '0' && another <= '9')) {
return -1;
}
if ((c >= '0' && c <= '9') && (another < '0' ||another > '9')) {
return 1;
}
return c - another;
}
}
細かい部分ですが、マージソートの最悪計算量が*O(n log n)*なので件数が多い場合はなかなかバカにできません。
今回の例ですと、この時点で10,000件ソートの処理時間が 700〜800ms まで短縮できました。処理時間もそうですが、なによりUI Threadを止めないようにするというのが重要だと思います。