LoginSignup
4

More than 5 years have passed since last update.

UI表示に使うRealmObjectをI/O Threadでソートする

Last updated at Posted at 2015-12-08

RealmObjectを独自ルールでソートする

2015/04から担当しているプロダクトでRealmを使用しております。
RealmObjectのソートは RealmQuery#findAllSortedRealmResult#sort 等で行うのが普通だと思いますが、独自のソートルールなどで単純なソートでは対応できない場合があります。

その場合は Collections#sort 等を使用することになりますが、Realmには「取得したThread以外からのアクセスは不可」という制約があるため、UIで使用する場合はUI Threadでソート処理を実行することになります。

普通に書くとこんな感じになります。

RealmItem.java
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;
    }
}

ItemModel.java
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にアクセスしないように、ソート用のクラスをかぶせることにします。

ComparableItem.java
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で実行する処理は以下のようにかけます。

ItemModel.java
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 の処理を簡略化すれば処理時間の短縮も図れます。

こんな感じで。

ComparableItem.java
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を止めないようにするというのが重要だと思います。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4