Unityで作っているスマホゲームに、端末移行や故障・紛失からの復旧のための、バックアップ機能を付けようと思った。
小規模であればFirebaseの無償枠で十分に使えると聞いたため、やってみる。
Firebaseのプロジェクトづくり
なにはともあれ Firebase(https://console.firebase.google.com/) にアクセスし、Googleアカウントでログインする。
その後、「プロジェクトを作成」を選択。
任意のプロジェクト名を付ける。
Google Analyticsが使える。
無料なので、何も考えずにONのまま。
アカウントは適当に設定。
(このあたり良く分かっていない。新規作成は面倒だったので、Default Account for Firebase
を使った)
アプリ登録
Firebaseを使うためには、アプリの登録が必要になる。
アプリ自体は開発中なので、当然まだリリースしていないが、パッケージ名だけ先に決めてしまい、登録する。
「開始するにはアプリを追加してください」の上にあるUnityロゴを選択。
今回はAndroidを対象とする。後からiOSも追加できるので、特に気にせず登録。
パッケージ名は決めておいたものを入力(e.g. com.company.appname
)。
ニックネームは必要に応じて適当に入力。
設定ファイルをダウンロードできるようになる。
google-services.json
をダウンロードして、UnityのAssets配下に置く。
場所は任意らしいが、特に階層は掘らず、直下に置いた。
Firebase Unity SDKを入手する。
ボタンを押すとfirebase_unity_sdk_6.15.2.zip
がダウンロードできた。
これは後で使う。
とりあえずFirebase側の設定を最後までやってしまう
アプリが追加できたら、左ペインからRealtime Database
を選び、データベースを作成
を選ぶ
セキュリティルールを聞かれる。
後で設定すればいいので、今はテストモードで開始
を選ぶ。今はとりあえず動かすことが目標。
この記事では省くが、Google様からもしつこく注意されるとおり、公開前には必ずルールを設定しなおすこと。
これでDBが作成された。
試しに、データを一つ作ってみる。
今回は/users
を作り、その下に各ユーザのセーブデータを格納していく構成にした。(わざわざここで作らなくても、あとで無ければ勝手に作ってくれる。これはあくまで確認用)
これで、/users/{ユーザID}/xxx
みたいな感じで、データのバックアップを作る。
ここから先はUnity側の作業。
Unityに必要なAssertを入れる。
Firebase SDKを追加
FirebaseからダウンロードしたZipファイル中の\firebase_unity_sdk\dotnet4
にFirebaseDatabase.unitypackage
が入っているので、それをUnityにImport。(手順は画面に表示されている通り)。
dotnet3か4かは、作っているアプリに寄る。
なおこの時、Unity側のPlatformが誤ってPC, Mac & Linux Standalone
になっていて、インポートした後でUnable to load options for default app
というエラーが発生した。Assets/StreamingAssets\google-services-desktop.json
を読み込もうとして失敗する。
焦らずAndroidへSwitch Platformして解決。
PlayGamesPluginを更新
FirebaseSDKを入れた後、Unityを動かそうとすると、以下のエラーが発生。
System.MissingMethodException: bool GooglePlayServices.UnityCompat.SetAndroidMinSDKVersion(int)
他にはこんなエラーも
PrecompiledAssemblyException: Multiple precompiled assemblies with the same name Google.VersionHandler.dll included for the current platform. Only one assembly with the same name is allowed per platform. Assembly paths: Assets/ExternalDependencyManager/Editor/Google.VersionHandler.dll, Assets/PlayServicesResolver/Editor/Google.VersionHandler.dll
UnityEditor.Scripting.ScriptCompilation.EditorBuildRules.CreateTargetAssemblies (System.Collections.Generic.IEnumerable`1[T] customScriptAssemblies, System.Collections.Generic.IEnumerable`1[T] precompiledAssemblies) (at...
ググると、これはPlayGamesPluginのバージョンが古いかららしいので、更新する。
https://github.com/playgameservices/play-games-plugin-for-unity/issues/2877
PlayGamesPluginはリゾルバを更新すれば自動で上がるので、そちらを更新する。
参考:
- https://qiita.com/tkyaji/items/b838c97228f99f194bcd
- https://qiita.com/kusu123/items/9aac7f1b899b95edde07
リゾルバは以下から取得。見ての通りunitypackageになっているので、Firebase SDKと同様、インポートするだけ。
https://github.com/googlesamples/unity-jar-resolver/blob/master/external-dependency-manager-latest.unitypackage
「Obsoleteファイルを消すか?」と聞かれたら、削除する。(何回か出るかも。毎回削除でOK)
これを消さないと別のエラーが出る。ダイヤログが出ない場合、Unityを再起動すると出て来るかも。
PackageManagerレジストリを追加するか?と聞かれたので、素直にAdd Selected Registries
。
Packageの移行を進められたので、これまた素直にApply
。
これでエラーが消えた。
PackageのMigrate中、なぜかUnityがフリーズすることがあった。
Unityの再起動で直った。
アプリ側の実装
あとはFirebaseと通信するためのコードを書いていく
初期化
まず、Assets直下に置いてある google-services.json
の中身を開いて、接続先のURLを確認。project_info.firebase_url
の欄がそれ。(https://アプリ名.firebaseio.com/
という形式のはず)
このurlを使って、firebaseの中身を読み書きするためのオブジェクトを作成する。
// Set up the Editor before calling into the realtime database.
FirebaseApp.DefaultInstance.SetEditorDatabaseUrl("https://アプリ名.firebaseio.com/");
// Get the root reference location of the database.
DatabaseReference databaseRoot = FirebaseDatabase.DefaultInstance.RootReference;
データを書き込む
実際にfirebaseにデータを書き込んでみる。
上で作った/users/{ユーザID}/
にデータを入れていくわけだけど、最初にユーザIDを採番しなければいけない。
これが重複すると、他人のデータを上書きすることになってしまう。
各ユーザにユニークなIDを自分でつけてもらうゲームも多いが、FirebaseではIDの採番を行うためのメソッド(Push
)もあるので、今回はそれを使う。
Push()
はあくまでユニークな文字列を返してくれるだけなので、これだけではFirebase側にデータは書き込まれない。
//ユーザIDを新規作成する
var newData= databaseRoot.Child("users").Push();
//作ったIDをローカルに保存しておく
PlayerPrefs.SetString("user-id", userId);
IDができたら、実際のデータを入れてみる。とりえあずはテスト的に作成日を突っ込んでみた。
databaseRoot.Child("users").Child(userId).Child("created_at").SetValueAsync(DateTime.UtcNow.ToString("yyyy/MM/dd HH:mm:ss"));
ちゃんとfirebaseに書き込まれている。
失敗する場合は、パスを間違えているか、書き込み権限が不足している可能性があるので、ルールを再確認するよろし。
それでも原因がわからない場合は、後述するコールバックを追加して、失敗した理由をログ出力する。
オブジェクトを丸ごと保存する
SetValueAsync
だと、数字とか文字列とか、単一のデータしか書き込めない。
例えば以下のようなクラスがあった時、全部自前でバラしてセーブするのは面倒くさい。
class UserSaveData
{
public int Level { get; set; }
public string Name { get; set; }
public List<string> RewardIds{ get; set; }
public Dictionary<string, int> Items { get; set; }
}
こういう時のために、オブジェクトをJsonデータに変換して、それを記録するためのメソッドSetRawJsonValueAsync()
が用意されている。
var userId = PlayerPrefs.GetString("user-id");
var reference = databaseRoot.Child("users").Child(userId);
var saveData = new UserSaveData()
{
Level = 1,
Name = "ああああ",
RewardIds = new List<string>() { "a0001", "b0002" },
Items = new Dictionary<string, int> { { "Sword", 1 }, { "Shield", 2 } }
};
string data = JsonUtility.ToJson(saveData);
reference.SetRawJsonValueAsync(data);
が、実は上記は失敗例。
これをやっても、Firebase側には何も表示されないと思う。
それどころか、先の処理で保存していたcreated_atも消えてしまう。
この原因は以下
- オブジェクトを保存するための条件を満たしていない
- クラスは
Serializable
でないといけない - プロパティは(そのままでは)NG。フィールドでないといけない。
-
JsonUtility.ToJson()
はDictionaryに対応していない
- クラスは
- 今回は保存するデータが空なので、空っぽのデータで
/users/{user-id}/
を上書きしてしまい、すでに保存されていたデータを吹き飛ばした
これを直すために、以下の修正を行う。
- クラスに
Serialozable
属性を付ける - プロパティをフィールドにする(C#に慣れている身からすると、この仕様はものすごく気持ち悪い。。。)
- DictionaryをListに変える
- セーブするときは、
/users/{user-id}
に直接保存するのではなく、階層を一つ掘る
結果が以下。
var userId = PlayerPrefs.GetString("user-id");
var reference = databaseRoot.Child("users").Child(UserId).Child("backup"); // user-id直ではなく、backupに保存
var saveData = new UserSaveData()
{
Level = 1,
Name = "ああああ",
RewardIds = new List<string>() { "a0001", "b0002" },
Items = new Dictionary<string, int> { { "Sword", 1 }, { "Shield", 2 } }
};
string data = JsonUtility.ToJson(saveData);
reference.SetRawJsonValueAsync(data);
[Serializable]
class UserSaveData : ISerializationCallbackReceiver
{
public int Level;
public string Name;
public List<string> RewardIds;
public Dictionary<string, int> Items;
[SerializeField] private List<string> itemKeys;
[SerializeField] private List<int> itemCounts;
public void OnBeforeSerialize()
{
itemKeys = new List<string>();
itemCounts = new List<int>();
foreach (var item in Items)
{
itemKeys.Add(item.Key);
itemCounts.Add(item.Value);
}
}
public void OnAfterDeserialize()
{
Items = new Dictionary<string, int>();
for (int i = 0; i < itemKeys.Count; i++)
{
Items.Add(itemKeys[i], itemCounts[i]);
}
}
}
Dictionaryが保存できないため、Jsonに変換する前にListに詰め替えている。
こうすれば、クラス丸ごとJsonに保存することが可能。
書き込んだ後のコールバックを受け取る
単にSetValueAsync()
を実行しただけだと、このメソッドが非同期で実行されるため、書き込みに成功したのか、失敗したのか、わからない。
なのでコールバックを受け取る。
string data = JsonUtility.ToJson(saveData);
reference.SetRawJsonValueAsync(data).ContinueWith(task =>
{
if (task.IsFaulted)
{
Debug.LogError("firebase error: " + task.Exception);
}
else if (task.IsCompleted)
{
Debug.Log("firebase result:" + task.Status);
}
});
コールバックをUIスレッドに戻す
コールバックが実装できたので、「保存に成功/失敗したら、画面にメッセージを出す」という機能を付けたくなる。
が、これはまたうまく動かないことがある。
「エラーは一切出ないのに画面がなにも更新されない」という状況になったなら、スレッドを疑った方が良いかもしれない。
コールバックが実行されるスレッドがUIスレッドでない場合、画面は更新できない。
詳細はここでは割愛するが、この場合はコンテキストを切り替えてUIスレッドでコールバックを実行すればいい。
UIスレッドについて詳しく知りたい方は、Unity UI Thread
とかSynchronizationContext
とかでググってください。
// Action<bool> callback = xxx //何かコールバック関数を定義
string data = JsonUtility.ToJson(saveData);
//UIスレッドを触る可能性があるので、コールバックを渡すためのコンテキストを退避しておく
var context = System.Threading.SynchronizationContext.Current;
reference.SetRawJsonValueAsync(data).ContinueWith(task =>
{
context.Post((obj) =>
{
if (obj is Action<bool>)
{
callback(task.IsFaulted);
}
}, callback);
});
データを読み込む
読み込みはGetValueAsync()
を使うだけ。シンプル。
書き込みの時に書いたコールバックやら、スレッドの切り替えやら、オブジェクトへの変換やら、そういうのを盛り込むと以下のようになると思う。
// Action<UserSaveData> callback = xxx //何かコールバック関数を定義
//UIスレッドを触る可能性があるので、コールバックを渡すためのコンテキストを退避しておく
var context = System.Threading.SynchronizationContext.Current;
DatabaseRoot.Child("users").Child(userId).Child("backup").GetValueAsync().ContinueWith(task =>
{
context.Post((obj) =>
{
if (task.IsFaulted)
{
Debug.LogError($"firebase error: {obj} : {task.Exception}");
callback(null);
}
else if (task.IsCompleted)
{
UserSaveData restoreData = JsonUtility.FromJson<UserSaveData>(task.Result.GetRawJsonValue());
Debug.Log("Complete save data restore");
callback(restoreData);
}
}, callback);
});
以上。
確かにSDKを入れて入出力するだけだから、簡単なのはそうなんだろうけど、罠もたくさんあって結構時間を使ってしまった。