オブジェクトを破棄したフレーム内限定で起きる問題

<sup>更新履歴： `GetComponentsInChildren` への回避策と Leaked Managed Shell を発生させるサンプルコードを追加</sup><br/>&nbsp;

[Leaked Managed Shell](https://qiita.com/sator_imaging/items/6bca6c642b6347bd68d8) に関連して気付いた不具合？ に近い Unity の挙動。

--

<p><details lang="ja" --open><summary>Unity のマニュアルに記載の通り、<code>Object.Destroy(obj, 0)</code> はたとえ遅延設定が０でも同一フレーム内の処理が全て終わった後にオブジェクトの破棄を実行する。</summary>

> [Unity - Scripting API: Object.Destroy](https://docs.unity3d.com/2021.3/Documentation/ScriptReference/Object.Destroy.html)  
> Actual object destruction is always delayed until after the current Update loop, but is always done before rendering.

`ScriptExecutionOrder` [💬](https://docs.unity3d.com/ja/current/Manual/class-MonoManager.html) なんてのもあるが Unity のコンポーネントの実行順は基本的に安定しない。

オブジェクトを即時破棄（`Object.DestroyImmediate`）してしまうと、コンポーネントがどの順番で実行されるかによってエラーが出たりでなかったりすることになるので、ループの最後にオブジェクトを破棄するって設計は妥当だと言える。


<!------- End of Details JA Tag -------></details></p>



# 問題を起こす / LMS を発生させるコード

ただし問題があって、Unity の一部の機能はループ内で削除済みとマークされたオブジェクトを同一フレーム内であれば取得できてしまう。

※ `GameObject.Find()` `GameObject.FindWithTag()` `GetComponentsInChildren<T>()` その他アニメーション系のメソッド

> **Note**
> `Resources.FindObjectsOfTypeAll()` は破棄する側の対策だけでは不十分で、使う側が注意しないと Leaked Managed Shell を引き起こす可能性がある  

<sup>※ シグネチャが度々 `Obsolete` されているので古いコードから探す場合は `.FindObjectsOfTypeAll` で検索する必要がある</sup>


```csharp
internal class ManagedShellDestroyTests : MonoBehaviour
{
    [SerializeField] public GameObject TestObj, TestObj2;
    Transform _parent;
    string _objName, _objTag;
    string _objName2, _objTag2;

    void Start()
    {
        _parent = TestObj.transform.parent;
        _objName = TestObj.name;
        _objName2 = TestObj2.name;
        _objTag = TestObj.tag;
        _objTag2 = TestObj2.tag;

        StartCoroutine(nameof(DestroyTest));
    }

    IEnumerator DestroyTest()
    {
        // GetComponentsInChildren には includeInactive オプションがある
        UnityEngine.Debug.Log("GetComponentsInChildren(true): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: true)?.Length);
        UnityEngine.Debug.Log("GetComponentsInChildren(false): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: false)?.Length);

        // 削除前に非アクティブにしたとしても includeInactive で見つかってしまう
        //TestObj.SetActive(false);

        // Destroy(obj, t) t = 0 の時に限れば削除前に parent を null すれば防げる
        //TestObj.transform.SetParent(null, false);

        // タグと名前は破棄する前に変更しておけば問題を回避できる
        //   ※ Find 系は SetActive(false) でも同様の結果が得られる
        // ただ、アクティブかどうかに関係なく名前やタグにのみ依存する Unity の機能は意外と存在する
        // 自作スクリプトが削除済みオブジェクトを取得した時の予防にもなるので変更しておくのが無難
        // 【注】空の文字列は境界チェックしていない決め打ちのコードで問題が起きるので避けた方がイイ
        // 【注】name をそのまま辞書のキーとして突っ込む可能性もあるので同じ名前を使うのも避けるべき
        //TestObj.name = "DESTROYED" + TestObj.GetHashCode();
        //TestObj.tag = "Untagged";
        //TestObj.layer = 0;

        UnityEngine.Debug.LogWarning("=== OBJECT PROPERTIES CLEARED ===");

        // GetComponentsInChildren からは破棄前に SetParent(null, false) すれば逃れられる
        UnityEngine.Debug.Log("GetComponentsInChildren(true): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: true)?.Length);
        UnityEngine.Debug.Log("GetComponentsInChildren(false): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: false)?.Length);

        // 型ベースの検索メソッドから逃れる方法は無さそう
        // ※ Object.FindObjectsOfType は非アクティブにすれば回避できる
        UnityEngine.Debug.Log("Resources.FindObjectsOfTypeAll: "
            + Resources.FindObjectsOfTypeAll(typeof(Transform))?.Length);

        UnityEngine.Debug.LogWarning("=== OBJECT DESTROYED ===");
        Destroy(TestObj, t: 0);
        TestObj = null;

        // なにもしないと削除したフレーム内では取得できてしまう ※別コンポーネントからも取得可
        UnityEngine.Debug.Log("= Find Tests ===");
        UnityEngine.Debug.Log(Object.FindObjectsOfType<Transform>()?.Length);
        UnityEngine.Debug.Log(GameObject.FindGameObjectsWithTag(_objTag).Length);
        UnityEngine.Debug.Log(GameObject.FindGameObjectWithTag(_objTag));
        UnityEngine.Debug.Log(GameObject.FindWithTag(_objTag));
        UnityEngine.Debug.Log(GameObject.Find(_objName));

        // 取得は出来るが Destroy 後は名前もタグもエラーが出て設定は出来ない
        //TestObj.tag = "Untagged";
        //TestObj.name = string.Empty;

        // 削除済みオブジェクトは NullReferenceException だけど、所謂ヌルじゃないヌル状態（のハズ）
        //UnityEngine.Debug.Log("NullReferenceException thrown: " + TestObj.name);
        //UnityEngine.Debug.Log("NullReferenceException thrown: " + TestObj.transform);

        // includeInactive 次第で GetComponentsInChildren の結果が変わる
        // 挙動を見るに Managed Shell 内部で削除フラグが立っているだけで、そもそもネイティブ側は
        // 削除フラグ自体が無い DB なのかテーブルなのかを参照してオブジェクトを取得してるんじゃないだろうか
        UnityEngine.Debug.Log("GetComponentsInChildren(true): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: true)?.Length);
        UnityEngine.Debug.Log("GetComponentsInChildren(false): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: false)?.Length);

        // GetComponentsInChildren を通して削除済みオブジェクトに関連付けられているコンポーネントの取得が可能
        // 同一フレーム内ならプロパティーへのアクセスも可能な状態
        // そして次のフレームでこれらは MissingReferenceException になって Leaked Managed Shell に化ける
        var trans = _parent.GetComponentsInChildren<Transform>(includeInactive: true);
        UnityEngine.Debug.Log("Retrieved Object Names: " + string.Join("\n- ", trans.Select(x => x.name)));
        UnityEngine.Debug.Log("Retrieved Object GameObjects: " + string.Join("\n- ", trans.Select(x => x.gameObject)));

        yield return null;  // １フレーム待つ

        // 前のフレームで取得した一部のオブジェクトは Leaked Managed Shell に化けているので
        // ?.name や ?.gameObject は意図した動作をしない
        //UnityEngine.Debug.Log("Retrieved Object Names (NEXT FRAME): " + string.Join("\n- ", trans.Select(x => x?.name)));
        //UnityEngine.Debug.Log("Retrieved Object GameObjects (NEXT FRAME): " + string.Join("\n- ", trans.Select(x => x?.gameObject)));

        // 検索メソッドは削除した次のフレームでは想定通りの動作をする
        UnityEngine.Debug.Log("= Find Tests (NEXT FRAME) ===");
        UnityEngine.Debug.Log(Object.FindObjectsOfType<Transform>()?.Length);
        UnityEngine.Debug.Log(GameObject.FindGameObjectsWithTag(_objTag).Length);
        UnityEngine.Debug.Log(GameObject.FindGameObjectWithTag(_objTag));
        UnityEngine.Debug.Log(GameObject.FindWithTag(_objTag));
        UnityEngine.Debug.Log(GameObject.Find(_objName));

        UnityEngine.Debug.Log("GetComponentsInChildren(true): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: true)?.Length);
        UnityEngine.Debug.Log("GetComponentsInChildren(false): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: false)?.Length);

        UnityEngine.Debug.Log("Resources.FindObjectsOfTypeAll (NEXT FRAME): "
            + Resources.FindObjectsOfTypeAll(typeof(Transform))?.Length);

        EditorApplication.isPaused = true;
    }

    void LateUpdate()
    {
        // Update() 内で破棄したオブジェクトは見つからない
        // ※マニュアルの説明通りなら仕様として保証している訳では無さそう
        UnityEngine.Debug.Log("[LateUpdate] Obj: " + GameObject.FindGameObjectsWithTag(_objTag).Length);
        UnityEngine.Debug.Log("[LateUpdate] Obj: " + GameObject.FindGameObjectWithTag(_objTag));
        UnityEngine.Debug.Log("[LateUpdate] Obj: " + GameObject.FindWithTag(_objTag));
        UnityEngine.Debug.Log("[LateUpdate] Obj: " + GameObject.Find(_objName));

        // LateUpdate() 内でオブジェクトを破棄
        Destroy(TestObj2, t: -1);  // t = マイナスでも結果は当然同じ
        TestObj2 = null;

        // Update() 同様、LateUpdate() 内で破棄したオブジェクトは同一フレームなら見つかる
        UnityEngine.Debug.Log("[LateUpdate] Obj2: " + GameObject.FindGameObjectsWithTag(_objTag2).Length);
        UnityEngine.Debug.Log("[LateUpdate] Obj2: " + GameObject.FindGameObjectWithTag(_objTag2));
        UnityEngine.Debug.Log("[LateUpdate] Obj2: " + GameObject.FindWithTag(_objTag2));
        UnityEngine.Debug.Log("[LateUpdate] Obj2: " + GameObject.Find(_objName2));
    }
}
```



# 雑感

`GameObject` `MonoBehaviour` は取っつきやすいし便利だし Unity 最大級の発明だと思うけど、C# マネージド側とネイティブ側で辻褄を合わせるのが大変なんだろうな。

ECS / DOTS ではサブシーンを作って `**Authoring` ってクラスを貼り付けて～～、ってワークフローになるみたいだけど、今までの取っつきやすい部分は入り口としてそのまま残しつつ、その中で別ワールドを作って根本からガラリと変えたシステムにしたいんだろう。


--


`Update` から `LateUpdate` に変えたらなんか上手く行った、（必要ないはずだけど）ヌルチェックを入れたら上手く行った、ココではおまじないとして１フレーム待ってください！！絶対に消さないで！！とか、そんな場合は 👆 の現象を疑ってみるといいかもです。

> **オブジェクト名の初期化の注意点**  
> １）空の文字列は境界チェックせずにスライスするコードとかで問題が起きるので避ける  
> ２）名前をそのまま辞書のキーとして突っ込む可能性もあるので全く同じ名前も避ける

非アクティブ化だけでは足らずに名前を変えなきゃいけないってシチュエーションはあるにはある、けど凄いレアなケース。

とは言えオブジェクトの生成と破棄を高速で繰り返したい！って状態はそもそものロジックに問題があると思うので、オブジェクトプール `ObjectPool<T>` [💬](https://docs.unity3d.com/2021.3/Documentation/ScriptReference/Pool.ObjectPool_1.html) を使うなりなんなりに変えるべき。

そして破棄するときにほんの些細なコストを追加して GameObject の値の初期化をしておく、ってのがやっぱり無難な落としどころ。

--

以上です。お疲れ様でした。