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側の設定を最後までやってしまう
アプリが追加できたら、左ペインからReadtie 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.DatabaseReferencedatabaseRoot=FirebaseDatabase.DefaultInstance.RootReference;データを書き込む
実際にfirebaseにデータを書き込んでみる。
上で作った/users/{ユーザID}/にデータを入れていくわけだけど、最初にユーザIDを採番しなければいけない。
これが重複すると、他人のデータを上書きすることになってしまう。
各ユーザにユニークなIDを自分でつけてもらうゲームも多いが、FirebaseではIDの採番を行うためのメソッド(Push)もあるので、今回はそれを使う。Push()はあくまでユニークな文字列を返してくれるだけなので、これだけではFirebase側にデータは書き込まれない。
//ユーザIDを新規作成するvarnewData=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だと、数字とか文字列とか、単一のデータしか書き込めない。
例えば以下のようなクラスがあった時、全部自前でバラしてセーブするのは面倒くさい。
classUserSaveData{publicintLevel{get;set;}publicstringName{get;set;}publicList<string>RewardIds{get;set;}publicDictionary<string,int>Items{get;set;}}こういう時のために、オブジェクトをJsonデータに変換して、それを記録するためのメソッドSetRawJsonValueAsync()が用意されている。
varuserId=PlayerPrefs.GetString("user-id");varreference=databaseRoot.Child("users").Child(userId);varsaveData=newUserSaveData(){Level=1,Name="ああああ",RewardIds=newList<string>(){"a0001","b0002"},Items=newDictionary<string,int>{{"Sword",1},{"Shield",2}}};stringdata=JsonUtility.ToJson(saveData);reference.SetRawJsonValueAsync(data);が、実は上記は失敗例。
これをやっても、Firebase側には何も表示されないと思う。
それどころか、先の処理で保存していたcreated_atも消えてしまう。
この原因は以下
- オブジェクトを保存するための条件を満たしていない
- クラスは
Serializableでないといけない - プロパティは(そのままでは)NG。フィールドでないといけない。
JsonUtility.ToJson()はDictionaryに対応していない
- クラスは
- 今回は保存するデータが空なので、空っぽのデータで
/users/{user-id}/を上書きしてしまい、すでに保存されていたデータを吹き飛ばした
これを直すために、以下の修正を行う。
- クラスに
Serialozable属性を付ける - プロパティをフィールドにする(C#に慣れている身からすると、この仕様はものすごく気持ち悪い。。。)
- DictionaryをListに変える
- セーブするときは、
/users/{user-id}に直接保存するのではなく、階層を一つ掘る
結果が以下。
varuserId=PlayerPrefs.GetString("user-id");varreference=databaseRoot.Child("users").Child(UserId).Child("backup");// user-id直ではなく、backupに保存varsaveData=newUserSaveData(){Level=1,Name="ああああ",RewardIds=newList<string>(){"a0001","b0002"},Items=newDictionary<string,int>{{"Sword",1},{"Shield",2}}};stringdata=JsonUtility.ToJson(saveData);reference.SetRawJsonValueAsync(data);[Serializable]classUserSaveData:ISerializationCallbackReceiver{publicintLevel;publicstringName;publicList<string>RewardIds;publicDictionary<string,int>Items;[SerializeField]privateList<string>itemKeys;[SerializeField]privateList<int>itemCounts;publicvoidOnBeforeSerialize(){itemKeys=newList<string>();itemCounts=newList<int>();foreach(variteminItems){itemKeys.Add(item.Key);itemCounts.Add(item.Value);}}publicvoidOnAfterDeserialize(){Items=newDictionary<string,int>();for(inti=0;i<itemKeys.Count;i++){Items.Add(itemKeys[i],itemCounts[i]);}}}Dictionaryが保存できないため、Jsonに変換する前にListに詰め替えている。
こうすれば、クラス丸ごとJsonに保存することが可能。
書き込んだ後のコールバックを受け取る
単にSetValueAsync()を実行しただけだと、このメソッドが非同期で実行されるため、書き込みに成功したのか、失敗したのか、わからない。
なのでコールバックを受け取る。
stringdata=JsonUtility.ToJson(saveData);reference.SetRawJsonValueAsync(data).ContinueWith(task=>{if(task.IsFaulted){Debug.LogError("firebase error: "+task.Exception);}elseif(task.IsCompleted){Debug.Log("firebase result:"+task.Status);}});コールバックをUIスレッドに戻す
コールバックが実装できたので、「保存に成功/失敗したら、画面にメッセージを出す」という機能を付けたくなる。
が、これはまたうまく動かないことがある。
「エラーは一切出ないのに画面がなにも更新されない」という状況になったなら、スレッドを疑った方が良いかもしれない。
コールバックが実行されるスレッドがUIスレッドでない場合、画面は更新できない。
詳細はここでは割愛するが、この場合はコンテキストを切り替えてUIスレッドでコールバックを実行すればいい。
UIスレッドについて詳しく知りたい方は、Unity UI ThreadとかSynchronizationContextとかでググってください。
// Action<bool> callback = xxx //何かコールバック関数を定義stringdata=JsonUtility.ToJson(saveData);//UIスレッドを触る可能性があるので、コールバックを渡すためのコンテキストを退避しておくvarcontext=System.Threading.SynchronizationContext.Current;reference.SetRawJsonValueAsync(data).ContinueWith(task=>{context.Post((obj)=>{if(objisAction<bool>){callback(task.IsFaulted);}},callback);});データを読み込む
読み込みはGetValueAsync()を使うだけ。シンプル。
書き込みの時に書いたコールバックやら、スレッドの切り替えやら、オブジェクトへの変換やら、そういうのを盛り込むと以下のようになると思う。
// Action<UserSaveData> callback = xxx //何かコールバック関数を定義//UIスレッドを触る可能性があるので、コールバックを渡すためのコンテキストを退避しておくvarcontext=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);}elseif(task.IsCompleted){UserSaveDatarestoreData=JsonUtility.FromJson<UserSaveData>(task.Result.GetRawJsonValue());Debug.Log("Complete save data restore");callback(restoreData);}},callback);});以上。
確かにSDKを入れて入出力するだけだから、簡単なのはそうなんだろうけど、罠もたくさんあって結構時間を使ってしまった。













