#はじめに
Unityでユーザデータを保存する仕組みとしてPlayerPrefsがありますが
これは速度や保存する量にあまり期待ができません。
そこで永続化の候補としてFlatBuffersを使ってみませんか?という話です。
#検証条件
Unity 5.3.3f1
FlatBuffers 1.3.0
Android/iOS上での実行の確認
#FlatBuffersとは
FlatBuffersはバイナリで保存するシリアライザーです。
cocos2dxのgame dataに採用されていたり
Facebookで使用されているようです。
JSONでは構造定義と値をテキストファイルに保存するため、
視覚的に、直観的に、編集が可能ですが
ファイルサイズが大きくなりデータの解析に時間がかかるという側面があります。
ゲームは速度が優先されるためJSONの使用はあまりオススメできません。
FlatBuffersは構造定義(スキーマ)とデータが分かれていて、
シリアライズされるのはデータのみとなりますから
- ファイルサイズが小さい
- 解析にかかる時間が短い
- 消費されるメモリが少ない
というメリットが得られます。
(エンディアンの異なっていても動作が保証されています。サーバクライアントでも利用できるってことですね。)
最速という噂のFlatbuffersの速度のヒミツと、導入方法の紹介(Go)
#FlatBuffersの利用
FlatBuffersを利用するには以下の流れになります。
- スキーマの定義
- 対応する言語ファイルの自動生成
- シリアライズ/デシリアラズ処理の実装
##スキーマの定義
スキーマの定義はIDL(Interface Definition Language)で記述します。
今回は実務で使う上で、こんな感じの定義はどうでしょうか?
という簡単な定義を紹介します。
もっと詳しく知りたい人は公式ページをみてください。
namespace Data;
file_identifier "MYFI";
union Data
{
MonsterDataV1
}
table Root
{
data:Data;
}
table MonsterDataV1
{
name:string;
hp:uint;
hitRate:float;
speed:uint;
luck:uint;
}
table MonsterDataV2
{
name:string;
hp:uint;
hitRate:float;
speed:uint;
luck:uint;
defence:uint;
}
root_type Root;
まずは「table MonsterDataV1」ですが、
これはRPGなどで使用されるモンスターのパラメータを想定しています。
tableを使うことで後にフィールドを追加できるテーブル構造を宣言できます。
記述されているフィールドはビルドインの型を使用しています。
ビルドインされた記述以外でも自分で定義したtableをフィールドの型として使用できます。
次に「union Data」です。
これはenum型のようなもので、フィールドにtableで宣言したものを使用するのが特徴です。
enum型のようなものと書いた通り、実際のデータは記述されたフィールドの中から1つだけが格納されています。
例ではMonsterDataV1のみ記述されていますが
MonsterDataV2を追加すれば実データはどちらかのデータ一方が
格納されているということになります。
(C言語のunionみたいな感じですね)
「root_type」には親となるtableの型を記述します。
この型をもとに解析していくことになります。
「file_identifier」はファイルIDの機能でファイルの先頭らへん(4-7bytes)に4文字のASCII文字を埋め込むことができます。
サーバからエラーデータ等が送られてきたかチェックすることが可能です。
##バージョンアップの考察
今回は長い間、運用するようなゲームまたはアプリを想定しています。
アプリのバージョンアップによって新機能が増えたとき
データも変更することがありますよね?
ただスキーマには、いくつかの制限があります。
- すでに運用してしまったデータがある場合、フィールドを削除してはならない
- 宣言したフィールドの順番を変えてはいけない
1.は後方互換のためです。
古いデータを読んだときに、読み捨てるためには宣言したフィールドが残っている必要があります。
2.はJSONのようにキーを文字で定義しているわけではなく
フィールドの宣言順に自動でIDを割り当てているためです。
###マイナーバージョンアップについて
マイナーバージョンアップのときは既に宣言されているtableにフィールドを追加していくことになります。
初期は以下のように宣言されたMonsterDataV1があったとして
table MonsterDataV1
{
name:string;
hp:uint;
hitRate:float;
speed:uint;
luck:uint;
}
機能改善で以下のようにデータ定義が変更していく感じになると思います。
table MonsterDataV1
{
name:string;
hp:uint;
hitRate:float;
speed:uint;
luck:uint; (deprecated)
hoge1:[ubyte];
hoge2:bool;
hoge3:int;
hoge4:long;
hoge5:long; (deprecated)
hoge6:long;
hoge7:long;
hoge8:long; (deprecated)
hoge9:long;
}
もっと長い間、運用していくと定義自体がわかりにくくなるでしょう。
そういうときは最初に宣言していたunionに新しいtable型(今回の例ではMonsterDataV2)を追加します。
union Data
{
MonsterDataV1,
MonsterDataV2
}
こうすることで膨大なフィールドになってしまった定義を一旦リセットして
MonsterDataV2型でやりなおすことができるようになります。
読み込んだデータはプログラム側では変換処理すればいいってことですね!
##プログラムファイルの出力
プログラムの出力にはflatc実行ファイルが必要になります。
Windows版はgithubページ上でexeの形になってます。
以下のコマンドでC#用のファイルが生成されます。
flatc.exe -o OUTPUT_PATH -n --gen-onefile inputfile.fbs
「OUTPUT_PATH」は出力するファイルパスを設定してください。
「-n」がC#ファイルを出力する引数
「--gen-onefile」はcsファイルを生成するとき1つのファイルに出力します。
「inputfile.fbs」はデータ定義を宣言したファイル名になります。
自分で作ったファイル名に変更してください。
##実行時ファイルをUnityへインポートする
FlatBuffersを実行するためにスキーマ用のスクリプトファイル以外のものが必要になります。
公式githubからDLしてUnityへインポートしてください。
##スクリプトファイルでのシリアライズ/デシリアライズ
ファイルへ出力したものをデシリアライズする実験プロジェクトをgithub上に用意しました。
test1シーンでシリアライズ, test2でデシリアライズを実行しています。
string path = Path.Combine(Application.persistentDataPath, DataName.FILE_NAME);
if (File.Exists(path))
File.Delete(path);
FlatBufferBuilder builder = new FlatBufferBuilder(1);
int offestData;
Data.Data dataType;
Offset<Data.MonsterDataV1> data = Data.MonsterDataV1.CreateMonsterDataV1(builder, builder.CreateString(name), hp, hitRate, speed, luck);
offestData = data.Value;
dataType = Data.Data.MonsterDataV1;
Data.Root.StartRoot(builder);
Data.Root.AddDataType(builder, dataType);
Data.Root.AddData(builder, offestData);
Offset<Data.Root> endOffset = Data.Root.EndRoot(builder);
Data.Root.FinishRootBuffer(builder, endOffset);
bytes = builder.SizedByteArray();
File.WriteAllBytes(path, bytes);
文字列やtableデータはルートのtableよりも先にシリアライズしている必要があります。
末端からシリアライズしていくという感じですね。
デシリアライズ処理は
string path = Path.Combine(Application.persistentDataPath, DataName.FILE_NAME);
ByteBuffer buffer = new ByteBuffer(File.ReadAllBytes(path));
Data.Root root = Data.Root.GetRootAsRoot(buffer);
Data.Data dataType = root.DataType;
switch (dataType)
{
case Data.Data.MonsterDataV1:
Data.MonsterDataV1 monsterV1 = root.GetData<Data.MonsterDataV1>(new Data.MonsterDataV1());
if (monsterV1 == null)
{
Debug.LogError("Failed load monster data version 1.");
return;
}
textVersion.text = "Version1";
if (Encoding.Default != Encoding.UTF8)
textName.text = Encoding.Default.GetString(Encoding.Convert(Encoding.UTF8, Encoding.Default, Encoding.UTF8.GetBytes(monsterV1.Name)));
else
textName.text = monsterV1.Name;
textHp.text = monsterV1.Hp.ToString();
textHitRate.text = monsterV1.HitRate.ToString();
textSpeed.text = monsterV1.Speed.ToString();
textLuck.text = monsterV1.Luck.ToString();
textDefence.text = "No data";
break;
}
このようになります。
デシリアライズではunionに定義された型のタイプごとに処理を振り分けられます。
つまりデータ定義をリセットする際は、振り分けてデータの読み込みを行えばよいことになりますね。
github上の実験コードは一部違いがあります。
またgithub上の実験コードには暗号化の処理についても実装されています。
端末に保存されたデータをバイナリエディタ等で編集されるというようなチート対策も可能になります。
(暗号化/複合化の処理分、処理時間が長くなりますが...)
#sqliteとの比較
永続化の手段としてsqliteも考えられると思います。
sqliteにはトランザクション処理、耐障害性(?)もありますよね。
UnityはC#で動きますからC言語で書かれているsqliteはつなぎの部分ができるのも
Unityで使う上でのデメリットじゃないかな!?
いっぽうでFlatBuffersはというと...
- gitと同じ考えでKey-Valueをファイル構造として持ちますからクエリがいりません。パスを指定すれば任意のデータを読み込むことができます。
- C#のスクリプト上ですべてが完結している
など考えられます。
#FlatBuffersを使っての感想
cocos2dxでつくったゲームでspineを使用していたとき
spineの出力データがJSONだったために読み込みがとても遅かったです。
やっぱりゲームだとバイナリフォーマットが相性いいなぁと感じました。
長所/短所をふまえつつ適時利用してみては、いかがでしょうか???