Firebase UnityでAndroidゲームアプリのセーブデータ管理

Unityで作っているスマホゲームに、端末移行や故障・紛失からの復旧のための、バックアップ機能を付けようと思った。
小規模であればFirebaseの無償枠で十分に使えると聞いたため、やってみる。

# Firebaseのプロジェクトづくり

なにはともあれ Firebase(https://console.firebase.google.com/) にアクセスし、Googleアカウントでログインする。

その後、「プロジェクトを作成」を選択。
任意のプロジェクト名を付ける。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/bb8999a8-6a22-bec0-d089-bd9b7df6928a.png)

Google Analyticsが使える。
無料なので、何も考えずにONのまま。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/6974c3e0-b02d-fd83-6b73-954e0b439a6a.png)

アカウントは適当に設定。
（このあたり良く分かっていない。新規作成は面倒だったので、`Default Account for Firebase`を使った）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/4486ac27-d359-93d6-4a80-6b7a7d609d77.png)


# アプリ登録

Firebaseを使うためには、アプリの登録が必要になる。
アプリ自体は開発中なので、当然まだリリースしていないが、パッケージ名だけ先に決めてしまい、登録する。

「開始するにはアプリを追加してください」の上にあるUnityロゴを選択。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/845f3f98-963a-5228-3356-f37dc9f5349a.png)

今回はAndroidを対象とする。後からiOSも追加できるので、特に気にせず登録。
パッケージ名は決めておいたものを入力(e.g. `com.company.appname`)。
ニックネームは必要に応じて適当に入力。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/e554a55c-a157-bdf1-2edc-ceb03b6b54ca.png)

設定ファイルをダウンロードできるようになる。
`google-services.json`をダウンロードして、UnityのAssets配下に置く。
場所は任意らしいが、特に階層は掘らず、直下に置いた。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/5c2eba32-b179-2cb8-05b4-67fabe6bd947.png)

Firebase Unity SDKを入手する。
ボタンを押すと`firebase_unity_sdk_6.15.2.zip`がダウンロードできた。
これは後で使う。
とりあえずFirebase側の設定を最後までやってしまう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/25b1f081-95aa-57ba-8130-af3e31579cc2.png)

アプリが追加できたら、左ペインから`Realtime Database`を選び、`データベースを作成`を選ぶ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/f41a73a2-0b5b-a49f-54c6-671ee044a9ff.png)

セキュリティルールを聞かれる。
後で設定すればいいので、今は`テストモードで開始`を選ぶ。今はとりあえず動かすことが目標。
この記事では省くが、Google様からもしつこく注意されるとおり、公開前には必ずルールを設定しなおすこと。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/f086daa6-17b2-fd4f-5cb7-3d0c3ba0d81f.png)

これでDBが作成された。
試しに、データを一つ作ってみる。

今回は`/users`を作り、その下に各ユーザのセーブデータを格納していく構成にした。（わざわざここで作らなくても、あとで無ければ勝手に作ってくれる。これはあくまで確認用）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/3a2d1d24-9285-917a-831f-19f147965dd8.png)

これで、`/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して解決。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/ab082335-9f4c-b1d0-399d-06a982449d2f.png)

## 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を再起動すると出て来るかも。

![capture02.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/41d9cf83-8688-8033-cc95-5270856bcad3.png)

PackageManagerレジストリを追加するか？と聞かれたので、素直に`Add Selected Registries`。
![capture05.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/268968ea-eb26-073b-4adc-9637d4b36676.png)

Packageの移行を進められたので、これまた素直に`Apply`。
![capture06.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/da0f585b-5a85-6de1-7b40-72039305c582.png)

これでエラーが消えた。

PackageのMigrate中、なぜかUnityがフリーズすることがあった。
Unityの再起動で直った。

# アプリ側の実装

あとはFirebaseと通信するためのコードを書いていく

## 初期化

まず、Assets直下に置いてある `google-services.json` の中身を開いて、接続先のURLを確認。`project_info.firebase_url`の欄がそれ。（`https://アプリ名.firebaseio.com/`という形式のはず）

このurlを使って、firebaseの中身を読み書きするためのオブジェクトを作成する。

```csharp
// 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側にデータは書き込まれない。

```csharp

//ユーザIDを新規作成する
var newData= databaseRoot.Child("users").Push();
//作ったIDをローカルに保存しておく
PlayerPrefs.SetString("user-id", userId);
```

IDができたら、実際のデータを入れてみる。とりえあずはテスト的に作成日を突っ込んでみた。

```csharp
databaseRoot.Child("users").Child(userId).Child("created_at").SetValueAsync(DateTime.UtcNow.ToString("yyyy/MM/dd HH:mm:ss"));
```

ちゃんとfirebaseに書き込まれている。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/396291c9-1276-edb3-9110-05a90857fac0.png)

失敗する場合は、パスを間違えているか、書き込み権限が不足している可能性があるので、ルールを再確認するよろし。
それでも原因がわからない場合は、後述するコールバックを追加して、失敗した理由をログ出力する。

### オブジェクトを丸ごと保存する

`SetValueAsync`だと、数字とか文字列とか、単一のデータしか書き込めない。
例えば以下のようなクラスがあった時、全部自前でバラしてセーブするのは面倒くさい。

```csharp
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()`が用意されている。

```csharp
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}`に直接保存するのではなく、階層を一つ掘る

結果が以下。

```csharp
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);
```

```csharp
[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に保存することが可能。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299048/0ec521a0-acad-e0f8-f692-158006ac51dc.png)

### 書き込んだ後のコールバックを受け取る

単に`SetValueAsync()`を実行しただけだと、このメソッドが非同期で実行されるため、書き込みに成功したのか、失敗したのか、わからない。

なのでコールバックを受け取る。

```csharp
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`とかでググってください。

```csharp
// 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()`を使うだけ。シンプル。

書き込みの時に書いたコールバックやら、スレッドの切り替えやら、オブジェクトへの変換やら、そういうのを盛り込むと以下のようになると思う。

```csharp
// 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を入れて入出力するだけだから、簡単なのはそうなんだろうけど、罠もたくさんあって結構時間を使ってしまった。