第13章 さまざまなイベントのコールバック - エディター拡張入門

第13章 さまざまなイベントのコールバック

Unity では、「ビルド後」「コンパイル後」「プラットフォームの変更後」など、特定のアクションの後に呼び出されるさまざまなコールバックが存在します。これらのコールバックの中には、使用することで作業の自動化が行える「知らなければ損」なコールバックもあります。

13.1 PostProcessBuildAttribute

Build Settings ウィンドウや BuildPipeline.BuildPlayer によって行われるアプリのビルド後に呼び出されるコールバックです。

ビルドで生成された、Xcode プロジェクトや Android Project などで、成果物をさらに加工したり、アプリを生成するまで自動化することも可能です。CI ツールと組み合わせたり、ゲーム開発において必須と言えるくらい重要なコールバックなのでぜひ覚えておきましょう。

次のサンプルでは Xcode Manipulation API*1 を使ってフレームワークを追加しています。

using UnityEditor;
using UnityEditor.Callbacks;
using System.IO;
using UnityEditor.iOS.Xcode;

public class NewBehaviourScript
{
    //callbackOrder で実行順を指定することが出来る
    //0 が内部で使われている order なので 1以上を指定する
    [PostProcessBuild(1)]
    static void OnPostProcessBuild (BuildTarget buildTarget, string path)
    {
        if (buildTarget != BuildTarget.iOS)
            return;

        //Xcode プロジェクトのパスを取得
        var xcodeprojPath = Path.Combine (path, "Unity-iPhone.xcodeproj");
        var pbxprojPath = Path.Combine (xcodeprojPath, "project.pbxproj");

        //Xcode プロジェクトロード
        PBXProject proj = new PBXProject ();
        proj.ReadFromFile (pbxprojPath);

        var target = proj.TargetGuidByName("Unity-iPhone");

        proj.AddFrameworkToProject (target, "Social.framework", false);
        proj.WriteToFile (pbxprojPath);
    }
}

13.2 PostProcessSceneAttribute

シーンの再生時やビルド時のシーン構築時などの「シーンをロードした後」に呼び出されるコールバックです。この属性を付けたメソッド内でゲームオブジェクトやプレハブから生成をすることで、「手動で Prefab をシーンに含める作業」以上の強制力を持たせることができます。

ゲームを再生すると自動的にゲームオブジェクトが生成される

図13.1: ゲームを再生すると自動的にゲームオブジェクトが生成される

using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.SceneManagement;

public class NewBehaviourScript
{
    [PostProcessScene]
    static void OnPostProcessScene ()
    {
        //現在のマルチシーン情報を取得
        foreach (var sceneSetup in EditorSceneManager.GetSceneManagerSetup()) {

            var scene = EditorSceneManager.GetSceneByPath (sceneSetup.path);
            var go = new GameObject ("OnPostProcessScene: " + scene.name);

            EditorSceneManager.MoveGameObjectToScene (go, scene);
        }
    }
}

ただし、ゲーム再生時とビルド時でシーンアセットのパスを取得する方法に問題が発生してしまいます。通常、現在開いているシーンのパスを取得するには EditorSceneManager.GetActiveSceneEditorSceneManager.GetSceneManagerSetup を使用してシーン情報を取得します。エディター上でゲームを再生するときは大丈夫なのですが、問題なのはビルド時です。ビルド時は 現在エディターで開いているシーンはバックアップされたシーンを使用します。

左がビルド時の Debug.Log。右がエディター上でゲーム再生時の Debug.Log

図13.2: 左がビルド時の Debug.Log。右がエディター上でゲーム再生時の Debug.Log

using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.SceneManagement;

public class NewBehaviourScript1
{
    [PostProcessScene]
    static void OnPostProcessScene ()
    {
        foreach (var sceneSetup in EditorSceneManager.GetSceneManagerSetup()) {
            Debug.Log (sceneSetup.path);
        }
    }
}

対策としては、ビルド前にビルド時に含まれるシーンファイルを開かないことです。新しいシーンを作成することで回避できます。*2

[*2] これは明らかにバグなので近い将来解決する可能性があります。

13.3 InitializeOnLoad

Unity エディターの起動時や、スクリプトのコンパイル直後にクラスの静的コンストラクタを呼び出すための属性です。コンパイル直後に毎回行いたい処理、例えば、(コンパイルごとにデリゲートに登録したものがリセットされる理由で)デリゲートの再登録を行う目的として使用されます。

注意したいのはエディター上でゲームを再生した直後にも InitializeOnLoad 属性の付いた静的コンストラクタが呼び出されてしまいます。

この対策としては、静的コンストラクタ内で EditorApplication.isPlayingOrWillChangePlaymode を使い、ゲーム再生時に呼び出されたものであるかを判断します。

using UnityEditor;
using UnityEngine;

[InitializeOnLoad]
public class NewBehaviourScript
{
    static NewBehaviourScript ()
    {
        if (EditorApplication.isPlayingOrWillChangePlaymode)
            return;

        Debug.Log ("call");
    }
}

また、Unity エディターを起動した直後のみ実行したい場合は、EditorApplication.timeSinceStartup を組み合わせて使用します。

using UnityEditor;
using UnityEngine;

[InitializeOnLoad]
public class NewBehaviourScript
{
    static NewBehaviourScript ()
    {
        //10秒以内であれば起動時と判断する
        if (10 < EditorApplication.timeSinceStartup)
            return;

        Debug.Log ("起動時に呼び出される");
    }
}

13.4 InitializeOnLoadMethod

InitializeOnLoad のメソッド版です。静的メソッドにこの属性を付加することで InitializeOnLoad と同じタイミング(正確には InitializeOnLoad が先に呼び出されます)で呼び出すことができます。

using UnityEditor;
using UnityEngine;

public class NewBehaviourScript
{
    [InitializeOnLoadMethod]
    static void RunMethod ()
    {
    }
}

ただし、InitializeOnLoadMethod の実行順を指定することはできないので注意してください。

13.5 DidReloadScripts

機能的には InitializeOnLoadMethod とほぼ同じです。ただ1点だけ違うところは実行順を選べる点です。引数として callbackOrder があり、昇順に実行できます。

using UnityEditor;
using UnityEngine;
using UnityEditor.Callbacks;

public class NewBehaviourScript
{

    [DidReloadScripts(0)]
    static void First ()
    {
        Debug.Log ("最初に処理する");
    }

    [DidReloadScripts(1)]
    static void Second ()
    {
        Debug.Log ("次に処理する");
    }
}

13.6 EditorUserBuildSettings.activeBuildTargetChanged

Build Settings ウィンドウや EditorUserBuildSettings.SwitchActiveBuildTarget によって、プラットフォームが変更された時に呼び出されるコールバックです。プラットフォームごとに異なる設定、例えば BundleIdentifier の変更や、StreamingAssets や Resources フォルダーの中身の変更などで活用できます。

using UnityEditor;
using UnityEngine;

public class NewBehaviourScript
{

    [InitializeOnLoadMethod]
    static void ChangeBundleIdentifier ()
    {
        //プラットフォームごとに bundleIdentifier を切り替える
        EditorUserBuildSettings.activeBuildTargetChanged += () => {

            var bundleIdentifier = "com.kyusyukeigo.superapp";

            switch (EditorUserBuildSettings.activeBuildTarget) {
                case BuildTarget.iOS:
                    bundleIdentifier += ".ios";
                    break;
                case BuildTarget.Android:
                    bundleIdentifier += ".android";
                    break;
                case BuildTarget.WSAPlayer:
                    bundleIdentifier += ".wp";
                    break;
                default:
                    break;
            }

            if (Debug.isDebugBuild)
                bundleIdentifier += ".dev";

            PlayerSettings.bundleIdentifier = bundleIdentifier;
        };
    }
}

13.7 EditorApplication.hierarchyWindowChanged と projectWindowChanged

選択しているオブジェクトを変更したり、新規でゲームオブジェクトを作成するなど、さまざまな要素によってヒエラルキーやプロジェクトの構成が変化した時に呼び出されます。

シーン内のゲームオブジェクトを対象に、なんらかの処理をしている場合に使用すると便利です。

下記コードは、シーン内のカメラをリストアップするコードです。

シーンビューの左上にウィンドウが追加された

図13.3: シーンビューの左上にウィンドウが追加された

using System.IO;
using UnityEditor;
using UnityEngine;
using System.Linq;

public class NewBehaviourScript
{

  [InitializeOnLoadMethod]
  static void DrawCameraNames()
  {

    var selected = 0;
    var displayNames = new string[0];
    var windowRect = new Rect(10, 20, 200, 24);

    //変更があれば初期化
    EditorApplication.hierarchyWindowChanged += () =>
    {
      var cameras = Object.FindObjectsOfType<Camera>();
      var cameraNames = new string[0];

      // マルチシーンであれば、どのシーンにあるカメラかを把握できるようにする
      if (1 < EditorSceneManager.loadedSceneCount)
      {
        //Main Camera (Stage 1.unity) という表示名にする
        cameraNames =
          cameras.Select(camera => new
          {
            cameraName = camera.name,
            sceneName = Path.GetFileName(AssetDatabase.GetAssetOrScenePath(camera))
          })
            .Select(x => string.Format("{0} ({1})", x.cameraName, x.sceneName))
            .ToArray();
      }
      else
        cameraNames = cameras.Select(c => c.name).ToArray();

      displayNames = new[] { "None", "" };
      ArrayUtility.AddRange(ref displayNames, cameraNames);
    };

    //任意のタイミングで呼び出すことも出来る
    EditorApplication.hierarchyWindowChanged();

    //全シーンビューの GUI デリゲート
    SceneView.onSceneGUIDelegate += (sceneView) =>
    {

      GUI.skin = EditorGUIUtility.GetBuiltinSkin(EditorSkin.Inspector);

      Handles.BeginGUI();

      int windowID =
        EditorGUIUtility.GetControlID(FocusType.Passive, windowRect);
      //シーンビューにウィンドウを追加
      windowRect = GUILayout.Window(windowID, windowRect, (id) =>
      {

        selected = EditorGUILayout.Popup(selected, displayNames);

        //ドラッグできるように
        GUI.DragWindow();

      }, "Window");

      Handles.EndGUI();
    };
  }
}

さらに上記のコードにシーンのカメラとゲーム内のカメラの Transform を同期させると、シーンビューのカメラの位置/向きがそのままゲームビューにも反映されます。*3

Unite 2014 で発表した Sync Camera の機能になる

図13.4: Unite 2014 で発表した Sync Camera の機能になる

13.8 EditorApplication.hierarchyWindowItemOnGUI と projectWindowItemOnGUI

ヒエラルキーやプロジェクトウィンドウで、各ゲームオブジェクトやアセットの文字が描画されている範囲をコールバックとして取得できます。

各ゲームオブジェクト名が描画されてる範囲を取得できる

図13.5: 各ゲームオブジェクト名が描画されてる範囲を取得できる

これらは、小さな範囲の中でゲームオブジェクトの情報を表示してもいいですし、ボタンなどを配置してなんらかのトリガーとするのもいいかもしれません。

だた、注意して欲しいのはアセットストアにあるアセットでも、hierarchyWindowItemOnGUI を使用している可能性がある点です。もし、使用していた場合、かつ、なんらかのトリガーとなっている場合は hierarchyWindowItemOnGUI の使用は控えたほうがいいかもしれません。

各ゲームオブジェクトの右端にアタッチされているコンポーネントを表示

図13.6: 各ゲームオブジェクトの右端にアタッチされているコンポーネントを表示

using UnityEditor;
using UnityEngine;

public class NewBehaviourScript
{

    [InitializeOnLoadMethod]
    static void DrawComponentIcons ()
    {
        EditorApplication.hierarchyWindowItemOnGUI += (instanceID, selectionRect) => {
            //インスタンス ID からゲームオブジェクトを取得
            var go = (GameObject)EditorUtility.InstanceIDToObject (instanceID);

            if (go == null)
                return;

            var position = new Rect (selectionRect) {
                width = 16,
                height = 16,
                x = Screen.width - 20
            };

            foreach (var component in go.GetComponents<Component>()) {
                //Transform は全ゲームオブジェクトにあるので
                //無駄な情報なため表示しない
                if (component is Transform)
                    continue;

                var icon = AssetPreview.GetMiniThumbnail (component);

                GUI.Label (position, icon);
                position.x -= 16;
            }
        };
    }
}

13.9 EditorApplication.playmodeStateChanged

[ss05] の再生状態を切り替えた時に呼び出されるコールバックです。ただし、引数もなく「現在の再生状態はなにか」はユーザーの手で判断しなくてはいけません。

[InitializeOnLoadMethod]
static void CheckPlaymodeState ()
{
    EditorApplication.playmodeStateChanged += () => {

        if (EditorApplication.isPaused) {
            //一時停止中
        }

        if (EditorApplication.isPlaying) {
            //再生中
        }

        if (EditorApplication.isPlayingOrWillChangePlaymode) {
            //再生中または再生ボタンを押した直後
            //コンパイルやさまざまな処理が走っている状態
            //また、再生を止めるときにも呼び出される
        }
    };
}

下記は「コンパイルエラーが出たまま再生ボタンを押すと、ドラクエの呪いの効果音を再生する」サンプル

using UnityEngine;
using UnityEditor;
using System.Reflection;

[InitializeOnLoad]
public class CompileError
{
    //効果音。自由に変更する
    //http://commons.nicovideo.jp/material/nc32797
    const string musicPath = "Assets/Editor/nc32797.wav";
    const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance;

    static CompileError ()
    {
        EditorApplication.playmodeStateChanged += () => {

            //再生ボタンを押した時であること
            if (!EditorApplication.isPlayingOrWillChangePlaymode
                 && EditorApplication.isPlaying)
                return;

            //SceneView が存在すること
            if (SceneView.sceneViews.Count == 0)
                return;

            EditorApplication.delayCall += () => {
                var content = typeof(EditorWindow)
                    .GetField ("m_Notification", flags)
                    .GetValue (SceneView.sceneViews [0]) as GUIContent;

                if (content != null && !string.IsNullOrEmpty (content.text)) {
                    GetAudioSource ().Play ();
                }
            };
        };
    }

    static AudioSource GetAudioSource ()
    {
        var gameObjectName = "HideAudioSourceObject";
        var gameObject = GameObject.Find (gameObjectName);

        if (gameObject == null) {
            //HideAndDontSave フラグを立てて非表示・保存しないようにする
            gameObject =
                EditorUtility.CreateGameObjectWithHideFlags (gameObjectName,
                    HideFlags.HideAndDontSave, typeof(AudioSource));
        }

        var hideAudioSource = gameObject.GetComponent<AudioSource> ();

        if (hideAudioSource.clip == null) {
            hideAudioSource.clip =
                AssetDatabase.LoadAssetAtPath (musicPath,
                                            typeof(AudioClip)) as AudioClip;
        }

        return hideAudioSource;
    }
}

13.10 EditorApplication.modifierKeysChanged

なんらかの修飾キーを押した時に呼び出されるコールバックです。EditorWindow やインスペクターでは修飾キーを押しただけでは GUI の再描画処理が行われません。それを可能にするためにこのコールバックを使用します。

using UnityEditor;
using UnityEngine;

public class NewBehaviourScript : EditorWindow
{
    [MenuItem ("Window/Example")]
    static void CheckModifierKeysChanged ()
    {
        GetWindow<NewBehaviourScript> ();
    }

    void OnEnable ()
    {
        EditorApplication.modifierKeysChanged += Repaint;
    }

    void OnGUI ()
    {
        GUILayout.Label (Event.current.modifiers.ToString());
    }
}

13.11 EditorApplication.update

Unity エディターの更新タイミングで呼び出されるコールバックです。エディターにも MonoBehaviour の Update のような一定の更新タイミングがあります。呼び出されるのは約 200回/秒 となり、描画系処理の前に実行されます。*4

例えば WWW の通信でも使用できます。

using UnityEditor;
using UnityEngine;
using System;
using System.IO;
using UnityEngine.Experimental.Networking;
public class NewBehaviourScript
{
    [MenuItem("Assets/Get Texture")]
    static void TestWWW()
    {
        //画像の URL
        var www = UnityWebRequest.GetTexture("http://placehold.it/350x150");

        //画像を取得して保存する
        EditorUnityWebRequest(www, () =>
        {
            var downloadHandler = (DownloadHandlerTexture)www.downloadHandler;
            var assetPath = "Assets/New Texture.png";
            File.WriteAllBytes(assetPath, downloadHandler.data);
            AssetDatabase.ImportAsset(assetPath);
        });
    }

    static void EditorUnityWebRequest(UnityWebRequest www, Action callback)
    {
        www.Send();
        EditorApplication.CallbackFunction update = null;

        update = () =>
        {
            //毎フレームチェック
            if (www.isDone && string.IsNullOrEmpty(www.error))
            {
                callback();
                EditorApplication.update -= update;
            }
        };

        EditorApplication.update += update;
    }
}

このほかにも独自のコールバックを実装するときにも使用できます。

下記コードは、フォーカスしている EditorWindow が変更するごとに呼び出されるコールバックを作成します。

using UnityEngine;
using UnityEditor;
using System;

[InitializeOnLoad]
class EditorApplicationUtility
{
    public static Action<EditorWindow> focusedWindowChanged;

    static EditorWindow currentFocusedWindow;

    static EditorApplicationUtility ()
    {
        EditorApplication.update += FocusedWindowChanged;

    }

    static void FocusedWindowChanged ()
    {
        if (currentFocusedWindow != EditorWindow.focusedWindow) {
            currentFocusedWindow = EditorWindow.focusedWindow;
            focusedWindowChanged (currentFocusedWindow);
        }
    }
}


[InitializeOnLoad]
public class Test
{
    static Test ()
    {
        EditorApplicationUtility.focusedWindowChanged += (window) => {
            Debug.Log (window);
        };
    }
}

第25章「時間を制御する TimeControl」 のような時間管理のために使用するのもオススメです。

13.12 EditorApplication.delayCall

インスペクター関連の更新処理後に実行されるコールバックです。1度実行すると登録されたデリゲートは破棄されます。

Unity エディター実行の流れ

図13.7: Unity エディター実行の流れ

使いどころとしては、エディターのライフサイクルにどうしても逆らわなければいけない場合に使います。例えば、MonoBehaviour の OnValidate メソッド内ではオブジェクトの破棄はできません。 OnValidate はインスペクターの更新中に実行されるため、オブジェクトの破棄が禁止されています。

エラーが出る

図13.8: エラーが出る

どうしてもオブジェクトの破棄を行いたい時に EditorApplication.delayCall を使用して、一番最後に処理を後回しさせることでさまざまな問題を回避します。

using UnityEngine;

public class NewBehaviourScript : MonoBehaviour
{
    public GameObject go;

    #if UNITY_EDITOR
    void OnValidate ()
    {
        UnityEditor.EditorApplication.delayCall += () => {
            DestroyImmediate (go);
            go = null;
        };
    }
    #endif
}

13.13 EditorApplication.globalEventHandler

Unity エディター全体でなんらかのイベントが実行された時に実行されるコールバックです。これは正式には公開されていませんが、なんらかのキーイベントやマウス位置など把握したい時に使用すると便利です。

下記コードは、globalEventHandler を使いやすくしたラッパークラスです。

適当なところでキーを押すとログが表示されていく

図13.9: 適当なところでキーを押すとログが表示されていく

using UnityEngine;
using UnityEditor;
using System.Reflection;
using CallbackFunction = UnityEditor.EditorApplication.CallbackFunction;

[InitializeOnLoad]
class EditorApplicationUtility
{
    static BindingFlags flags =
        BindingFlags.Static | BindingFlags.Instance | BindingFlags.NonPublic;

    static FieldInfo info = typeof(EditorApplication)
                                     .GetField ("globalEventHandler", flags);

    public static CallbackFunction globalEventHandler {
        get {
            return  (CallbackFunction)info.GetValue (null);
        }
        set {
            CallbackFunction functions = (CallbackFunction)info.GetValue (null);
            functions += value;
            info.SetValue (null, (object)functions);
        }
    }
}


[InitializeOnLoad]
public class Test
{
    static Test ()
    {
        EditorApplicationUtility.globalEventHandler += () => {
            Debug.Log (Event.current);
        };
    }
}

ただし、ゲームビューや、ほかの一部のウィンドウ上では動作しないので注意してください。

[*4] 200回/秒実行されるのはバグとの報告があります。ドキュメントでは 100回/秒と記載されています。

第12章 Undo について 第14章 ReorderbleList