Unityの開発において、設定データやマスターテーブル、アセット参照リストなどを管理する上でScriptableObjectは欠かせない機能です。しかし、独自の「EditorWindow(エディタウィンドウ)」や「Custom Editor(カスタムインスペクター)」、またはエディタメニューから実行するバッチ処理用C#スクリプトなどからScriptableObjectのデータを書き換えた際、以下のようなトラブルに直面したことはないでしょうか。
- 「インスペクター上では値が変わっているのに、Unityを再起動したら元の値に戻っていた」
- 「再生ボタン(Play Mode)を押した瞬間に、値がすべて空になってリセットされた」
- 「エディタ上では正しく動いているのに、ビルドしたゲーム実機では変更が一切反映されていない」
本記事では、このエディタ開発初心者が必ず直面する「データ保存されない問題」の内部メカニズムと、EditorUtility.SetDirty、Undo.RecordObject、そしてAssetDatabase.SaveAssetsの正しい使い分けと実装コードを解説します。
1. 根本原因:メモリ上とディスク上の「同期ズレ」
この問題が発生する最大の理由は、「Unityエディタはメモリ上でのアセットの書き換えと、ディスク(.assetファイル)への書き出しを同期していない」という仕様にあります。
Unityエディタ内のアセット(ScriptableObjectやPrefab、Materialなど)は、Unity起動時にディスクから読み込まれ、メモリ上のC#オブジェクトとして展開されます。C#スクリプトやエディタ拡張からフィールドの値を変更すると、メモリ上の値は書き換わります。そのため、インスペクターの表示も新しい値に更新されます。
しかし、Unityはディスクアクセスの負荷を最小限に抑えるため、メモリ上で変更されたアセットを自動的にはディスクへ書き戻しません。
この状態で以下のようなイベントが発生すると、メモリ上の「未保存のデータ」が破棄され、ディスク上の古いデータで上書き(初期化)されます。
- 再生モードへの移行: ドメインリロード(Domain Reload)が走り、C#アセンブリが再読み込みされ、メモリ上のオブジェクトがシリアライズされたディスクのデータから再構築される。
- エディタの再起動: メモリが解放され、次回起動時にディスク(.asset)からロードされる。
- プロジェクトのビルド: Unityはディスク上のアセットファイルをそのままパッケージングするため、メモリ上の最新データは無視される。
このトラブルを防ぐためには、Unityエディタに対して「このアセットは変更された(ダーティになった)ので、保存対象としてマークしてください」と明示的に通知し、ディスクにシリアライズさせる必要があります。
2. 解決の手順:3つの保存プロセスとC#コードでの書き方
データを確実に保存するには、以下の3つの役割を果たすAPIを組み合わせて使用します。
| API名 | 主な役割 | 使用すべきタイミング |
|---|---|---|
Undo.RecordObject |
変更前の状態を記録し、Ctrl+Zによる元に戻す(Undo)機能に対応させると同時に、アセットの変更追跡を開始する。 | インスペクターやEditorWindowなど、ユーザーがエディタ上で対話的に値を操作する場合。 |
EditorUtility.SetDirty |
アセットが「変更された(Dirtyになった)」ことをエディタに伝える。これにより、Unity終了時やシーン保存時に自動で保存対象になる。 | あらゆるアセットの変更時(Undoを使用しない自動処理スクリプトでも必須)。 |
AssetDatabase.SaveAssets |
ダーティ状態としてマークされたすべての変更アセットを、即座にディスク(.asset)へ書き出す。 | インポート処理、ビルド前処理、コマンドラインからのバッチ処理など、エディタのUI自動更新に頼らないスクリプト。 |
パターンA:自作EditorWindowやGUIから手動で変更する場合(推奨)
人間がEditorWindowやカスタムインスペクターを操作して値を変更する場合は、必ず値の変更前に Undo.RecordObject を呼び、変更後に EditorUtility.SetDirty を呼びます。
using UnityEngine;
using UnityEditor;
public class MyDataEditorWindow : EditorWindow
{
public MyGameConfig config; // 対象のScriptableObject
[MenuItem("Window/Config Editor")]
public static void ShowWindow()
{
GetWindow<MyDataEditorWindow>("Config Editor");
}
void OnGUI()
{
if (config == null)
{
config = EditorGUILayout.ObjectField("Config", config, typeof(MyGameConfig), false) as MyGameConfig;
return;
}
// 1. 変更前の値をエディタのUndoシステムに記録(第一引数に対象オブジェクト、第二引数に操作名)
// ※このメソッドを呼ぶことで、自動的にそのオブジェクトはシリアライズマークされます
EditorGUI.BeginChangeCheck();
string newName = EditorGUILayout.TextField("Player Name", config.playerName);
int newHp = EditorGUILayout.IntField("Max HP", config.maxHp);
if (EditorGUI.EndChangeCheck())
{
// 2. 変更が発生した場合、実際に値を反映する前に元に戻せるよう記録
Undo.RecordObject(config, "Modify Game Config");
// 値の反映
config.playerName = newName;
config.maxHp = newHp;
// 3. エディタに変更があったことをマーク(ディスク書き出しキューへ追加)
EditorUtility.SetDirty(config);
Debug.Log("Config updated and marked dirty.");
}
}
}
Undo.RecordObject が必要なのか?単に
SetDirty を呼ぶだけでは、値の変更は保存されますが、Ctrl+Zで元に戻せなくなります。また、Undo.RecordObject は内部で「現在のオブジェクトが変更予定である」ことをトラッキング対象にするため、メモリからのシリアライズ漏れを防ぐ二重の安全策になります。
パターンB:ビルド前処理やインポート時などのバッチスクリプトから自動変更する場合
対話的な操作(Undo)が不要な自動ビルドスクリプトやバッチスクリプトでは、Undo.RecordObject は不要です。しかし、処理後に即座にディスクに反映しなければならないため、AssetDatabase.SaveAssets を明示的に呼ぶ必要があります。
using UnityEngine;
using UnityEditor;
public class BuildAutomation
{
[MenuItem("Tools/Auto Update Config Before Build")]
public static void UpdateConfig()
{
// アセットパスからScriptableObjectをロード
MyGameConfig config = AssetDatabase.LoadAssetAtPath<MyGameConfig>("Assets/Data/GameConfig.asset");
if (config != null)
{
// 1. 直接値を代入
config.buildVersion = "1.2.3b";
config.isReleaseBuild = true;
// 2. ダーティマークを付与(エディタに変更を通知)
EditorUtility.SetDirty(config);
// 3. ダーティマークされたすべてのアセットを即座にディスク(.asset)へ書き出し
AssetDatabase.SaveAssets();
Debug.Log("GameConfig saved successfully for build.");
}
}
}
3. カスタムインスペクター(Editor)における「最もスマートな書き方」
MonoBehaviourやScriptableObjectのInspector画面をカスタマイズする(CustomEditor)場合、最も安全でスマートなのはSerializedObjectとSerializedPropertyを使用する方法です。
この手法を使うと、C#オブジェクトのフィールドへ直接値を代入するのではなく、Unityのシリアライズされたプロパティを経由して値を書き換えます。これにより、Undo.RecordObject も EditorUtility.SetDirty も自動的に裏で実行されるため、呼び忘れによるデータ消失が100%防げます。
using UnityEngine;
using UnityEditor;
[CustomEditor(typeof(MyGameConfig))]
public class MyGameConfigEditor : Editor
{
SerializedProperty playerNameProp;
SerializedProperty maxHpProp;
void OnEnable()
{
// インスペクター描画開始時にプロパティを取得
playerNameProp = serializedObject.FindProperty("playerName");
maxHpProp = serializedObject.FindProperty("maxHp");
}
public override void OnInspectorGUI()
{
// 1. 最新のシリアライズデータをメモリ上にロードして同期
serializedObject.Update();
// 2. プロパティに応じたGUIフィールドを描画
EditorGUILayout.PropertyField(playerNameProp);
EditorGUILayout.PropertyField(maxHpProp);
// 3. GUIで変更があった場合、自動的にUndo登録・ダーティマークを付けてアセットを更新
serializedObject.ApplyModifiedProperties();
}
}
この serializedObject.ApplyModifiedProperties() を用いたアプローチは、リスト(Array)の要素数変更や複雑な入れ子構造(Serializableクラス)を安全に書き換える際にも最も堅牢な選択肢となります。原則として、インスペクター拡張ではこの方法を採用しましょう。