みなさんおひさしぶりです。有山圭二です。
今年も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が最も小さいことがわかりました。
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アプリにはありませんでした。
library size(KB) | |
---|---|
Serialize | 0 |
Parcelable | 0 |
MsgPack | 276.2 |
ProtoBuf | 582.7 |
Kryo | 279.0 |
もちろんAndroid Wearでの通信が込み入ったものになったり、そもそもマルチプラットフォームでのオブジェクトのやり取りが発生する場合は当然Protocol BuffersやMessagePackの出番はあるので、今回の調査は決して無駄にはならないでしょう。
Java言語ではおなじみのSerializableインターフェースです。インターフェースを実装するだけなので導入は簡単です。
Androidでは一般的なシリアライズの形式です。AIDL (Android Interface Definition Language)などプロセス間でオブジェクトをやり取りする際にも使います。
定められた形式で実装する必要があることから以前は実装に手間がかかると言われていましたが、現在はAndroid Studioの自動生成機能が追加されたので比較的楽に実装できるようになりました。
古橋貞之氏(@frsyuki)が開発しているマルチプラットフォームのシリアライザーです。シリアライズ後のデータサイズが小さいことが特徴です。
今回は前述のMessagePackの公式サイトから案内されているmsgpackの最新バージョン0.6.12
を使いました。msgpack-coreという名前でバージョン0.8.7
が公開されています(0.6系よりあとでBreaking Changeがあったようで、既存のコードがそのままでは動かなかったため検証していません)。
Google社がオープンソースで開発しているマルチプラットフォームのシリアライザーです。AIDL同様あらかじめ記述したIDLにしたがって出力したコードを使います。Googleが公開した機械知能向けの計算フレームワークTensorFlowが採用しているデータ形式TFRecord
もProtocol Buffersでプロトコルが定義されています。バージョン2系と3系がありますが、3系はまだβのため、今回は2.6.1
を使いました。
Esoteric Software社がオープンソースで公開しているマルチプラットフォームのシリアライザーです。筆者はこれまで使ったことがありませんでしたが、Javaのシリアライザーとしてよく名前が挙がっていることから今回のベンチマークに含めました。バージョンは3.0.3
を使いました。
テストに用いたデータはリスト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でも動作確認のために実行しています。
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; }
@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回分の平均を取ります。これは初回の実行が異常に時間がかかってしまうことからテスト立ち上げ直後の処理負荷が影響していると判断したためです。
SampleDataにSerializable
を実装しました(リスト1.4)。シリアライズ・デシリアライズにはObject[Output/Input]Stream
を用いました(リスト1.5)。
package io.keiji.serializerbenchmark.common; import java.io.Serializable; public class SampleData implements Serializable {
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のようになりました。
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 |
SampleDataにParcelableを実装しました(リスト1.6)。Parcelableの実装は基本的にはAndroid Studioによる自動生成を用い、列挙型のgender
については手動で追加しました。
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)); } }
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のようになりました。
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 |
SampleDataに@Message
アノテーションを追加して、MessagePackで処理できるようにしました(リスト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に慣れていないので、もっと良い方法があればどなたか教えてください)。
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のようになりました。
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 |
Sampledataクラスの生成にはリスト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のコードを生成します。そのためサンプルデータの生成方法が変わっています。
@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(); }
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のようになりました。
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 |
SampleDataはそのまま変更なく、Serializableのような感覚で利用できました。
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のようになりました。
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がもっとも小さいということがわかりました。
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の通りです。
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にあります。
大阪市のソフトウェア開発会社"有限会社シーリス"の代表。Androidアプリの開発は、2007年11月にAndroidが発表された当時から手がけている。 Androidアプリケーションの受託開発や、Androidに関するコンサルティングの傍ら、技術系月刊誌への記事執筆。また、AOSP(Android Open Source Project)のコントリビューターとして活動している。 著書に「Android Studioではじめる 簡単Androidアプリ開発」(技術評論社刊)や「Effective Android」(共著:インプレスジャパン刊)がある。 最近では、Google GlassやAndroid Wearなど、ウェアラブルデバイス関連の分野でも活動中。
この執筆者の記事一覧※ コメントはこちらのに同意の上、投稿ください。