Androidで使えるシリアライザー特集
有山 圭二
みなさんおひさしぶりです。有山圭二です。
今年も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の出番はあるので、今回の調査は決して無駄にはならないでしょう。
テストするシリアライザー
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のようになりました。
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のようになりました。
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のようになりました。
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のようになりました。
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のようになりました。
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にあります。
更新履歴
2016.05.11: 強調マークアップ位置を修正しました
「表1.1」および「表1.8」のサイズの列で、本来は「MessagePack」がもっとも性能が良いと強調すべきところを「Kryo」を強調していました。