【Unity】JsonUtilityで配列とリストを処理する

Unity の JsonUtility の話です。Unity では JSON をオブジェクトに変換(デシリアライズ)したり、オブジェクトを JSON に変換(シリアライズ)するための JsonUtility というライブラリがあります。

この JsonUtility というライブラリ動作が非常に高速でヒープの使用も少ないため割と良い感じのライブラリです。しかし各種制限(*1)のうちのひとつに 配列をルートにできない問題があります。この記事ではこの問題に対処して JSON 配列 ⇔ オブジェクト配列を相互に変換できる実装を紹介したいと思います(とはいえ、この問題、かなり以前から広く知られている問題のため解決策の実装はネットで色々参照できます。そのため今更な感じですが自分はこう実装するという意味合いで紹介したいと思います)

*1) 配列がルートにできないほかに、Dictionary<Tkey, TValue>が変換不可、List<T>以外のコレクションの型に対応していない、null許容型が未サポート、変換に失敗するとコンストラクタが走らない謎オブジェクトが設定される場合がある、プロパティは変換できない、一部変換できない型がある、等です。

確認環境

  • Unity2020.2f1
  • VisualStudio2019

エラーになる状況

では、まず問題の確認をします。変換できない状況の確認です。

JSON配列 → C#の配列に変換できない

まず JSON 配列からオブジェクト配列への変換です。配列を指定すると例外が起きて変換できません。

// JSON 配列は変換できない
[
    {
        "id": 123,
        "project_id": 234
    },
    {
        "id": 456,
        "project_id": 567
    },
    ...
]

Foo[] array = JsonUtility.FromJson<Foo[]>(json);
// 例外が発生して変換できない
// > ArgumentException: JSON must represent an object type.

C#の配列 → JSON配列に変換できない

次に、C# の配列を JSON 配列に変換した場合、string の結果が空の JSON となり例外は起きませんが期待した結果にはなりません。

// オブジェクト配列は変換できない

string[] array = new string[] { "aaa", "bbb", "ccc" };
string result = JsonUtility.ToJson(array);
// できない "{}" という空 JSON になる

相互変換できるようにする

以上の事から上記の動作を回避するために自作のクラスを作成します。

ルート要素さえあれば処理できるため変換時にダミー用ををルートに付与することで変換を可能にしていきます。

使用するクラスとデータ

前提として取り扱うデータとクラスは以下のように定義しています。

// 配列の要素に使用するクラス
[Serializable]
public class Sample
{
    public int id;
    public string project_id;
}

// 処理対象のJSON(ルート配列のデータ)
[
    {
        "id": 123,
        "project_id": "aaa"
    },
    {
        "id": 456,
        "project_id": "bbb"
    },
    {
        "id": 789,
        "project_id": "ccc"
    }
]

JsonHelperクラス

JsonUtility をラップする JsonHelper の実装です。

コード内のコメントの通りですが変換時にダミー要素を追加することで変換できるようにしつつ、利用者側にはそのことを意識させないようになっています。

/// <summary>
/// <see cref="JsonUtility"/> に不足している機能を提供します。
/// </summary>
public static class JsonHelper
{
    /// <summary>
    /// 指定した string を Root オブジェクトを持たない JSON 配列と仮定してデシリアライズします。
    /// </summary>
    public static T[] FromJson<T>(string json)
    {
        // ルート要素があれば変換できるので
        // 入力されたJSONに対して(★)の行を追加する
        //
        // e.g.
        // ★ {
        // ★     "array":
        //        [
        //            ...
        //        ]
        // ★ }
        //
        string dummy_json = $"{{\"{DummyNode<T>.ROOT_NAME}\": {json}}}";

        // ダミーのルートにデシリアライズしてから中身の配列を返す
        var obj = JsonUtility.FromJson<DummyNode<T>>(dummy_json);
        return obj.array;
    }

    /// <summary>
    /// 指定した配列やリストなどのコレクションを Root オブジェクトを持たない JSON 配列に変換します。
    /// </summary>
    /// <remarks>
    /// 'prettyPrint' には非対応。整形したかったら別途変換して。
    /// </remarks>
    public static string ToJson<T>(IEnumerable<T> collection)
    {
        string json = JsonUtility.ToJson(new DummyNode<T>(collection)); // ダミールートごとシリアル化する
        int start = DummyNode<T>.ROOT_NAME.Length + 4;
        int len = json.Length - start - 1;
        return json.Substring(start, len); // 追加ルートの文字を取り除いて返す
    }

    // 内部で使用するダミーのルート要素
    [Serializable]
    private struct DummyNode<T>
    {
        // 補足:
        // 処理中に一時使用する非公開クラスのため多少設計が変でも気にしない

        // JSONに付与するダミールートの名称
        public const string ROOT_NAME = nameof(array);
        // 疑似的な子要素
        public T[] array;
        // コレクション要素を指定してオブジェクトを作成する
        public DummyNode(IEnumerable<T> collection) => this.array = collection.ToArray();
    }
}

利用方法

上記クラスの利用方法は以下の通り。オブジェクト → JSON への変換は配列とリストや Linq の結果などのリスト構造の要素が指定できます。

// JSON配列の読み取り
string json = File.ReadAllText(@"d:\sample1.json");

// JSON配列 → C#配列への変換
Sample[] array = JsonHelper.FromJson<Sample>(json);

// C#配列 → JSON配列への変換
string json2 = JsonHelper.ToJson(array);

// ★C#のリスト → JSON配列への変換
var list = new List<Sample>(array);
string json3 = JsonHelper.ToJson(list);

// ★Linqの結果 → JSON配列への変換
var items = list.Where(s => s.id > 200);
string json4 = JsonHelper.ToJson(items);

おまけ

拡張メソッドで利用する

完全に余談ですが、stringのインスタンスや配列のインスタンスを指定してJSONの変換ができるように以下のような拡張メソッドを定義します。

// JsonUtilityExtension.cs

/// <summary>
/// JSON ⇔ オブジェクト間の変換へのショートカットを提供します。
/// </summary>
public static class JsonUtilityExtension
{
    /// <summary>
    /// <see cref="string"/> を JSON 形式の文字列と仮定し {T} 型への変換を試行します。
    /// </summary>
    /// <remarks>
    /// 内部実装に <see cref="JsonUtility.ToJson(object)"/> を使用しているので
    /// 変換可能な規則は Unity の <see cref="UnityEngine.JsonUtility"/> に準拠します。
    /// </remarks>
    public static T FromJson<T>(this string self) => JsonUtility.FromJson<T>(self);

    /// <summary>
    /// <see cref="string"/> を Root 要素を持たない JSON 配列形式の文字列と仮定し T[] 型への変換を試行します。
    /// </summary>
    public static T[] FromJsonArray<T>(this string self) => JsonHelper.FromJson<T>(self);

    /// <summary>
    /// 指定したオブジェクトを JSON 文字列に変換します。
    /// </summary>
    /// <remarks>
    /// 内部実装に <see cref="JsonUtility.ToJson(object)"/> を使用しているので
    /// 変換可能な規則は Unity の <see cref="UnityEngine.JsonUtility"/> に準拠します。
    /// </remarks>
    public static string FromJson<T>(this object self) => JsonUtility.ToJson(self);

    /// <summary>
    /// 指定したオブジェクトを Root 要素を持たない JSON 配列に変換します。
    /// </summary>
    public static string FromJsonArray<T>(this IEnumerable<T> self) => JsonHelper.ToJson(self);
}

そうすると以下のようにインスタンスを指定して変換できるようになります。

// JSON配列 → C#配列への変換
Sample[] array = json.FromJsonArray<Sample>();

// C#配列 → JSON配列への変換
string json2 = array.ToJsonArray(); // string のインスタンスからメソッドが呼べる

// C#のリスト → JSON配列への変換
var list = new List<Sample>(array);
string json3 = list.ToJsonArray(); // List<T> のインスタンスからメソッドが呼べる

// Linqの結果 → JSON配列への変換
var items = list.Where(s => s.id > 200);
string json4 = items.ToJsonArray(); // IEnumerable<T> のインスタンスからメソッドが呼べる

どんな変数値でも変換用のメソッドが表示されるのでちょっと微妙かもしれませんがこれはこれで使えるかもしれません。