[Unity] jsonを用いた外部へのデータの保存と読み込み

二回目の投稿です。よろしくお願いします。


# 目次
1. [概要](#概要)
2. [実装概要](#実装概要)
3. [作業手順](#作業手順)
4. [実装方法](#実装方法)
5. [最後に](#最後に)


# 概要
簡単なサンプルを用いて以下のことを解説します：
1. 外部へのデータの保存 (セーブ)
2. 外部からのデータの読み出し (ロード)
3. 外部データの削除 (デリート)

##### こんな感じのシステムが作れます：
![ezgif.com-gif-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/480241/ca2b8d45-2554-6991-6cfd-48c6615ea69c.gif)
(動画での手順: Add 5回 -> Clear -> Add 4回 -> Save -> Clear -> Load -> ゲーム再起動 -> Load -> Delete -> Clear -> Load
(これにより、ゲームを再起動してもデータが残っていることと、データの削除ができていることがわかる))
また、セーブデータは以下のような形式で保存されています：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/480241/e26928ba-28e3-50d4-1fa6-bb4d6622dbcf.png)

:::note warn
注意
解説にスコアカウンターのシステムを用いています。その実装方法は本題から大きく離れるため解説いたしません。ボタンやテキストは[これ](#こんな感じのシステムが作れます)を見ていい感じに配置し、ボタンを割り当てて下さい (きちんと配置したい人は自動レイアウトで検索)。
スコアカウンターに関するスクリプトは以下においておきます：

<details><summary>ScoreManager.cs (tap)</summary>

```
using UnityEngine;
using UnityEngine.UI;

namespace App.SaveSystem
{
    /// <summary>
    /// スコアの可算や表示、最大値の更新を担うクラス
    /// </summary>
    public class ScoreManager : MonoBehaviour
    {
        public Text maxScoreText;
        public Text scoreText;

        private int maxScore;
        private int score;

        private void Start()
        {
            UpdateScoreTexts();
        }

        private void UpdateScoreTexts()
        {
            maxScoreText.text = maxScore.ToString();
            scoreText.text = score.ToString();
        }

        public void AddScore() // only button
        {
            score++;
            if (score > maxScore) // scoreがmaxScoreを超えるごとにmaxScoreを更新
            {
                maxScore = score;
            }
            UpdateScoreTexts();
        }

        public void ClearScore() // only button
        {
            maxScore = 0;
            score = 0;
            UpdateScoreTexts();
        }
    }
}
```
</details>
:::

# 実装概要

1. [セーブの概要](#セーブの概要)
2. [ロードの概要](#ロードの概要)
3. [デリートの概要](#デリートの概要)

#### セーブの概要
要点は「セーブしたいデータを含むクラスをjson形式に変換し、保存先を指定し、上書き(新規)保存すること」なので、最低限以下をすればよい：
1. 保存したいデータ(maxScore)を含むクラス(SaveData)を用意する。
2. SaveDataクラスの経由地となるstaticクラス(SaveDataStore)を用意する。
(staticにすることで参照しやすくなり、かつ、シーン間で保持される)
3. SaveDataStoreのSaveDataをjson形式に変換する。
4. 保存先のパスを指定し、上書き(もしくは新規)保存する。

ちなみに、今回使用するセーブ用のクラスは以下である：
<details><summary>SaveData.cs (tap)</summary>

```
using UnityEngine;

namespace App.SaveSystem
{
    [System.Serializable]
    public class SaveData
    {
        public int MaxScore
        {
            get => maxScore;
            set => maxScore = value;
        }
        [SerializeField]
        private int maxScore;
    }
}
```

</details>

:::note warn
注意
データをjson形式にシリアル化するので、保存したいクラス(今回はSaveDataクラス)をSystem.Serializable属性でシリアル化可能にしておく必要がある。
(シリアル化とは、階層をもたないフラットな（直線的な）データ構造に変換すること。(wiki))
:::


#### ロードの概要
要点は「保存しているjson形式のデータを読み出してクラスに変換し、SaveDataStoreのSaveDataに代入する」なので、最低限以下をすればよい：
1. 保存先で指定していたパスを用いて、セーブデータを読み出す。
2. このjson形式のデータをクラスに変換する(元に戻す)。
3. 読みだしたデータをSaveDataStoreのSaveDataに代入する。

:::note warn
注意
上記手順でデータを読み出せるが、反映(今回の場合はMaxScoreとして表示)はされないので、イベントを用いてロード時に表示する必要がある。
:::

#### デリートの概要
System.IO.File.Delete関数を用いて、削除したいデータのパス(保存先で指定していたパス)を指定し、実行するだけ。


# 作業手順
[1. セーブの実装](#1-セーブの実装)
　1.1. 保存したいデータを含むクラス(SaveData)と、その経由地となるクラス(SaveDataStore)の作成
　1.2. スコアの最大値が更新されたら、SaveDataStore.SaveDataのMaxScoreを更新する。
　1.3. SaveDataStoreのSaveDataをjson形式に変換し、指定したフォルダ内に保存する。
　1.4. セーブ機能を持つボタン用関数の作成
　1.5. セーブの実装確認
[2. ロードの実装](#2-ロードの実装)
　2.1. セーブデータを読み出し、クラスに変換する。
　2.2. ロード機能を持つボタン用関数の作成
　2.3. ロード時にテキストを更新
　2.4. ロードの実装確認
[3. デリートの実装](#3-デリートの実装)
　3.1. セーブデータを削除する機能を追加
　3.2. デリート機能を持つボタン用関数の作成
　3.3. デリートの実装確認


# 実装方法

#### 1. セーブの実装
##### 1.1. 保存したいデータを含むクラス(SaveData)と、その経由地となるクラス(SaveDataStore)の作成
以下のようにSaveData.csとSaveDataStore.csを作成する：
<details><summary>SaveData.cs (tap)</summary>

```
using UnityEngine;

namespace App.SaveSystem
{
    [System.Serializable]
    public class SaveData
    {
        public int MaxScore
        {
            get => maxScore;
            set => maxScore = value;
        }
        [SerializeField]
        private int maxScore;
    }
}
```

</details>

:::note info
ポイント：
* maxScoreを直接参照させないようにプロパティを用いている。
* 後にjson形式にシリアル化するので、クラスにSerializable属性をつけている。
:::

(2022/12/10追記)
maxScoreをpublic変数にしてセーブデータとして扱えるようにしているが、外部に参照させたくないので、private変数にして[UnityEngine.SerializeField]を用いた方が良い。

<details><summary>SaveDataStore.cs (tap)</summary>

```
using UnityEngine.Events;

namespace App.SaveSystem
{
    /// <summary>
    /// セーブデータを読み込んだもの
    /// </summary>
    public static class SavedDataStore
    {
        public static SaveData SaveData
        {
            get => saveData;
            set => saveData = value;
        }
        private static SaveData saveData = new SaveData();
    }
}
```
</details>

:::note info
ポイント：
* saveDataを直接参照させないようにプロパティを用いている。
* static変数でラップすることで参照しやすくしている。 (staticなのでシーン間で値を保持)
:::


##### 1.2. スコアの最大値が更新されたら、SaveDataStore.SaveDataのMaxScoreを更新する。
<details><summary>ScoreManager.cs (tap)</summary>

```diff_c_sharp
using UnityEngine;
using UnityEngine.UI;

namespace App.SaveSystem
{
    /// <summary>
    /// スコアの可算や表示、最大値の更新を担うクラス
    /// </summary>
    public class ScoreManager : MonoBehaviour
    {
        public Text maxScoreText;
        public Text scoreText;

        private int maxScore;
        private int score;

        private void Start()
        {
            UpdateScoreTexts();
        }

        private void UpdateScoreTexts()
        {
            maxScoreText.text = maxScore.ToString();
            scoreText.text = score.ToString();
        }

        public void AddScore() // only button
        {
            score++;
            if (score > maxScore) // scoreがmaxScoreを超えるごとにmaxScoreを更新
            {
                maxScore = score;
+               SavedDataStore.SaveData.MaxScore = maxScore;
            }
            UpdateScoreTexts();
        }

        public void ClearScore() // only button
        {
            maxScore = 0;
            score = 0;
            UpdateScoreTexts();
        }
    }
}
```
</details>

##### 1.3. SaveDataStoreのSaveDataをjson形式に変換し、指定したフォルダ内に保存する。
以下のようにSaveAndLoadSystem.csを作成する：

<details><summary>SaveAndLoadSystem.cs (tap)</summary>

```
using System;
using System.IO;
using UnityEngine;

namespace App.SaveSystem
{
    public static class SaveAndLoadSystem<T> where T : class, new()
    {
        private const string FolderName = "SaveData";

        public static void Save(T data)
        {
            var jsonData = JsonUtility.ToJson(data); // json形式に変換

            using (var sw = new StreamWriter(GetPath(data), false)) // 上書き(データがないなら新規作成)
            {
                try
                {
                    sw.Write(jsonData); // セーブ
                    Debug.Log($"Saved {data.GetType().Name} : \n{jsonData}"); // debugonly
                }
                catch (Exception e)
                {
                    Debug.LogError(e);
                }
            }
        }

        private static string GetPath(T data)
        {
            var directoryPath = Application.persistentDataPath + "\\" + FolderName;
            Directory.CreateDirectory(directoryPath); // 指定したパスにフォルダがないなら, フォルダを新規作成
            return directoryPath + $"\\{data.GetType().Name}" + ".json";
        }
    }
}
```
</details>

:::note info
ポイント：
* JsonUtility.ToJson()でjson形式に変換
* StreamWriterにパスを指定して、そこに保存させる。
* persistentDataPathとは"C: \Users\\(ユーザーフォルダ)\AppData\LocalLow\\{Company Name}\\{Project Name}"で、そこにセーブデータが保存される。
(Company NameはUnityエディター上のEdit/ProjectSetting/Playerで見れる)
* ジェネリック型<T>を用いることで、様々なクラスに対応させている。
(今回はSaveData型しか使わないので、<T>内のTをSaveDataとしても動作する(汎用性に欠けるが。))
:::

##### 1.4. セーブ機能を持つボタン用関数の作成
以下のようにSaveAndLoadButton.csを作成し、Save関数をボタンに付与する：

<details><summary>SaveAndLoadButton.cs (tap)</summary>

```
using UnityEngine;

namespace App.SaveSystem
{
    /// <summary>
    /// データのセーブとロード、デリート機能を持つボタン用関数群
    /// </summary>
    public class SaveAndLoadButton : MonoBehaviour
    {
        public void Save()
        {
            var saveData = SavedDataStore.SaveData;
            SaveAndLoadSystem<SaveData>.Save(saveData);
        }
    }
}
```
</details>


##### 1.5. セーブの実装確認
スコアカウンターを何回か押し、続けてセーブボタンを押す。そして、画面に表示されているMaxScoreの値を保持するjson形式のデータ(SaveData.json)が"C: \Users\\(ユーザーフォルダ)\AppData\LocalLow\\{Company Name}\\{Project Name}"にあればok。
↓ MaxScoreが4回の状態でセーブを押したときに、生成されるデータ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/480241/4b784f40-3635-728e-8fba-3e0a1d1046b7.png)


#### 2. ロードの実装
##### 2.1. セーブデータを読み出し、クラスに変換する。
SaveAndLoadSystem.csにデータをロードする関数を追記する：
<details><summary>SaveAndLoadSystem.cs (tap)</summary>

```diff_c_sharp
using System;
using System.IO;
using UnityEngine;

namespace App.SaveSystem
{
    public static class SaveAndLoadSystem<T> where T : class, new()
    {
        private const string FolderName = "SaveData";

        public static void Save(T data)
        {
            var jsonData = JsonUtility.ToJson(data);

            using (var sw = new StreamWriter(GetPath(data), false)) // 上書き
            {
                try
                {
                    sw.Write(jsonData);
                    Debug.Log($"Saved {data.GetType().Name} : \n{jsonData}"); // debugonly
                }
                catch (Exception e)
                {
                    Debug.LogError(e);
                }
            }
        }

+        public static T Load()
+        {
+            var data = new T();
+            try
+            {
+                using (var fs = new FileStream(GetPath(data), FileMode.OpenOrCreate))
+                {
+                    using (var sr = new StreamReader(fs))
+                    {
+                        var jsonData = sr.ReadToEnd();
+                        Debug.Log($"Loaded {data.GetType().Name} : \n{jsonData}"); // debugonly
+                        data = JsonUtility.FromJson<T>(jsonData);
+                    }
+                }
+            }
+            catch (Exception e)
+            {
+                Debug.LogError(e);
+                return null;
+            }
+            return data;
+        }

        private static string GetPath(T data)
        {
            var directoryPath = Application.persistentDataPath + "\\" + FolderName;
            Directory.CreateDirectory(directoryPath); // 指定したパスにフォルダがないなら, フォルダを新規作成
            return directoryPath + $"\\{data.GetType().Name}" + ".json";
        }
    }
}
```
</details>

:::note info
ポイント：
* JsonUtility.FromJson()でjsonをクラスに変換
* FileStreamにパスを指定して、それを読み出す。
:::

##### 2.2. ロード機能を持つボタン用関数の作成
<details><summary>SaveAndLoadButton.cs (tap)</summary>

```diff_c_sharp
using UnityEngine;

namespace App.SaveSystem
{
    /// <summary>
    /// データのセーブとロード、デリート機能を持つボタン用関数群
    /// </summary>
    public class SaveAndLoadButton : MonoBehaviour
    {
        public void Save()
        {
            var saveData = SavedDataStore.SaveData;
            SaveAndLoadSystem<SaveData>.Save(saveData);
        }

+        public void Load()
+        {
+            var loadedData = SaveAndLoadSystem<SaveData>.Load() ?? new SaveData();
+            SavedDataStore.SaveData = loadedData;
+        }
    }
}
```
</details>

:::note info
ポイント：
* ロード時にデータがなければ初期値を取得
* SavedDataStore.SaveDataに読みだしたデータを代入
:::


##### 2.3. ロード時にテキストを更新
イベントに実行する処理を予め登録しておき、ロード時にイベントを実行すればよい：
1. SavedDataStore.csにイベントを格納する変数(onLoadEvent)を用意
2. ScoreManager.csでロード時に実行する処理を登録
3. SaveAndLoadButton.csのLoad()内でロード後にonLoadEventを実行

<details><summary>SavedDataStore.cs (tap)</summary>

```diff_c_sharp
using UnityEngine.Events;

namespace App.SaveSystem
{
    /// <summary>
    /// セーブデータを読み込んだもの
    /// </summary>
    public static class SavedDataStore
    {
+        public static UnityEvent onLoadEvent = new UnityEvent(); // ロード時に呼ばれるイベント

        public static SaveData SaveData
        {
            get => saveData;
            set => saveData = value;
        }
        private static SaveData saveData = new SaveData();
    }
}
```
</details>

<details><summary>ScoreManager.cs (tap)</summary>

```diff_c_sharp
using UnityEngine;
using UnityEngine.UI;

namespace App.SaveSystem
{
    /// <summary>
    /// スコアの可算や表示、最大値の更新を担うクラス
    /// </summary>
    public class ScoreManager : MonoBehaviour
    {
        public Text maxScoreText;
        public Text scoreText;

        private int maxScore;
        private int score;

        private void Start()
        {
+            SavedDataStore.onLoadEvent.AddListener(() => 
+            {
+                maxScore = SavedDataStore.SaveData.MaxScore;
+                UpdateScoreTexts();
+            }); // ロード時に保存していたデータを表示
            UpdateScoreTexts();
        }

        private void UpdateScoreTexts()
        {
            maxScoreText.text = maxScore.ToString();
            scoreText.text = score.ToString();
        }

        public void AddScore()
        {
            score++;
            if (score > maxScore)
            {
                maxScore = score;
                SavedDataStore.SaveData.MaxScore = maxScore;
            }
            UpdateScoreTexts();
        }

        public void ClearScore()
        {
            maxScore = 0;
            score = 0;
            UpdateScoreTexts();
        }
    }
}
```
</details>

<details><summary>SaveAndLoadButton.cs (tap)</summary>

```diff_c_sharp
using UnityEngine;

namespace App.SaveSystem
{
    /// <summary>
    /// データのセーブとロード、デリート機能を持つボタン用関数群
    /// </summary>
    public class SaveAndLoadButton : MonoBehaviour
    {
        public void Save()
        {
            var saveData = SavedDataStore.SaveData;
            SaveAndLoadSystem<SaveData>.Save(saveData);
        }

        public void Load()
        {
            var loadedData = SaveAndLoadSystem<SaveData>.Load() ?? new SaveData();
            SavedDataStore.SaveData = loadedData;
+           SavedDataStore.onLoadEvent?.Invoke();
        }
    }
}
```
</details>

:::note info
ポイント：
* onLoadEventをstaticにすることで唯一性を保証
* イベント続行時に"?.Invoke()"を用いてnull確認
:::

##### 2.4. ロードの実装確認
セーブデータが存在する状態でロードボタンを押した時、データに記されているMaxScoreが読みだされ、表示されればok。[(参考)](#こんな感じのシステムが作れます)


#### 3. デリートの実装
##### 3.1. セーブデータを削除する機能を追加
<details><summary>SaveAndLoadSystem.cs (tap)</summary>

```diff_c_sharp
using System;
using System.IO;
using UnityEngine;

namespace App.SaveSystem
{
    public static class SaveAndLoadSystem<T> where T : class, new()
    {
        private const string FolderName = "SaveData";

        public static void Save(T data)
        {
            var jsonData = JsonUtility.ToJson(data);

            using (var sw = new StreamWriter(GetPath(data), false)) // 上書き
            {
                try
                {
                    sw.Write(jsonData);
                    Debug.Log($"Saved {data.GetType().Name} : \n{jsonData}"); // debugonly
                }
                catch (Exception e)
                {
                    Debug.LogError(e);
                }
            }
        }

        public static T Load()
        {
            var data = new T();
            try
            {
                using (var fs = new FileStream(GetPath(data), FileMode.OpenOrCreate))
                {
                    using (var sr = new StreamReader(fs))
                    {
                        var jsonData = sr.ReadToEnd();
                        Debug.Log($"Loaded {data.GetType().Name} : \n{jsonData}"); // debugonly
                        data = JsonUtility.FromJson<T>(jsonData);
                    }
                }
            }
            catch (Exception e)
            {
                Debug.LogError(e);
                return null;
            }
            return data;
        }

+       public static void Delete()
+       {
+           File.Delete(GetPath(new T())); // 指定したパスのファイルを削除
+           Debug.Log($"Delete SaveData");
+       }

        private static string GetPath(T data)
        {
            var directoryPath = Application.persistentDataPath + "\\" + FolderName;
            Directory.CreateDirectory(directoryPath); // 指定したパスにフォルダがないなら, フォルダを新規作成
            return directoryPath + $"\\{data.GetType().Name}" + ".json";
        }
    }
}
```
</details>

##### 3.2. デリート機能を持つボタン用関数の作成

<details><summary>SaveAndLoadButton.cs (tap)</summary>

```diff_c_sharp
using UnityEngine;

namespace App.SaveSystem
{
    /// <summary>
    /// データのセーブとロード、デリート機能を持つボタン用関数群
    /// </summary>
    public class SaveAndLoadButton : MonoBehaviour
    {
        public void Save()
        {
            var saveData = SavedDataStore.SaveData;
            SaveAndLoadSystem<SaveData>.Save(saveData);
        }

        public void Load()
        {
            var loadedData = SaveAndLoadSystem<SaveData>.Load() ?? new SaveData();
            SavedDataStore.SaveData = loadedData;
            SavedDataStore.onLoadEvent?.Invoke();
        }

+        public void Delete()
+        {
+            SaveAndLoadSystem<SaveData>.Delete();
+        }
    }
}
```
</details>


##### 3.3. デリートの実装確認
セーブデータがある状態でデリートボタンを押し、セーブデータがフォルダ(C: \Users\\(ユーザーフォルダ)\AppData\LocalLow\\{Company Name}\\{Project Name})からなくなっていればok。[(参考)](#こんな感じのシステムが作れます)


# 最後に
どうでしたか、わかりやすかったでしょうか。今回は[前回](https://qiita.com/Marthallll/items/ae0a22a7b54e3af835d6)よりは難しいと思います。ただ、セーブはゲームに限らずアプリ制作でほぼ必須となる技術なので、苦労して習得する価値は大いにあります。
今回はボタンを用いて実装しましたが、トリガーを用いれば自動セーブが実装できたりと、いろいろできます。ちなみに、ScriptableObjectを用いる場合はMonoBehaviorを継承していることからシリアル化できないので、一工夫必要です(一旦別のクラスにデータを移して、それを保存するなど)。
また気が向いたら何か書きます。それでは。


宣伝 : CUPLEXという時間差アクションパズルゲームを作成しています。私は主にプログラムとプロジェクト管理を担当しています。よかったら、覗いてみてください↓
https://store.steampowered.com/app/2499100/CUPLEX/