8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-08-29

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

Firebaseのプロジェクトづくり

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

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

image.png

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

image.png

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

image.png

アプリ登録

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

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

image.png

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

image.png

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

image.png

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

image.png

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

image.png

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

image.png

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

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

image.png

これで、/users/{ユーザID}/xxxみたいな感じで、データのバックアップを作る。

ここから先はUnity側の作業。

Unityに必要なAssertを入れる。

Firebase SDKを追加

FirebaseからダウンロードしたZipファイル中の\firebase_unity_sdk\dotnet4FirebaseDatabase.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

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はリゾルバを更新すれば自動で上がるので、そちらを更新する。
参考:

リゾルバは以下から取得。見ての通りunitypackageになっているので、Firebase SDKと同様、インポートするだけ。
https://github.com/googlesamples/unity-jar-resolver/blob/master/external-dependency-manager-latest.unitypackage

「Obsoleteファイルを消すか?」と聞かれたら、削除する。(何回か出るかも。毎回削除でOK)
これを消さないと別のエラーが出る。ダイヤログが出ない場合、Unityを再起動すると出て来るかも。

capture02.PNG

PackageManagerレジストリを追加するか?と聞かれたので、素直にAdd Selected Registries
capture05.PNG

Packageの移行を進められたので、これまた素直にApply
capture06.PNG

これでエラーが消えた。

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に書き込まれている。

image.png

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

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

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

image.png

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

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

8
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?