C#
Unity
ゲーム制作
OriginalqnoteDay 11

【Unity】JsonUtilityを使用してデータを超カンタンかつ美しくセーブする

【Unity】LitJsonを使用してデータを超カンタンにセーブする を書いてから、はや2年半。

↑の手法は私自身がゲームを作るときにも活用していたのですが、最近つかいづらさが目立つようになってきました。

LitJsonを使うとクラスを美しく設計できない

自身で記事を書いといて何ですが、コレがホントに痛いのです。

ゲームの規模が大きくなってくるとセーブデータの内容も膨らんできます。
セーブデータの内容が膨らんでくると、フィールドへ直アクセスさせずプロパティを噛ませたい場合もしばしば出てくるのですが、残念ながら「LitJsonはシリアライズ対象のフィールドが必ずpublicである」必要があります。

publicのフィールドを残したままプロパティを定義するとクラスが汚くなっちゃいますし、本来フィールドに直アクセスさせたくないのに出来ちゃうってことで、あとあとバグの原因にもなり得るワケで。

JsonUtilityという救いの手

幸いなことにUnity5.3からUnity公式の「JsonUtility」が使えるようになり、実質的にLitJsonが不要になりました。
私も最近はLitJsonを卒業し、常にJsonUtilityのお世話になっています。

!!注!!: JsonUtilityは少々クセがある(後述)ため、LitJson版のセーブデータをまるっとJsonUtility版に移すと一部のデータが復元できなかったりします。
ご注意をば!

JsonUtilityを使う準備

Unity5.3以降であれば初期状態で使えるので、準備は不要です。

セーブデータ用基底クラスの準備

LitJson版とほとんど一緒です。
以前はPlayerPrefsにJSON文字列を保存していましたが、セーブデータのバックアップをサーバに保存したい場合があったので、よりカンタンに扱えるようファイルに保存する形に変えました。

SavableSingletonBase.cs
using UnityEngine;
using System;
using System.IO;
using System.Security.Cryptography;

/// <summary>
/// ローカルストレージにファイルとして、シリアライズしたデータを保存できるシングルトンです(iOSの場合、該当ファイルはiCloudバックアップ対象から除外します)
/// </summary>
abstract public class SavableSingletonBase<T> where T : SavableSingletonBase<T>, new()
{
    protected static T instance;
    bool isLoaded = false;
    protected bool isSaving = false;

    public static T Instance
    {
        get
        {
            if (null == instance)
            {
                var json = File.Exists(GetSavePath()) ? File.ReadAllText(GetSavePath()) : "";
                if (json.Length > 0)
                {
                    LoadFromJSON(json);
                }
                else
                {
                    instance = new T();
                    instance.isLoaded = true;
                    instance.PostLoad();
                }
            }
            return instance;
        }
    }

    protected virtual void PostLoad()
    {
    }

    public void Save()
    {
        if (isLoaded)
        {
            isSaving = true;
            var path = GetSavePath();
            File.WriteAllText(path, JsonUtility.ToJson(this));
#if UNITY_IOS
            // iOSでデータをiCloudにバックアップさせない設定
            UnityEngine.iOS.Device.SetNoBackupFlag(path);
#endif
            isSaving = false;
        }
    }

    public void Reset()
    {
        instance = null;
    }

    public void Clear()
    {
        if (File.Exists(GetSavePath()))
        {
            File.Delete(GetSavePath());
        }
        instance = null;
    }

    public static void LoadFromJSON(string json)
    {
        try
        {
            instance = JsonUtility.FromJson<T>(json);
            instance.isLoaded = true;
            instance.PostLoad();
        }
        catch (Exception e)
        {
            Debug.Log(e.ToString());
        }
    }

    static string GetSavePath()
    {
        return string.Format("{0}/{1}", Application.persistentDataPath, GetSaveKey());
    }

    static string GetSaveKey()
    {
        var provider = new SHA1CryptoServiceProvider();
        var hash = provider.ComputeHash(System.Text.Encoding.ASCII.GetBytes(typeof(T).FullName));
        return BitConverter.ToString(hash);
    }
}

なお、後述の使用例のようにインスタンス自体を意識させずstaticプロパティとstaticメソッドのみ使用する場合、Instanceフィールドはprotectedにしちゃった方が良いです。

使ってみる

使い方に関しては、LitJson版よりチョット気を遣う必要があります。

JsonUtilityの制限とか

基本的には、publicまたは[SerializeField]が付いたフィールドがシリアライズ対象になります。
フィールドにオブジェクトを持たせ、一緒にシリアライズしちゃうことも可能です。
シリアライズしたいクラスには[Serializable]を付与しましょう。

その他 https://qiita.com/keidroid/items/24e03f82d74560dc557a でまとめてくださっていますので、ぜひご一読をば。

特に注意すべきこと

中でも特に注意すべきことを書き出しておきます。

readonlyのフィールドはシリアライズできない

フィールドをprivateにしておいて、プロパティでアクセス制御しましょう。

第一階層を列挙型にすることはできない

列挙型はクラスのフィールドとして持たせてあげましょう。

DateTime型はシリアライズ不可

しょうがないので、文字列に直してあげましょう。

Dictionary型はシリアライズ不可

こちらも残念ですが、LinqとListの組み合わせで何とかしましょう。

使用例

セーブデータじゃなくてキャラのマスタデータですが、使い方はこんな感じ。
フィールドはすべて隠蔽してますので、意図しない使われ方をされちゃうことはまず無いでしょう。

CharactersData.cs
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

[Serializable]
public class CharactersData : SavableSingletonbase<CharactersData>
{
    public enum CharacterId { chara1, chara2, chara3 }

    [SerializeField]
    List<CharacterData> items;

    /// <summary>
    /// IDに対応するキャラの最大レベルを返します
    /// </summary>
    /// <returns>The max level.</returns>
    /// <param name="id">Identifier.</param>
    public static int GetMaxLevel(string id)
    {
        return GetCharacterData(id).MaxLevel;
    }

    /// <summary>
    /// IDに対応するキャラの、次のレベルに上がるために必要な経験値を返します
    /// </summary>
    /// <returns>The required exp to next level.</returns>
    /// <param name="id">Identifier.</param>
    /// <param name="currentLevel">Current level.</param>
    public static int GetRequiredExpToNextLevel(string id, int currentLevel)
    {
        return GetCharacterData(id).GetRequiredExpToNextLevel(currentLevel);
    }

    /// <summary>
    /// IDに対応するキャラデータを取得します
    /// </summary>
    /// <returns>The character data.</returns>
    /// <param name="id">Identifier.</param>
    static CharacterData GetCharacterData(string id)
    {
        return instance.items.First(x => x.Id == id);
    }

    /// <summary>
    /// キャラ毎のデータクラス
    /// </summary>
    [Serializable]
    class CharacterData
    {
        [SerializeField]
        string id;

        [SerializeField]
        int[] requiredExps;

        public string Id
        {
            get { return id; }
        }

        /// <summary>
        /// 初期レベルを1としたときの最大レベルを返します
        /// </summary>
        /// <value>The max level.</value>
        public int MaxLevel
        {
            get { return requiredExps.Length + 1; }
        }

        /// <summary>
        /// 次のレベルに上がるために必要な経験値を返します
        /// </summary>
        /// <returns>The required exp to next level.</returns>
        /// <param name="currentLevel">Current level.</param>
        public int GetRequiredExpToNextLevel(int currentLevel)
        {
            return currentLevel >= MaxLevel ? 0 : requiredExps[currentLevel - 1];
        }
    }
}

まとめ

前作ったゲームもJsonUtility版に置き換えたい・・・!