LoginSignup
26

More than 5 years have passed since last update.

Unity オブジェクトのライフサイクルを意識し MissingReferenceException を撲滅しよう 〜Destroyされた変数の中身は何なのか〜

Last updated at Posted at 2016-03-23

Unity で開発していると MissingReferenceException に悩まされることが多々あると思います。再現性が低いととても厄介です。これは、Unity オブジェクトのライフサイクルを意識することで抑制できます。
今回は MissingReferenceException が発生しやすい 3つのケースと、MissingReferenceException を発生させる Destroy されたオブジェクトの正体を考察してみます。

Case. 1

public class Test : MonoBehaviour {

    public class InternalScript : MonoBehaviour {}

    IEnumerator Start() {
        var go = new GameObject("InternalObject");
        var script = go.AddComponent<InternalScript>();
        Debug.Log("script.enabled: " + script.enabled);
        GameObject.Destroy(go);
        Debug.Log("script.enabled: " + script.enabled);
        yield return null;
        Debug.Log("script.enabled: " + script.enabled);
    }
}
script.enabled: True
script.enabled: False
MissingReferenceException: The object of type 'InternalScript' has been destroyed but you are still trying to access it.
Your script should either check if it is null or you should not destroy the object.

最もよく見るケースで、3回目の script.enabledMissingReferenceException が発生しています。GameObject を Destroy すると、参照が残っていようが次のフレームにはアタッチされている Component インスタンスも含め、全てのオブジェクトが破棄されます。
エラーメッセージには「null チェックするか Destroy するなよ」とありますので、メンバ変数にアクセスする前に、null チェックを入れることで解決できます。
比較的意識しやすく、対処もしやすいので、あまり大きな問題にはならない。

Case. 2

public class Test : MonoBehaviour {

    public class InternalScript : MonoBehaviour {
        public IEnumerator Run() {
            while (true) {
                Debug.Log("this: " + this);
                Debug.Log("this.enabled: " + this.enabled);
                yield return new WaitForSeconds(1.0f);
            }
        }
    }

    IEnumerator Start() {
        var go = new GameObject("InternalObject");
        var script = go.AddComponent<InternalScript>();
        StartCoroutine(script.Run());
        GameObject.Destroy(go);
    }
}
this: InternalObject (InternalScript)
this.enabled: True
this: null
MissingReferenceException: The object of type 'InternalScript' has been destroyed but you are still trying to access it.
Your script should either check if it is null or you should not destroy the object.

こちらは、2回目の this.enabledMissingReferenceException が発生しています。 Run 関数の中では自分自身のメンバにアクセスしているので意識されにくいですが、 this 自体が null を返しているのが分かります。
このケースでは StartCoroutine を実行する際に、コルーチンを実行するオブジェクトが適切かを意識しなければなりません。
上記のコードでは、自身を参照する InternalScript のコルーチンを Test オブジェクトで実行しているため、 InternalScript オブジェクトが Destroy されても Test オブジェクトはコルーチンを実行し続けます。

InternalScript を下記のように実装するのが、間違った使い方をされずにすむでしょう。

public class Test : MonoBehaviour {

    public class InternalScript : MonoBehaviour {
        public void Run() {
            StartCoroutine(InternalRun());
        }

        IEnumerator InternalRun() {
            while (true) {
                Debug.Log("this: " + this);
                Debug.Log("this.enabled: " + this.enabled);
                yield return new WaitForSeconds(1.0f);
            }
        }
    }

    IEnumerator Start() {
        var go = new GameObject("InternalObject");
        var script = go.AddComponent<InternalScript>();
        script.Run();
        GameObject.Destroy(go);
    }
}

Case. 3

public class Test : MonoBehaviour {

    public class InternalScript : MonoBehaviour {
        public void Call() {
            Debug.Log("this.enabled: " + this.enabled);
        }
    }

    IEnumerator Start() {
        var go = new GameObject("InternalObject");
        var script = go.AddComponent<InternalScript>();
        var func = new Action(script.Call);
        func();
        GameObject.Destroy(go);
        func();
        yield return null;
        func();
    }
}
script.enabled: True
script.enabled: False
MissingReferenceException: The object of type 'InternalScript' has been destroyed but you are still trying to access it.
Your script should either check if it is null or you should not destroy the object.

このケースでは、3回目の this.enabledMissingReferenceException が発生しています。
これを防ぐには呼び出し側で Delegate のインスタンスが生きているかを次のように確認します。

public class Test : MonoBehaviour {

    public class InternalScript : MonoBehaviour {
        public void Call() {
            Debug.Log("this.enabled: " + this.enabled);
        }
    }

    public class Event {
        Action callback;

        public Event(Action callback) {
            this.callback = callback;
        }

        public void Call() {
            var component = callback.Target as MonoBehaviour;
            if (component != null)
                callback();
        }
    }

    IEnumerator Start() {
        var go = new GameObject("InternalObject");
        var script = go.AddComponent<InternalScript>();
        var ev = new Event(script.Call);
        ev.Call();
        GameObject.Destroy(go);
        ev.Call();
        yield return null;
        ev.Call();
    }
}

Delegate.Target には、その Delegate のインスタンスオブジェクトが参照されているので、こいつを null チェックします。
ポイントは MonoBehaviour にキャストした上で null チェックを行うことです。
理由は次の項で説明します。

このままでは無名関数を Delegate として渡しても絶対実行されないので Event を無名関数でも使えるようにする為には次のようにします。

public class Event {
    Action callback;
    MonoBehaviour instance;
    bool isComponent;

    public Event(Action callback) {
        this.callback = callback;
        this.instance = callback.Target as MonoBehaviour;
        this.isComponent = instance != null;
    }

    public void Call() {
        if (!isComponent || instance != null)
            callback();
    }
}

Destroy された変数の中身は null ではない

※ここからは私の考察であり、実際の実装とは異なる場合があります。この辺りの公開情報をご存じであれば、ご教授頂けると幸いです。

ここまでは、Destroy されたかどうかを null チェックで判断してきたが、Destory されたオブジェクトは本当に null なのでしょうか。
次のコードを見てみましょう。

public class Test : MonoBehaviour {

    public class InternalScript : MonoBehaviour {
        public string whoami = "I am an InternalScript.";
    }

    IEnumerator Start() {
        var go = new GameObject("InternalObject");
        var script = go.AddComponent<InternalScript>();
        GameObject.Destroy(go);
        yield return null;
        Debug.Log("script: " + script);
        Debug.Log("script.whoami: " + script.whoami);
        Debug.Log("script.enabled: " + script.enabled);
    }
}
script: null
script.whoami: I am an InternalScript.
MissingReferenceException: The object of type 'InternalScript' has been destroyed but you are still trying to access it.
Your script should either check if it is null or you should not destroy the object.

script インスタンスは null を返しているが、script.whoami は正常にアクセスできています。しかし、次の script.enabledMissingReferenceException が発生しています。
この MissingReferenceException は Unity で定義されている例外で C# の null参照エラーは NullReferenceException です。これは、Destory されたオブジェクトは null のように振る舞うだけで実際には残っており、継承されたメンバにアクセスした際に MissingReferenceException を throw していることになります。

では何が Destory されたのか。

Unity には2系統のメモリマネージャ(GC)が存在します。mono と Unity です。mono は C# 上のマネージドメモリを管理し、Unity はネイティブコードのメモリを管理します。(C/C++ で new/delete されているようなもの。)

C# コード上で、Unity オブジェクトを生成した際、mono のメモリ上で変数が生成されると同時に、ネイティブコード上にもメモリが確保(こちらが実態)され、C# 上のオブジェクトはネイティブコード上のインスタンスを参照します。

Destory されると、ネイティブ側のメモリが破棄され、C# 側のネイティブオブジェクトへの参照が失われると、null として振る舞うようになり、ネイティブオブジェクトを参照していたメンバにアクセスされると MissingReferenceException を throw します。

null としての振る舞いは ToString(...)== operator をオーバーライドすることで表現されています。
(object)component == nullcomponent ?? go.AddComponent<..>() が意図した動作を行わない原因です。

ネイティブコードへの参照を失った C# 側のオブジェクトは mono のメモリに残り続け、mono の GC スケジュールにより破棄されます。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26