この記事について

この記事では、JsonUtilityを使ってデータをJSON形式に変換し、ファイルへ保存・読み込みする方法について解説します。
名前・レベル・スコアのように、複数の値をまとめたセーブデータを扱いたいときに便利です。

基本の流れは「クラス → JSON文字列 → ファイルへ保存」。読み込みはその逆です。保存場所やデータの存在確認に加えて、配列・Listの扱い方、Base64とAESの違いも紹介します。

画像について:この記事の画像は、以前のサンプルを掲載したときの記録です。画像内のpassword・MySecretは当時のダミー項目で、今回のコードには含めていません。実際のパスワードをセーブデータやConsoleへ出力しないでください。ファイル名や項目は、以下のコードを基準にしてください。

旧サンプルでJSONファイルへ保存したデータの例

この記事でわかること

  • JsonUtility.ToJson・FromJsonでデータを変換する方法
  • ファイルの保存・存在確認・読み込みと、失敗したときの扱い
  • 保存場所、public・SerializeField、配列・Listの注意点
  • Base64のエンコードと、AESを使う前に確認したいこと

JsonUtilityとは?

JsonUtilityは、Unityが提供するJSONシリアライズ用のAPIです。クラスや構造体のデータをJSON文字列へ変換し、JSON文字列からデータを復元できます。
JsonUtility自体にファイルを作成する機能はないので、今回はSystem.IOのFileクラスと組み合わせて使います。

  • ToJson:データをJSON文字列に変換する
  • File.WriteAllText:文字列をファイルへ書き込む
  • File.ReadAllText:ファイルから文字列を読み込む
  • FromJson:JSON文字列からデータを復元する

音量やハイスコアなど、少数の数値・文字列だけを保存したい場合は、PlayerPrefsの使い方も参考にしてください。

保存できるフィールドと型

保存用クラスには[System.Serializable]を付けます。ただし、この属性を付ければ何でも保存できるわけではありません。

  • 基本の対象はpublicフィールド。対応する型なら、[SerializeField]を付けたprivateフィールドも対象になる
  • 通常のプロパティ、static・const・readonlyのフィールドは対象外
  • int・float・stringなどや、対応する型の一次元配列・List<T>を扱える
  • Dictionaryや多次元配列などは、そのままでは扱えない

詳しい条件は、Unity公式のシリアライズのルールで確認できます。

JsonUtilityの準備

まずはヒエラルキーで右クリックをして、Create Emptyから空のGameObjectを1つ用意します。
続いて、保存するデータ用のPlayerData.csと、処理を書くData_JsonUtility.csを作成します。GameObjectへアタッチするのはData_JsonUtilityだけです。

すでに旧サンプルを使っている場合は、同じ名前のクラスを重複して追加せず、サンプル用プロジェクトで置き換えてください。

空のGameObjectに保存処理のスクリプトをアタッチする準備画面

JsonUtilityで保存・存在確認・読み込みをする

保存するクラスを定義する

まずはPlayerData.csです。名前・レベル・スコアに加えて、データ形式を確認するためのVersionを持たせます。

[System.Serializable]
public class PlayerData
{
    public int Version;
    public string Name;
    public int Level;
    public float Score;
}

初期値は、次のCreateDefault()でまとめて設定します。保存用クラスはMonoBehaviourを継承しない普通のクラスにしておくと、FromJsonで扱いやすくなります。

ToJsonとFile.WriteAllTextで保存する

次はData_JsonUtility.csに、保存と読み込みの処理をまとめます。
起動時は読み込みだけを行い、保存はSaveToFile()を呼んだときだけにしています。毎回Start()で初期データを保存すると、前回の進行状況を上書きしてしまうためです。

ToJsonの第2引数prettyPrintは、trueなら改行・インデントを付けて読みやすく整形、falseなら余分な空白を省いた形式になります。省略時はfalseです。

using System;
using System.IO;
using UnityEngine;

public class Data_JsonUtility : MonoBehaviour
{
    [SerializeField] private PlayerData data;
    private string filePath;
    private bool canSave;

    private void Start()
    {
        string folder = Application.persistentDataPath;
        if (string.IsNullOrEmpty(folder))
        {
            Debug.LogError("この環境では保存先を取得できません。");
            return;
        }

        // 旧サンプルのファイルと混ぜないため、別名にする
        filePath = Path.Combine(folder, "playerData_sample.json");
        data = CreateDefault();
        LoadFromFile(); // 起動時は読み込みだけ。保存はしない
    }

    private static PlayerData CreateDefault()
    {
        return new PlayerData
        {
            Version = 1,
            Name = "No Name",
            Level = 100,
            Score = 12.3f
        };
    }

    private static bool IsValid(PlayerData value)
    {
        return value != null
            && value.Version == 1
            && !string.IsNullOrWhiteSpace(value.Name)
            && value.Level >= 1
            && value.Score >= 0f
            && !float.IsNaN(value.Score)
            && !float.IsInfinity(value.Score);
    }

    [ContextMenu("Save JSON")]
    public void SaveToFile()
    {
        if (!Application.isPlaying || string.IsNullOrEmpty(filePath)
            || !canSave || !IsValid(data))
        {
            Debug.LogWarning("保存できません。再生状態・データ・読み込み結果を確認してください。");
            return;
        }

        try
        {
            Directory.CreateDirectory(Path.GetDirectoryName(filePath));
            string json = JsonUtility.ToJson(data, true);
            File.WriteAllText(filePath, json);
            Debug.Log("保存完了: " + filePath);
        }
        catch (Exception e) when (e is IOException
            || e is UnauthorizedAccessException || e is ArgumentException)
        {
            canSave = false;
            Debug.LogError("保存に失敗しました: " + e.Message);
        }
    }

    [ContextMenu("Load JSON")]
    public void LoadFromFile()
    {
        if (!Application.isPlaying || string.IsNullOrEmpty(filePath))
            return;

        // 読み込みに失敗した場合は、続けて上書き保存しない
        canSave = false;
        try
        {
            if (!File.Exists(filePath))
            {
                Debug.Log("セーブファイルが見つかりません。現在のデータで続けます。");
                canSave = true;
                return;
            }

            string json = File.ReadAllText(filePath);
            if (string.IsNullOrWhiteSpace(json))
                throw new InvalidDataException("ファイルが空です。");

            PlayerData loaded = JsonUtility.FromJson<PlayerData>(json);
            if (!IsValid(loaded))
                throw new InvalidDataException("データの形式や値を確認してください。");

            data = loaded; // 検証できた場合だけ現在のデータを置き換える
            canSave = true;
            Debug.Log($"読み込み完了: 名前={data.Name}, レベル={data.Level}, スコア={data.Score}");
        }
        catch (Exception e) when (e is IOException || e is InvalidDataException
            || e is UnauthorizedAccessException || e is ArgumentException)
        {
            Debug.LogError("読み込みに失敗しました。元のファイルは上書きしません: " + e.Message);
        }
    }
}

少し長いですが、保存の中心はToJsonとFile.WriteAllTextの2行です。try・catchでは、ファイルの読み書きなどで起きたエラーを受け取っています。
Version・名前・数値の確認は、このサンプル用の簡単な検証です。すべての項目の欠損や、データの改ざんを検出するものではありません。

  • この例の保存先はplayerData_sample.jsonです。旧版のplayerData.jsonと混ぜず、既存ファイルの自動変換もしません
  • 保存先はApplication.persistentDataPathから取得し、Path.Combineでファイル名をつなぐ
  • 読み込みに失敗した場合は保存を止める。元ファイルを確認・退避し、原因を直してからLoad JSONをやり直す
  • File.WriteAllTextは既存ファイルを上書きする。書き込み中断への備えやバックアップは別途必要

保存場所を確認する

保存に成功すると、Consoleに「保存完了: 保存先」が表示されます。そのパスを確認しましょう。
Application.persistentDataPathは、アプリの再起動後も残すデータの保存先を取得するために使います。OSやPlayer Settingsによって場所が変わるので、固定のパスを書き込むより扱いやすくなります。

以下は旧サンプルの保存場所とJSONの画像です。今回の例ではファイル名がplayerData_sample.jsonになり、Versionが追加され、passwordは含まれません。

旧サンプルでApplication.persistentDataPathの保存場所を確認した画面
旧サンプルのplayerData.jsonに保存された名前・レベル・スコアなどの例

なお、WebではブラウザーのIndexedDBを使う保存領域になり、tvOSではpersistentDataPathが空文字になります。Webでの永続化や実機の動作まで、このサンプルだけで確認できるわけではありません。対象環境の条件はUnity公式の保存先の説明を参照してください。

File.ExistsとFromJsonで読み込む

読み込みは、ファイル確認 → 文字列の読み込み → FromJsonで変換 → 値の確認の順番です。上のLoadFromFile()では、読み込んだデータを検証できた場合だけ現在のdataへ代入しています。

  • 初回でファイルが見つからない:メモリー上の初期値で続ける。ファイルはまだ作らない
  • ファイルが読めて値も問題ない:保存済みのデータに置き換える
  • 空ファイル・形式不一致・読み込みエラー:エラーを表示し、続けて上書き保存しない

File.Existsがtrueでも、中身が正しいことや、その後の読み込み成功までは保証されません。また、権限などの問題でfalseになる場合もあります。存在確認に加えて、読み込み時のエラー処理も用意しておきましょう。

以下は旧サンプルで存在確認・読み込みを行った画像です。当時は存在しないダミーファイルも調べていましたが、今回のコードは保存先を1つにそろえています。画像内のパスワード表示をまねる必要はありません。

旧サンプルの存在確認とデータ読み込みのConsole表示

Unityエディターで保存と再読み込みを確認する

  1. Data_JsonUtilityをアタッチしたGameObjectを選び、Playモードにする
  2. InspectorのDataにあるName・Level・Scoreを変更する。Versionは1のままにする
  3. Data_JsonUtilityコンポーネントのメニューから「Save JSON」を実行する
  4. Playモードを終了して、もう一度再生する。保存した値がDataへ読み込まれるか確認する
  5. 追加で試す場合はサンプルファイルのコピーを取り、空ファイルやVersion違いで保存が止まるか確認する

ContextMenuはエディターで処理を呼ぶためのものです。ビルドしたゲームでは、UIの保存ボタンやチェックポイントなどからSaveToFile()を呼ぶ形にします。
このコードは基本の流れを学ぶための例です。公開するゲームでは、バックアップ、書き込み途中で終了した場合の復旧、データ形式を変更したときの移行も設計してください。

Base64でJSON文字列をエンコードする

Base64は、データを文字列で表すためのエンコード方式です。暗号化用の鍵は使わず、誰でもデコードできます。そのため、パスワードの保護や改ざん防止には使えません。

ここでは、JSONをUTF-8のバイト列にし、Base64へ変換してから元に戻す流れを見ていきます。ファイル保存の例とは分けて、メモリー上だけで変換します。

ToBase64Stringで変換する

先ほどのPlayerData.csを使い、別のJsonBase64Sample.csを作成します。試す場合はGameObjectへアタッチしてください。

using System;
using System.Text;
using UnityEngine;

public class JsonBase64Sample : MonoBehaviour
{
    private void Start()
    {
        PlayerData data = new PlayerData
        {
            Version = 1,
            Name = "No Name",
            Level = 100,
            Score = 12.3f
        };

        string json = JsonUtility.ToJson(data);
        string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
        Debug.Log("Base64: " + encoded);

        string decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
        PlayerData restored = JsonUtility.FromJson<PlayerData>(decoded);
        Debug.Log("元に戻した名前: " + restored.Name);
    }
}

Convert.ToBase64Stringに渡すのは文字列そのものではなく、Encoding.UTF8.GetBytesで変換したバイト列です。
以下は旧サンプルでBase64文字列をファイルへ保存した画像です。見た目が読みにくくなっていても、秘密にできたという意味ではありません。

旧サンプルでJSONをBase64文字列に変換して保存した例

FromBase64Stringで元のJSONに戻す

元に戻すときは、Base64文字列 → バイト列 → UTF-8のJSON文字列 → PlayerDataの順番です。上のコードのdecodedとrestoredが、その処理にあたります。

以下は旧サンプルの読み込み画像です。Base64を元へ戻したあとにFromJsonでデータを取り出す、という流れは同じです。

旧サンプルでBase64から復元したデータをConsoleに表示した例

今回のコードは自分で直前に作った文字列を戻しています。外部ファイルから読む場合は、Convert.FromBase64Stringで形式不正のFormatExceptionが起こる可能性があります。ファイルの読み込み、デコード、JSONの変換、値の検証をそれぞれ確認してください。APIの詳細はMicrosoftのFromBase64Stringの説明を参照してください。

AESで暗号化する前に知っておきたいこと

AESは、秘密の鍵を使う共通鍵暗号の方式です。ただし、「AESを呼んだらセーブデータを安全に保護できる」とは限りません。鍵の管理、暗号の利用モード、改ざん検出まで含めて考える必要があります。

旧サンプルの固定鍵・固定IVは使わない

旧版では、鍵とIVをソースコードへ固定で書いたAESサンプルを掲載していました。鍵をアプリ内へ埋め込むと解析で取り出される可能性があり、IVの固定も適切ではありません。
IVは鍵とは別の値で、秘密鍵の代わりにはなりません。生成・再利用の条件は暗号モードによって異なります。

下の画像は旧実装で保存したときの記録です。文字列が読めないことだけでは、安全性や改ざん耐性は確認できません。誤ってそのまま利用しないように、固定鍵・固定IVのコードは掲載していません。

固定鍵と固定IVを使っていた旧AESサンプルの保存結果。推奨実装ではありません

暗号化と改ざん検出を分けて考える

旧実装には認証タグなどによる完全性の検証がなく、読み込み側で改ざんを検出する仕組みが不足していました。復号できることと、改ざんされていないことを確認できることは別です。

暗号化と完全性の確認については、MicrosoftのCBCモードの復号に関する注意点にも解説があります。

以下は旧サンプルの復号・読み込み結果です。今回のJSON保存コードやBase64の例から、この旧AESファイルを読み込むことはできません。

旧AESサンプルで復号してConsoleに表示した記録。今回のコードの実行結果ではありません
  • 暗号化が必要なら、認証付き暗号、鍵の保管方法、保存形式、失敗時の扱いをセットで設計する
  • AES-GCMなどにもnonceの再利用禁止や認証タグの検証といった条件がある
  • 使用するUnityのバージョン・API互換性・対象OSで、その暗号APIを利用できるか確認する
  • ランキングや課金アイテムなど、改ざんの影響が大きい値はサーバー側での検証も検討する

認証付き暗号のAPIの一例は、MicrosoftのAesGcm.Encryptの説明で確認できます。この記事では、機密情報を含まないデータでJSONの保存・読み込みを学ぶところまでを扱います。

配列・List・Dictionaryの注意点

配列やListは保存用クラスで包む

アイテム一覧などを保存したいときは、Listをクラスのフィールドに持たせます。配列やListそのものをToJsonへ渡すのではなく、一覧を持つクラスを渡すのがポイントです。

以下はInventoryJsonExample.csとして置ける例です。必要な処理からInventoryJsonExample.CreateJson()を呼ぶと、Itemsを含むJSON文字列が返ります。

using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class InventoryData
{
    public List<string> Items;
}

public static class InventoryJsonExample
{
    public static string CreateJson()
    {
        InventoryData inventory = new InventoryData
        {
            Items = new List<string> { "Potion", "Key" }
        };
        return JsonUtility.ToJson(inventory, true);
    }
}

読み込む場合は、このJSON文字列をJsonUtility.FromJson<InventoryData>へ渡します。配列の場合も、同じように配列フィールドを持つクラスを用意してください。

Dictionaryやゲームオブジェクトはそのまま保存できる?

DictionaryはJsonUtilityの標準の対象外です。キーと値を持つ保存用クラスのListへ変換するなど、データの形をそろえる必要があります。汎用的なJSONを扱う場合は、用途に合った別のJSONライブラリーも選択肢になります。

また、GameObjectなどUnityEngine.Objectの参照をJSONに含めても、ゲームを再起動してシーンを丸ごと復元できるわけではありません。アイテムID・位置・所持数など、再構築に必要な値を保存する形にしましょう。ToJsonの参照の扱いはUnity公式のToJsonの説明も確認してください。

Dictionaryなどの保存をアセットで実装したい場合は、有料のEasy Save 3の使い方も参考にしてください。

うまく保存・読み込みできないときの確認点

JSONが空・項目が出てこない

  • 保存用クラスに[System.Serializable]が付いているか
  • 対象がフィールドで、対応する型になっているか
  • privateフィールドには[SerializeField]が付いているか
  • 配列やListを直接渡さず、クラスのフィールドにしているか

保存したはずのデータが初期値に戻る

  • StartやAwakeで、毎回初期値を保存していないか
  • 保存時と読み込み時のファイル名・保存場所が一致しているか
  • Consoleに保存エラーや読み込みエラーが出ていないか
  • Player Settingsや対象プラットフォームの変更で保存先が変わっていないか

項目を追加したら古いデータはどうなる?

JSONに項目がなくても、必ずエラーになるわけではありません。Unity 6.0の現行FromJsonの公式説明では、フィールド初期化子や既定コンストラクターで設定された値が、欠けた項目に維持されるとされています。
今回のPlayerDataには初期化子を置かず、初期データ生成と読み込み後の確認を分けています。たとえばScoreが欠けると0になり、このサンプルの範囲チェックを通るため、全項目の存在確認にはなりません。

公開後に保存項目を変更する場合は、Versionごとの変換処理を用意し、古いセーブデータでも確認しましょう。今回のVersionチェックは、不一致を検出するだけで自動移行は行いません。

エディターでは動くのにビルドでエラーになる

実行時の保存スクリプトに、不要なusing UnityEditorなどが入っていないか確認してください。今回の例にはUnityEditorやInput Systemへの依存はありません。
MonoBehaviourやScriptableObjectへJSONを適用する場合は、通常クラスと同じFromJsonではなく、既存インスタンスに対するFromJsonOverwriteを使う点にも注意してください。

まとめ

  • JsonUtilityはJSONへの変換・復元を行う。ファイル保存にはFileクラスなどを組み合わせる
  • ToJsonのprettyPrintはtrueで読みやすく整形される
  • 起動時の初期値保存を避け、読み込み失敗時に元データを上書きしない
  • 配列・Listはクラスで包み、Dictionaryなどの型の制約も確認する
  • Base64はエンコード。AESによる暗号化には鍵管理や改ざん検出の設計も必要

まずは名前・レベル・スコアのような小さなデータで、保存してから再起動後に読み込めるところまで確認してみてください。基本の流れがわかれば、アイテム一覧や進行状況へ少しずつ広げていけます。