Unityで独自の設定のUIを提供できるSettingsProviderの紹介と設定ファイルの保存について

# はじめに
本記事は、[QualiArts Advent Calendar 2021](https://qiita.com/advent-calendar/2021/qualiarts) 12/15の記事です。

社内基盤の開発などでUnityのパッケージやライブラリを作成するとき、利用者側が設定でカスタマイズできる機能を提供したい場面がよくあります。
そんなときに悩むのが、設定をどのようなUIにすれば良いのか（特に、設定をどのように開けるようにしたら良いのか）ということと、設定ファイルの保存や管理はどのようにしたら良いのかという点です。

実現方法は色々考えられると思いますが、いくつかのUnity標準のパッケージの実装を参考にしつつ、筆者が良さそうと考えている方法を紹介します。

# SettingsProvider
設定のUIを考えるときにまず重視するべきことは、Unityに既に備わっている設定と同じ場所に置くということです。
既存の設定と同じ場所に置いてあれば、Unityのユーザーが戸惑わずにスムーズに設定を見つけることができるからです。
（※本記事での**ユーザー**はゲームのプレイヤーではなくUnity開発者です。）

SettingsProviderという機能は、そんなときにうってつけの機能です。

SettingsProviderはUnity 2018.3から提供されており、UnityのPreferencesやProject Settingsに独自の項目を追加できる機能です。

Preferencesは、プロジェクトをまたいだ設定項目が集まっています。
<img width=800 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/52971d21-43b7-193d-d668-1005f7a18a73.png>

Project Settingsは、プロジェクト固有の設定項目が集まっています。
<img width=800 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/42743b20-a1d1-346f-1849-fd284f1e3efb.png>

独自で設定項目を追加するときもUnityのルールに従って、Preferencesではプロジェクト横断、Project Settingsではプロジェクト固有の設定ができるようにするべきです。

## SettingsProviderの使い方

それではSettingsProviderの使い方を示します。まずPreferencesに項目を追加する例です。
そのために、以下のようなコードを記述します。

```cs:PreferencesProvider.cs
using System.Collections.Generic;
using UnityEditor;

public class PreferencesProvider : SettingsProvider
{
    /// <summary>
    /// 設定のパス
    /// Preferencesに追加する場合は、第一階層は「Preferences」にするべきです
    /// 違うものにしたらどうなるかは試してみてください
    /// </summary>
    private const string SettingPath = "Preferences/My Preferences";

    /// <summary>
    /// このメソッドが重要です
    /// 独自のSettingsProviderを返すことで、設定項目を追加します
    /// </summary>
    [SettingsProvider]
    public static SettingsProvider CreateSettingProvider()
    {
        // SettingsProviderクラスを直接使う方法もありますが、
        // 今回は独自クラスPreferencesProviderとして継承して利用しています
        // コンストラクタの第二引数のscopesは、項目をPreferencesと
        // Project Settingsのどちらに追加するかの指定です
        // 第三引数のkeywordsは、検索時にこの設定項目を引っかけるためのキーワードですが、
        // 今回はnullを渡しています
        return new PreferencesProvider(SettingPath, SettingsScope.User, null);
    }

    public PreferenceProvider(string path, SettingsScope scopes, IEnumerable<string> keywords) : base(path, scopes, keywords)
    {
    }

    public override void OnGUI(string searchContext)
    {
        // ここに設定の中身のUIを記述します
        EditorGUILayout.LabelField("テストだよ");
    }
}
```

上記のファイルをEditorフォルダ配下に置くことで、↓の画像のようにPreferencesウィンドウに「My Preference」が追加されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/06c86908-da87-375b-a32d-07474a039c75.png)

コードの詳細については詳しめにコメントを入れているのでそちらを参考にしてください。
SettingsProviderという属性が付いたメソッドを定義し、独自にカスタマイズしたSettingsProviderのインスタンスをそのメソッドで返すことによって、Preferencesに独自の設定を追加しています。

### Project Settingsへの追加

Project Settingsへの追加は、Preferencesへの追加のときとほとんど同じです。
変えるのは、パスの第一階層とSettingsScopeのみです。

```cs:ProjectSettingsProvider.cs
using System.Collections.Generic;
using UnityEditor;

public class ProjectSettingsProvider : SettingsProvider
{
    // 第一階層をProjectにします
    private const string SettingPath = "Project/My Project Settings";

    [SettingsProvider]
    public static SettingsProvider CreateProvider()
    {
        // SettingsScopeをProjectにします
        return new ProjectSettingsProvider(SettingPath, SettingsScope.Project, null);
    }

    public ProjectSettingsProvider(string path, SettingsScope scopes, IEnumerable<string> keywords) : base(path, scopes, keywords)
    {
    }

    public override void OnGUI(string searchContext)
    {
        EditorGUILayout.LabelField("テストだよ");
    }
}
```

このファイルをEditorフォルダ配下に置くことで、↓の画像のようにProject Settingsウィンドウに「My Project Settings」が追加されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/eb6d810a-f49b-b718-2917-801ad336cecf.png)

以上で、設定のUIをどこで開けるようにしたら良いかという問題が解決しました。
設定のUIの中身に関しては、SettingsProviderのOnGUIの中で、通常のインスペクター拡張のように記述していくことになります。

# 設定ファイルの置き場所と設定UIの表示
続いて、設定ファイルの置き場所と、その設定をどのように先述のSettingsProviderによるUIの中に表示するかについて考えていきます。

ファイルの置き場所は、設定の影響範囲や利用範囲をどうしたいかによって変わりますが、本記事では以下の３つのパターンのみを扱うことにします。

1. プロジェクト横断でエディタ専用
+ プロジェクト固有でエディタ専用
+ プロジェクト固有でランタイムでも使う

1.はユーザーごとに設定が異なりますが、2.と3.はそのプロジェクト内のユーザーは設定を共有するとします。
SettingsProviderで設定のUIを追加する場所は、1.はプロジェクト横断なのでPreferences、2.と3.はプロジェクト固有なのでProject Settingsが適切となります。

それぞれの場合について解説していきます。

## 1. プロジェクト横断でエディタ専用
まずパターン1.のプロジェクト横断でエディタ専用の場合ですが、これにはScriptableSingletonとFilePath属性の組み合わせが適しています。
先に言ってしまうと、パターン2.のプロジェクト固有でエディタ専用の場合もこの手法を用います。

ScriptableSingletonは、ScriptableObjectのインスタンスでありつつ、シングルトンのようにただ１つしか存在しない前提のものを作りたいときに使われるクラスです。
ScriptableSingletonを継承して設定クラスを定義することで、どこからでも設定にアクセスできるようになります。
またScriptableSingletonを継承したクラスにFilePath属性をつけてパスを指定することで、インスタンスをそのパスにファイルとして保存できるようになります。

例えば以下のように設定クラスを実装します。

```cs:MyPreferences.cs
using UnityEditor;

[FilePath("MyPreferences.asset", FilePathAttribute.Location.PreferencesFolder)]
public class MyPreferences : ScriptableSingleton<MyPreferences>
{
    public bool flag;
    public string text;

    public void Save()
    {
        Save(true);
    }
}
```

MyPreferencesクラスはScriptableSingletonを継承して定義されており、中にflagとtextというフィールドを持ちます。
このflagやtextが、ユーザーが設定可能な項目だと考えてください。

クラスに付けられたFilePath属性によって、設定ファイルの名前を `MyPreferences.asset` とすることと、これをPC上のPreferencesフォルダに入れることを示しています。
Preferencesフォルダはプロジェクト横断で利用可能な場所で、OS毎に異なります（[公式ドキュメント](https://docs.unity3d.com/2020.3/Documentation/ScriptReference/FilePathAttribute.Location.PreferencesFolder.html)）。

Saveは設定を保存するメソッドです。
元々ScriptableSingletonにはboolを1つ引数に取るSaveメソッドが定義されていますが、protectedであるため、使い勝手を良くするために無引数のSaveメソッドをpublicで公開しています。
ちなみに引数のboolはテキスト形式として保存するかどうかです（falseならバイナリ形式、のはず）。

MyPreferencesのインスタンスへのアクセスは `MyPreferences.instance` で可能なので、設定を利用したい各所から簡単にアクセス可能です。

### SettingsProviderとの統合
続いてSettingsProviderによる設定UIとの統合を行います。

PreferencesProviderクラスの変更分を以下に示します。

```cs:PreferencesProvider.cs
public class PreferencesProvider : SettingsProvider
{
    // 略
    private Editor _editor;
    // 略

    public override void OnActivate(string searchContext, VisualElement rootElement)
    {
        var preferences = MyPreferences.instance;
        preferences.hideFlags = HideFlags.HideAndDontSave & ~HideFlags.NotEditable; // ScriptableSingletonを編集可能にする（本文で補足）
        // 設定ファイルの標準のインスペクターのエディタを生成
        Editor.CreateCachedEditor(preferences, null, ref _editor);
    }

    public override void OnGUI(string searchContext)
    {
        EditorGUI.BeginChangeCheck();
        // 設定ファイルの標準のインスペクターを表示
        _editor.OnInspectorGUI();
        if (EditorGUI.EndChangeCheck())
        {
            // 差分があったら保存
            MyPreferences.instance.Save();
        }
    }
}
```

OnGUIの中で記述する、設定のためのUIの構築は好きな方法で問題ありません。
ここではCreateCachedEditorメソッドを使うことで、MyPreferencesの標準のインスペクターをそのまま表示しています。

これにより、↓の画像のようにflagとtextを設定できるUIを構築することができました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/cf295a1e-7b1e-4609-6f87-faa561f0030c.png)

ここでhideFlagsについて補足しておきます。
ScriptableSingletonは、インスタンスのhideFlagsを自動的にHideFlags.HideAndDontSaveにセットします。
HideAndDontSaveにはNotEditableというフラグも含まれているのですが、これが含まれているとデフォルトのインスペクターやPropertyFieldを使ったときに入力ができない状態になってしまいます（下図）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/052d7bd4-d17b-1bca-6b6f-a6839421ac4f.png)

これを防ぐため、HideAndDontSaveからNotEditableを抜いたものをhideFlagsにしています。
（そもそもHideAndDontSaveが自動的にセットされる意図はよく分かっていません…）

以上で、「1.プロジェクト横断でエディタ専用」の場合の説明は終了です。

## 2. プロジェクト固有でエディタ専用

続いてパターン2.のプロジェクト固有でエディタ専用の場合についてです。
先述したとおり、この場合もパターン1.と同様にScriptableSingletonとFilePath属性を使います。
そのためパターン1.の場合とほとんど同じ対応になります。

まず設定用のクラスを実装します。

```cs:MyProjectEditorSettings.cs
using UnityEditor;

[FilePath("ProjectSettings/MyProjectEditorSettings.asset", FilePathAttribute.Location.ProjectFolder)]
public class MyProjectEditorSettings : ScriptableSingleton<MyProjectEditorSettings>
{
    public bool flag;
    public string text;

    public void Save()
    {
        Save(true);
    }
}
```

エディタ用であることを明示するため、クラス名にEditorを入れています。
またFilePath属性の指定ですが、パスは `ProjectSettings/MyProjectEditorSettings.asset`、場所はProjectフォルダとしています。
これによって、Unityプロジェクトのフォルダ直下にあるProjectSettingsフォルダの下にMyProjectEditorSettings.assetという名前で設定ファイルが保存されます。
このパターンではProjectSettingsフォルダに配置するのがお決まりのようですので、それに従っておくのが無難です。

### SettingsProviderとの統合

続いてSettingsProviderとの統合です。

先程実装したProject Settings用のSettingsProviderはProjectSettingsProviderという名前のクラスでしたが、
エディタ専用であることを強調するため、ProjectEditorSettingsProviderという名前にします。
（後のパターン3.のSettingsProviderに、ProjectSettingsProviderという名前を使用します。）

また、設定のパスは元々 `Project/My Project Settings` としていましたが、さらに階層を掘って `Project/My Project Settings/Editor` とします。このように設定のパスは自由に階層を深ぼることができます。

コードに関しては、ほとんどがパターン1.と同じため省略します。

これによって以下のようにProject Settingsの中に設定のためのUIが表示されるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/4151b167-252b-4524-ab4a-5569d8ba2d8e.png)

保存された設定ファイルの中身を見に行くと、以下のようになっています。

```cs:MyProjectEditorSettings.asset
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &1
MonoBehaviour:
  m_ObjectHideFlags: 53
  m_CorrespondingSourceObject: {fileID: 0}
  m_PrefabInstance: {fileID: 0}
  m_PrefabAsset: {fileID: 0}
  m_GameObject: {fileID: 0}
  m_Enabled: 1
  m_EditorHideFlags: 0
  m_Script: {fileID: 11500000, guid: dbe9c2ec187f43f6ad51347e0228bb1f, type: 3}
  m_Name: 
  m_EditorClassIdentifier: 
  flag: 1
  text: hoge
```

この場合、flagが1すなわちtrue、textがhogeとして、設定が保存されていることが分かります。
このファイルはチームで共有したいため、Gitへのコミットなどを行う必要があります。

## 3. プロジェクト固有でランタイムでも使う
最後にパターン3.のプロジェクト固有でランタイムでも使う場合です。

このパターンでは、残念ながらScriptableSingletonは使えません。
そもそもScriptableSingletonはUnityEditorネームスペースの機能であるためです。

そこで、ランタイムでも動くScriptableSingletonと似たような機能を自作することになります。

まず設定ファイルの保存場所ですが、これはランタイムでもロードする必要があることを考えてResourcesフォルダの中がいいでしょう。
さらに話を簡単にするため、Resourcesフォルダ直下にクラス名と同じ名前で置くことにします。

これを踏まえて、設定ファイルの実装は以下のようになります。

```cs:MyProjectSettings.cs
using UnityEngine;

public class MyProjectSettings : ScriptableObject
{
    private static MyProjectSettings _instance;

    public static MyProjectSettings Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = Resources.Load<MyProjectSettings>(nameof(MyProjectSettings));
            }

            return _instance;
        }
    }

    public bool flag;
    public string text;
}
```

Instanceにアクセスしたとき、ロード済みでなければロードを試みます。

今回の実装では、ロードに失敗しても新たに生成するようなことはせず諦めることにしました。
もしかすると、そのような場合は勝手にResources配下に生成してしまえば良いという考え方もあるかもしれませんし、ScriptableSingletonのように新しいインスタンスを生成してしまったほうが良いという考え方もあるかもしれません。

また、ファイルの保存処理に関してはSettingsProvider側に任せることにします。

### SettingsProviderとの統合
それではSettingsProviderとの統合を行います。

ProjectSettingsProviderクラスへの追加分だけを示します。

```cs:ProjectSettingsProvider.cs
public class ProjectSettingsProvider : SettingsProvider
{
    // 略

    public override void OnActivate(string searchContext, VisualElement rootElement)
    {
        Editor.CreateCachedEditor(MyProjectSettings.Instance, null, ref _editor);
    }

    public override void OnGUI(string searchContext)
    {
        var instance = MyProjectSettings.Instance;
        if (instance == null)
        {
            if (GUILayout.Button("生成する"))
            {
                CreateSettings();
                Editor.CreateCachedEditor(MyProjectSettings.Instance, null, ref _editor);
            }

            return;
        }

        _editor.OnInspectorGUI();
    }

    /// <summary>
    /// 設定ファイル生成
    /// </summary>
    private static void CreateSettings()
    {
        var config = ScriptableObject.CreateInstance<MyProjectSettings>();
        var parent = "Assets/Resources";
        if (AssetDatabase.IsValidFolder(parent) == false)
        {
            // Resourcesフォルダが無いことを考慮
            AssetDatabase.CreateFolder("Assets", "Resources");
        }

        var assetPath = Path.Combine(parent, Path.ChangeExtension(nameof(MyProjectSettings), ".asset"));
        AssetDatabase.CreateAsset(config, assetPath);
    }
{
```

OnGUIで、インスタンスが無い=設定ファイルがまだ生成されていなければ、ボタンを出して生成をユーザーに促します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/63cbe753-edae-23fe-a19f-4dc574018879.png)

ボタンがクリックされたら設定ファイルをResourcesフォルダ配下に生成します。
設定ファイルがあれば、デフォルトのインスペクターを描画します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/42f2fe52-fac5-8527-db18-bdb94c56d59b.png)

設定ファイルはResourcesフォルダ配下に生成されるため、Projectウィンドウで確認できます。
一度生成された後、Resourcesフォルダごと別のフォルダの下に移動しても問題ありません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/7d6c3319-cd25-d76e-8990-ae5335d309ef.png)

以上でパターン3.の実現方法の説明は終了です。

ランタイムで動くコードからも `MyProjectSettings.Instance` で簡単に設定にアクセスすることができます。

# おわりに

本記事では、Unityのパッケージやライブラリ作成時に直面する設定の実装方法を紹介しました。
本記事で紹介した方法よりもっと良い方法があるなど、ご意見ご感想あればぜひコメント頂ければと思います。