Androidで使えるシリアライザー特集

有山 圭二
75

みなさんおひさしぶりです。有山圭二です。
今年もGoogle I/Oの季節がやってきましたね。これまでサンフランシスコで開催されてきたGoogle I/Oですが、今年はMountainViewのShoreline Amphitheatreで開催されます。日程も2013年以来、3年ぶりの三日開催となりはっきり言って生きて帰れる気がしません。

さて、Google I/Oの前にはゴールデンウィークがあります。まとまった休みは普段できないことをやってみるチャンスと言うことで、Android Wear用のアプリを作ってみようと思い立ちました。

はじめに

Android Wearアプリ……最近はあまり話を聞きませんね。対応するのが当たり前と言うことでしょうか。普通にNotificationCompatを使っていれば巧くやってくれるようになったせいでしょうか(誰ですか。流行ってないとか言ってる人は!)。

閑話休題。モバイルアプリとWearアプリを通信させるにはGoogle ServicesのAPIを使います。その際のデータはバイト配列で送受信するので、適宜シリアライズ・デシリアライズをしてやる必要があります。普段、オブジェクトのシリアライズにはJSONを使うことが多いのですが、今回は可読性を意識することもないのでJSONは候補から外して、いろいろなバイナリ・シリアライザーを試してみることにしました。

免責事項

本書に記載された内容は情報の提供のみを目的としています。したがって、本書を用いた開発、製作、運用は、必ずご自身の責任と判断によって行ってください。これらの情報による開発、製作、運用の結果について、著者および株式会社リクルートマーケティングパートナーズはいかなる責任も負いません。

概要(忙しい人向け)

Android(Phone/Wear)で動作するシリアライザーであるSerializable, Parcelable, MessagePack, Protocol Buffers(Protobuf), Kryoについて、それぞれシリアライズ・デシリアライズの速度とシリアライズ後のデータサイズを計測しました。結果として速度ではシリアライズ・デシリアライズともにProtocol Buffersがもっとも速くデータサイズではMessagePackが最も小さいことがわかりました。
 

表1.1: 結果
serialize(ns) deserialize(ns) size(byte)
Serialize 605,078 1,380,990 1,353
Parcelable 206,041 195,209 1,324
MsgPack 338,763 1,734,232 383
ProtoBuf 193,763 139,284 560
Kryo 814,779 303,255 511
処理にかかった時間
処理にかかった時間
シリアライズ後のデータサイズ
シリアライズ後のデータサイズ

筆者はこれらの結果を受けてAndroid Wearとの通信をシリアライズするという目的に限定した場合、Parcelableによるシリアライズが適切であるとの結論に至りました。
 
一体なんのために調べたのかというツッコミがきそうですが、率直に言ってモバイルアプリとWearアプリ間の通信をシリアライズするためだけにサードパーティのライブラリを使うのは少々負荷が高すぎると感じます。アプリに組み込むライブラリのサイズを考えても、Protocol Buffersは600KB近くあります。これをモバイルアプリとWearアプリの双方に組み込むとなると、全体で1MBを超えるAPKサイズの増加が予想されます(ProGuard未使用時)。そこまでしてシリアライズ後のデータサイズと速度を必要とするような要件は、少なくとも今回のWearアプリにはありませんでした。

表1.2: ライブラリのサイズ
library size(KB)
Serialize 0
Parcelable 0
MsgPack 276.2
ProtoBuf 582.7
Kryo 279.0
ライブラリのサイズ
ライブラリのサイズ

もちろんAndroid Wearでの通信が込み入ったものになったり、そもそもマルチプラットフォームでのオブジェクトのやり取りが発生する場合は当然Protocol BuffersやMessagePackの出番はあるので、今回の調査は決して無駄にはならないでしょう。

テストするシリアライザー

Serializable

Java言語ではおなじみのSerializableインターフェースです。インターフェースを実装するだけなので導入は簡単です。

Parcelable

Androidでは一般的なシリアライズの形式です。AIDL (Android Interface Definition Language)などプロセス間でオブジェクトをやり取りする際にも使います。

定められた形式で実装する必要があることから以前は実装に手間がかかると言われていましたが、現在はAndroid Studioの自動生成機能が追加されたので比較的楽に実装できるようになりました。

MessagePack

古橋貞之氏(@frsyuki)が開発しているマルチプラットフォームのシリアライザーです。シリアライズ後のデータサイズが小さいことが特徴です。

今回は前述のMessagePackの公式サイトから案内されているmsgpackの最新バージョン0.6.12を使いました。msgpack-coreという名前でバージョン0.8.7が公開されています(0.6系よりあとでBreaking Changeがあったようで、既存のコードがそのままでは動かなかったため検証していません)。

Protocol Buffers(Protobuf)

Google社がオープンソースで開発しているマルチプラットフォームのシリアライザーです。AIDL同様あらかじめ記述したIDLにしたがって出力したコードを使います。Googleが公開した機械知能向けの計算フレームワークTensorFlowが採用しているデータ形式TFRecordもProtocol Buffersでプロトコルが定義されています。バージョン2系と3系がありますが、3系はまだβのため、今回は2.6.1を使いました。

Kryo

Esoteric Software社がオープンソースで公開しているマルチプラットフォームのシリアライザーです。筆者はこれまで使ったことがありませんでしたが、Javaのシリアライザーとしてよく名前が挙がっていることから今回のベンチマークに含めました。バージョンは3.0.3を使いました。

テストの内容

テストに用いたデータはリスト1.1の通りです。

リスト1.1
package io.keiji.serializerbenchmark.common;

public class SampleData {

    public enum Gender {
        Female,
        Male;
    }

    private long id;

    private String name;

    private int age;

    private Gender gender;

    private boolean isMegane;

    // アクセサ省略

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        SampleData that = (SampleData) o;

        if (id != that.id) return false;
        if (age != that.age) return false;
        if (isMegane != that.isMegane) return false;
        if (name != null ? !name.equals(that.name) : that.name != null) return false;
        return gender == that.gender;

    }

    @Override
    public int hashCode() {
        int result = (int) (id ^ (id >> 32));
        result = 31 * result + (name != null ? name.hashCode() : 0);
        result = 31 * result + age;
        result = 31 * result + (gender != null ? gender.hashCode() : 0);
        result = 31 * result + (isMegane ? 1 : 0);
        return result;
    }

}

リスト1.1のインスタンス30個を格納したリスト(リスト1.2)をシリアライズ、デシリアライズして、データサイズと実行にかかった時間を計測します。計測にはDebug.threadCpuTimeNanos()を用います。テストに使用した端末は「Nexus 5X(Android 6.0.1)」をメインに、Android 6.0.1搭載のAndroid Wearでも動作確認のために実行しています。

リスト1.2: データの生成
    private static final int LIMIT = 30;

    private final Random rand = new Random();
    private final List<SampleData> userList = new ArrayList<>();

    @Before
    public void prepare() throws Exception {

        for (int i = 0; i < LIMIT; i++) {
            SampleData data1 = generateSample(i);
            userList.add(data1);
        }
    }

    @NonNull
    private SampleData generateSample(long id) {
        SampleData data1 = new SampleData();
        data1.setId(id);
        data1.setName("user " + id);
        data1.setAge(rand.nextInt(50));
        data1.setGender(rand.nextBoolean() ? Gender.Female : Gender.Male);
        data1.setMegane(rand.nextBoolean());
        return data1;
    }
リスト1.3: テストの実行
    @Test
    public void test() throws Exception {

        for (int i = 0; i < EPOCH; i++) {
            onshotTest();
        }
    }

    private void onshotTest() throws Exception {
        Result result = serializeDeserialize();
        Log.d(TAG, result.toString());

        for (int i = 0; i < userList.size(); i++) {
            Assert.assertTrue(userList.get(i).equals(result.serializedList.get(i)));
        }
    }

    private Result serializeDeserialize() throws Exception {

        // serialize
        long start = Debug.threadCpuTimeNanos();
        // シリアライズ処理
        long serializeDuration = Debug.threadCpuTimeNanos() - start;

        long serializedSize = serializedData.length;

        // deserialize
        start = Debug.threadCpuTimeNanos();
        // デシリアライズ処理
        long deserializeDuration = Debug.threadCpuTimeNanos() - start;

        return new Result(list, serializeDuration, serializedSize, deserializeDuration);
    }

    private class Result {
        public final List<SampleData> serializedList;
        public final long serializeDuration;
        public final long serializedSize;
        public final long deserializeDuration;

        private Result(List<SampleData> serializedList,
                       long serializeDuration,
                       long serializedSize,
                       long deserializeDuration) {
            this.serializedList = serializedList;
            this.serializeDuration = serializeDuration;
            this.serializedSize = serializedSize;
            this.deserializeDuration = deserializeDuration;
        }

テストは5回実行し、最初の1回分は集計に含めず残り4回分の平均を取ります。これは初回の実行が異常に時間がかかってしまうことからテスト立ち上げ直後の処理負荷が影響していると判断したためです。

テスト結果

Serializable

SampleDataにSerializableを実装しました(リスト1.4)。シリアライズ・デシリアライズにはObject[Output/Input]Streamを用いました(リスト1.5)。

リスト1.4
package io.keiji.serializerbenchmark.common;

import java.io.Serializable;

public class SampleData implements Serializable {
リスト1.5
private Result serializeDeserialize() throws Exception {

        // serialize
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        long start = Debug.threadCpuTimeNanos();
        oos.writeObject(userList);
        byte[] serializedData = baos.toByteArray();
        long serializeDuration = Debug.threadCpuTimeNanos() - start;

        long serializedSize = serializedData.length;

        // deserialize
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(serializedData));
        start = Debug.threadCpuTimeNanos();
        List<SampleData> list = (List<SampleData>) ois.readObject();
        long deserializeDuration = Debug.threadCpuTimeNanos() - start;

        return new Result(list, serializeDuration, serializedSize, deserializeDuration);
    }

結果

実行結果は表1.3のようになりました。

表1.3: Serializable
Serializable  serialize(ns) deserialize(ns) size(bytes)
1 10,464,739 2,514,374 1,353
2 660,625 1,615,990 1,353
3 627,812 1,346,354 1,353
4 537,969 1,107,396 1,353
5 593,907 1,454,219 1,353
AVG 605,078 1,380,990 1,353

Parcelable

SampleDataにParcelableを実装しました(リスト1.6)。Parcelableの実装は基本的にはAndroid Studioによる自動生成を用い、列挙型のgenderについては手動で追加しました。

リスト1.6
package io.keiji.serializerbenchmark.common;

import android.os.Parcel;
import android.os.Parcelable;

public class SampleData implements Parcelable {

    public SampleData() {
    }

    // 変数、アクセサおよびequals, hashCode省略

    protected SampleData(Parcel in) {
        id = in.readLong();
        name = in.readString();
        age = in.readInt();
        gender = Gender.values()[in.readInt()]; // 追加
        isMegane = in.readByte() != 0;
    }

    public static final Creator<SampleData> CREATOR = new Creator<SampleData>() {
        @Override
        public SampleData createFromParcel(Parcel in) {
            return new SampleData(in);
        }

        @Override
        public SampleData[] newArray(int size) {
            return new SampleData[size];
        }
    };

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeLong(id);
        dest.writeString(name);
        dest.writeInt(age);
        dest.writeInt(gender.ordinal()); // 追加
        dest.writeByte((byte) (isMegane ? 1 : 0));
    }
}
リスト1.7: シリアライズ・デシリアライズ
    private Result serializeDeserialize() throws Exception {
        Parcel parcel = Parcel.obtain();

        // serialize
        long start = Debug.threadCpuTimeNanos();
        parcel.writeTypedList(userList);
        byte[] serializedData = parcel.marshall();
        long serializeDuration = Debug.threadCpuTimeNanos() - start;

        long serializedSize = serializedData.length;

        parcel.recycle();
        parcel = Parcel.obtain();

        // deserialize
        List<SampleData> list = new ArrayList<>();
        start = Debug.threadCpuTimeNanos();
        parcel.unmarshall(serializedData, 0, serializedData.length);
        parcel.setDataPosition(0);  // 実行しないとデシリアライズされない
        parcel.readTypedList(list, SampleData.CREATOR);
        long deserializeDuration = Debug.threadCpuTimeNanos() - start;

        parcel.recycle();

        return new Result(list, serializeDuration, serializedSize, deserializeDuration);
    }

結果

実行結果は表1.4のようになりました。

表1.4: Parcelable
Parcelable serialize(ns) deserialize(ns) size(bytes)
1 412,136 300,833 1,324
2 209,322 187,865 1,324
3 202,916 205,781 1,324
4 210,208 197,448 1,324
5 201,719 189,740 1,324
AVG 206,041 195,209 1,324

MessagePack

SampleDataに@Messageアノテーションを追加して、MessagePackで処理できるようにしました(リスト1.8)。

リスト1.8
package io.keiji.serializerbenchmark.common;

import org.msgpack.annotation.Index;
import org.msgpack.annotation.Message;
import org.msgpack.packer.Packer;
import org.msgpack.template.AbstractTemplate;
import org.msgpack.unpacker.Unpacker;

import java.io.IOException;

@Message
public class SampleData {

    @Message
    public enum Gender {
        Female,
        Male;
    }

    // https://github.com/msgpack/msgpack-java/issues/98
    @Index(0)
    private long id;

    @Index(1)
    private String name;

    @Index(2)
    private int age;

    @Index(3)
    private Gender gender;

    @Index(4)
    private boolean isMegane;

    // アクセサおよびequals, hashCode省略

    public static class Template extends AbstractTemplate<SampleData> {

        private Template() {
        }

        public static Template getInstance() {
            return new Template();
        }


        public void write(Packer pk, SampleData v, boolean required) throws IOException {
            pk.writeArrayBegin(5)
                    .write(v.id)
                    .write(v.name)
                    .write(v.age)
                    .write(v.gender.ordinal())
                    .write(v.isMegane)
                    .writeArrayEnd();
        }

        public SampleData read(Unpacker u, SampleData to, boolean required) throws IOException {
            if (to == null) {
                to = new SampleData();
            }

            u.readArrayBegin();
            to.id = u.readLong();
            to.name = u.readString();
            to.age = u.readInt();
            to.gender = Gender.values()[u.readInt()];
            to.isMegane = u.readBoolean();
            u.readArrayEnd();

            return to;
        }
    }
}

シリアライズ・デシリアライズを厳密に処理するためのTemplateを実装しています。これはListのオブジェクトそのままではデシリアライズに失敗してしまったことから、シリアライズ・デシリアライズを厳密に処理するためのTemplateが必要と判断したためです(筆者はMessagePackに慣れていないので、もっと良い方法があればどなたか教えてください)。

リスト1.9: シリアライズ・デシリアライズ
    private Result serializeDeserialize() throws Exception {
        MessagePack msgPack = new MessagePack();

        // serialize
        long start = Debug.threadCpuTimeNanos();
        byte[] serializedData = msgPack.write(userList, listTmpl);
        long serializeDuration = Debug.threadCpuTimeNanos() - start;

        long serializedSize = serializedData.length;

        // deserialize
        start = Debug.threadCpuTimeNanos();
        List<SampleData> list = msgPack.read(serializedData, listTmpl);
        long deserializeDuration = Debug.threadCpuTimeNanos() - start;

        return new Result(list, serializeDuration, serializedSize, deserializeDuration);
    }

結果

実行結果は表1.5のようになりました。

表1.5: MessagePack
MsgPack serialize(ns) deserialize(ns) size(bytes)
1 662,344 2,472,761 383
2 364,531 1,827,552 383
3 367,553 1,745,573 383
4 305,417 1,705,625 383
5 317,552 1,658,178 383
AVG 338,763 1,734,232 383

Protocol Buffers

Sampledataクラスの生成にはリスト1.10のプロトコル定義ファイルを用いました。

リスト1.10
package io.keiji.serializerbenchmark.common;

option java_package = "io.keiji.serializerbenchmark.common";

message SampleData {
  required int64 id = 1;
  required string name = 2;
  required int32 age = 3;
  required Gender gender = 4 [default = Female];
  required int32 isMegane = 5;

  enum Gender {
    Female = 0;
    Male = 1;
  }
}

message SampleList {
  repeated SampleData sampleData = 1;
}

このファイルを元にProtocol Buffersのツール(protoc)がJavaのコードを生成します。そのためサンプルデータの生成方法が変わっています。

リスト1.11
@Before
public void prepare() throws Exception {

    Sampledata.SampleList.Builder builder = userList.toBuilder();

    for (int i = 0; i < LIMIT; i++) {
        SampleData data1 = generateSample(i);
        builder.addSampleData(data1);
    }

    userList = builder.build();
}

@NonNull
private SampleData generateSample(long id) {
    SampleData.Builder builder = SampleData
            .newBuilder()
            .setId(id)
            .setName("user " + id)
            .setAge(rand.nextInt(50))
            .setGender(rand.nextBoolean() ? SampleData.Gender.Female : SampleData.Gender.Male)
            .setIsMegane(rand.nextBoolean() ? 1 : 0);
    return builder.build();
}
リスト1.12: シリアライズ・デシリアライズ
    private Result serializeDeserialize() throws Exception {

        // serialize
        long start = Debug.threadCpuTimeNanos();
        byte[] serializedData = userList.toByteArray();
        long serializeDuration = Debug.threadCpuTimeNanos() - start;

        long serializedSize = serializedData.length;

        // deserialize
        start = Debug.threadCpuTimeNanos();
        Sampledata.SampleList list = Sampledata.SampleList.parseFrom(serializedData);
        long deserializeDuration = Debug.threadCpuTimeNanos() - start;

        return new Result(list, serializeDuration, serializedSize, deserializeDuration);
    }

結果

実行結果は表1.6のようになりました。

表1.6: Protocol Buffer
ProtoBuf serialize(ns) deserialize(ns) size(bytes)
1 531,875 278,802 560
2 214,115 150,521 560
3 192,761 138,542 560
4 189,636 137,761 560
5 178,541 130,312 560
AVG 193,763 139,284 560

Kryo

SampleDataはそのまま変更なく、Serializableのような感覚で利用できました。

リスト1.13: シリアライズ・デシリアライズ
    private Result serializeDeserialize() throws Exception {

        // serialize
        Kryo kryo = new Kryo();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        Output output = new Output(baos);
        long start = Debug.threadCpuTimeNanos();
        kryo.writeClassAndObject(output, userList);
        output.flush(); // flushしないとシリアライズが不完全になる場合がある
        byte[] serializedData = baos.toByteArray();
        long serializeDuration = Debug.threadCpuTimeNanos() - start;

        long serializedSize = serializedData.length;

        // deserialize
        ByteArrayInputStream bais = new ByteArrayInputStream(serializedData);
        Input input = new Input(bais);
        start = Debug.threadCpuTimeNanos();
        List<SampleData> list = (List<SampleData>) kryo.readClassAndObject(input);
        long deserializeDuration = Debug.threadCpuTimeNanos() - start;

        return new Result(list, serializeDuration, serializedSize, deserializeDuration);
    }

結果

実行結果は表1.7のようになりました。

表1.7: Kryo
Kryo serialize(ns) deserialize(ns) size(bytes)
1 4,319,635 543,906 511
2 867,188 317,084 511
3 778,802 303,385 511
4 839,063 298,750 511
5 774,063 293,802 511
AVG 814,779 303,255 511

まとめ

テスト結果から、速度面ではProtocol Buffersがもっとも速く、シリアライズ後のデータサイズはMessagePackがもっとも小さいということがわかりました。

表1.8: テスト結果
serialize(ns) deserialize(ns) size(byte)
Serialize 605,078 1,380,990 1,353
Parcelable 206,041 195,209 1,324
MsgPack 338,763 1,734,232 383
ProtoBuf 193,763 139,284 560
Kryo 814,779 303,255 511
処理にかかった時間
処理にかかった時間
シリアライズ後のデータサイズ
シリアライズ後のデータサイズ

ここまで見ると、速度ではProtocol Buffers、シリアライズ後のサイズを重視するならMessagePackかの二択になりそうです。しかし、今回はモバイルアプリとAndroid Wearとの通信に必要なオブジェクトをシリアライズするという目的です。すなわち二つのアプリにそれぞれ同じライブラリを組み込む必要があります。また、WearアプリのAPKはモバイルアプリのAPKに組み込まれて配信されるので、ライブラリのサイズは単純に2倍となることから無視できません。

それぞれのライブラリのデータサイズは表1.9の通りです。

表1.9: ライブラリのサイズ
library size(KB)
Serialize 0
Parcelable 0
MsgPack 276.2
ProtoBuf 582.7
Kryo 279.0
ライブラリのサイズ
ライブラリのサイズ

一番性能面でのバランスが良いと思われたProtocol Buffersですが、ライブラリのサイズは他のものより大きいことがわかります(ProGuardはかけないという前提)。このサイズの増加を許容できるかで判断が分かれるところでしょう。

筆者の場合、APKのサイズを小さく抑えることを優先して、サードパーティのライブラリを必要としないParcelableを採用することにしました。ParcelableはProtocol Buffersには劣るものの、速度的には速い部類に入ります。データサイズの大きさがネックですが、こちらもAndroid Wearとの通信に限定すれば、それほど深刻な問題になることもないと考えました。

おわりに

ここまで、Androidで使えるシリアライザーの速度やシリアライズ後のバイナリサイズ、ライブラリ自体のデータサイズについて計測した結果について記述しました。

いかがでしたか?今回の記事が皆さんの目的にあったシリアライザーを選ぶ助けになることを願っています。

今回のテストに使ったコードは以下にあります。

テスト結果は以下のURLにあります。

更新履歴

2016.05.11: 強調マークアップ位置を修正しました
 「表1.1」および「表1.8」のサイズの列で、本来は「MessagePack」がもっとも性能が良いと強調すべきところを「Kryo」を強調していました。