LoginSignup
2
2

destroyCancellationToken でオブジェクトの寿命管理

Last updated at Posted at 2024-03-18

更新履歴: シーンの生存期間を MonoBehaviour で管理する意味はなかった。
 

以前 DI コンテナを使ってインスタンスのライフサイクルを管理すると良さそうという話を書いたんですが、

機械的にコンストラクターインジェクションを解決したい、という状況に中々巡り合えないまま気が付いたら MonoBehaviour.destroyCancellationToken というものが Unity に追加されてました。

全部 CancellationToken で良いじゃん

ということで、インスタンスの寿命管理は全部 CancellationToken.Register(...) で良いじゃんと思うようになりました。DI コンテナなんて要らなかったんだ。

せっかくなので Unity 2021 でも使えてアロケーションも回避するヘルパーも用意しちゃいましょう。(私が行ったテストに間違いがなければ)これがパフォーマンス的にもベストな選択です。

👇 Curried というテクニックは .NET 8 環境だとめっちゃ遅くなる。(コンソールアプリ + BenchmarkDotNet でテスト)

https://gist.github.com/ufcpp/b2e64d8e0165746effbd98b8aa955f7e

Unity IL2CPP 環境だとそもそもこのテクニックは無意味(遅くもならず早くもならず)で、静的クラスの Action に静的メソッドを挿す(static Action = StaticMethod;)という方法だけが遅くなる。
IL2CPP/.NET 共に一番早かったのは static Action = () => { ... };(静的関数じゃなくダイレクトでアクションを挿す)って方法。

Action のアロケ回避の確認

実行環境: https://sharplab.io/Results = C#

using System;
using System.Threading;

public class C
{
    // token.Register(Action<object>, object) にこれを渡すとアロケ回避&パフォーマンス的にもベスト
    readonly static Action<object?> Disposer = obj =>
    {
        if (obj is IDisposable disposable)
            disposable.Dispose();
    };
    
    public class Test : IDisposable
    {
        public void Dispose() {}
    }

    public void M()
    {
        CancellationToken token = new();
        
        // non alloc
        var test = new Test();
        var test2 = new Test();
        token.Register(Disposer, test);
        token.Register(Disposer, test2);

        // alloc
        var test3 = new Test();
        token.Register(test3.Dispose);
        token.Register(() => test3.Dispose());
    }
}

結果

static Action = () => { ... } は結構複雑な、、、暗黙的にクラスを定義してその静的フィールドにインスタンスを挿してさらにそのインスタンスのメソッドを呼んでる。

public class C
{
    public class Test : IDisposable
    {
        public void Dispose()
        {
        }
    }

    [CompilerGenerated]
    private sealed class <>c__DisplayClass2_0
    {
        public Test test3;

        internal void <M>b__0()
        {
            test3.Dispose();
        }
    }

    [Serializable]
    [CompilerGenerated]
    private sealed class <>c
    {
        public static readonly <>c <>9 = new <>c();

        internal void <.cctor>b__4_0(object obj)
        {
            IDisposable disposable = obj as IDisposable;
            if (disposable != null)
            {
                disposable.Dispose();
            }
        }
    }

    [System.Runtime.CompilerServices.Nullable(new byte[] {
        0,
        2
    })]
    private static readonly Action<object> Disposer = new Action<object>(<>c.<>9.<.cctor>b__4_0);

    public void M()
    {
        <>c__DisplayClass2_0 <>c__DisplayClass2_ = new <>c__DisplayClass2_0();
        CancellationToken cancellationToken = default(CancellationToken);
        Test state = new Test();
        Test state2 = new Test();
        cancellationToken.Register(Disposer, state);
        cancellationToken.Register(Disposer, state2);
        <>c__DisplayClass2_.test3 = new Test();
        cancellationToken.Register(new Action(<>c__DisplayClass2_.test3.Dispose));
        cancellationToken.Register(new Action(<>c__DisplayClass2_.<M>b__0));
    }
}

ヘルパークラス LifecycleBehaviour

出来上がったクラスは Unity 2021 でも DestroyWith 拡張メソッドが使えるようになってて、Unity メインメニューの Tests > LifecycleManager > ... から簡易的なテスト(手作業)が実行可能。

なんとなく CancellationToken.Register の戻り値を投げ捨てるのが憚られたので Unity エディター上では WeakReference で保持して状況が確認できるように。

つかいかた

至って単純。

拡張メソッドの名前は BindTo も良いなと思ったけど Unity らしく Destroy を含む名前にした。

CancellationToken.Register で登録したアクションの実行順は LIFO。最後にバインドしたオブジェクトから先に破棄される。(もちろんその前にオーナーが破棄される)

using SatorImaging.LifecycleManager;

// Unity 2021 でも動く
gameObject.DestroyWith(cancellationToken);
disposable.DestroyWith(tokenOrMonoBehaviour);
unityObj.DestroyUnityObjectWith(tokenOrBehaviour);  // Unity オブジェクトは別メソッド(理由は後述

// Unity シーンの生存期間を取得してバインド
var sceneLifetime = SceneLifetime.Get(gameObject.scene);
disposable.DestroyWith(sceneLifetime);
sceneLifetime.Token.Register(() => DoSomethingOnSceneUnloading());  // シーンのアンロード時に何かする

// Unity シーンに紐づいたライフサイクル(MonoBehaviour)を取得
var sceneLC = SceneLifecycle.Get();

// 生存期間の連結
var root = LifecycleBehaviour.Create("Root Lifecycle", dontDestroyOnLoad: false);
var child = LifecycleBehaviour.Create("Child Lifecycle", dontDestroyOnLoad: false);
var grand = LifecycleBehaviour.Create("Grandchild Lifecycle", dontDestroyOnLoad: false);
child.gameObject.DestroyWith(root);
grand.gameObject.DestroyWith(child);
    // child/grand はバインド後にシーンの破棄に巻き込まれるのを防ぐため DontDestroyOnLoad される

// デバッグ他の用途で使えるアクション。null なら何もしない。
// デフォルトで関連付けを WeakReference として保存するアクションが挿さってる(Unity エディター上のみ)
LifetimeExtensions.DebuggerAction = (obj, token, ticket, ownerOrNull) =>
{
    // バインド状況をログりたいとかそういう用途
    Debug.Log($"Target Object: {obj}");
    if (ownerOrNull != null)
        Debug.Log($"Lifetime Owner: {ownerOrNull}");
    Debug.Log($"CancellationToken: {token}");
    Debug.Log($"CancellationTokenRegistration: {ticket}");
};

ソースコード

最初は100行程度だったんだけど、テストとドキュメントを含むとは言え1000行になっちゃうとは。

API Reference

考慮したこと/すべきこと

シーン間でインスタンスの生存期間のバインドをすると物事が複雑になりそうだから制限。ただ monoBehaviour.destroyCancellationToken にオブジェクトを直接バインドすればシーン間で関連付けることも可能。(というかトークンから発行元オブジェクトを取得する方法がないから防げないだけ)

シーンのアンロードによる破棄が起きると OnDestroy の実行順が代わっちゃうので、生存期間がバインドされた GameObject は自動的に DontDestroyOnLoad に移して実行順を保証。

でもコンポーネント(MonoBehaviour)は親を勝手に DontDestroyOnLoad(component.gameObject) しちゃうとトラブルになりそうだからそのまま。なのでシーンのアンロードとオーナーの破棄の両方の理由で OnDestroy が発生しうる。めちゃくちゃ厄介。

これは Unity がシーンのアンロード時にすべての MonoBehaviour の destroyCancellationToken をキャンセル、そのコールバックすべてが終わったのを確認してから GameObject の破棄(OnDestroy)を開始する、ってしないと対応できない。

でも CancellationToken.Register のコールバックの終了を検出する方法はないから多分そういう処理が Unity で行われる可能性はない。コンポーネント MonoBehaviour の寿命を destroyCancellationToken に紐づける場合はその辺り考慮する必要がある。基本はやめた方が良いと思う。

(最初に挿したコールバックが最後に実行されるから、全ての MonoBehaviour にコールバック終了を通知するコールバックを挿すとかいう無茶をすれば検知出来なくもない)

※ コンポーネント側はオーナーより先に破棄されて困るってことはなさそうだけどオーナー側は困りそう? destroyCancellationToken で紐づけたからコンポーネントが MissingReferenceException になる筈はない、が保証されないので結局都度チェックする必要が出てくる。

当然かもだけど destroyCancellationToken は非同期処理を MonoBehaviour 内で実行するときに渡すことしか考えて無さそうな?? destroyCancellationToken.Register で Unity エンジン絡みの重要な処理を登録しちゃうのは少し怖いね。

「アップデートマネージャー」機能

勢いに任せて以下に対処する、所謂アップデートマネージャー機能も付けた。

特徴

各 Update は Early 無印 Late の3ステージに分かれていて実行順を保証、ただし各ステージに登録されたアクションの実行順は登録/解除の最適化の為に保証しないという実装。

MonoBehaviour も保証してないからダイジョブ。

ライブラリ内のコンポーネントの実行順制御はこの3ステージを使っとけば後々面倒が無くなる。

var lifecycle = LifecycleBehaviour.Create("My Lifecycle", false);

// 登録
lifecycle.RegisterUpdateEarly(...);
lifecycle.RegisterUpdate(...);
lifecycle.RegisterUpdateLate(...);

lifecycle.RegisterFixedUpdateEarly(...);
lifecycle.RegisterFixedUpdate(...);
lifecycle.RegisterFixedUpdateLate(...);

lifecycle.RegisterLateUpdateEarly(...);
lifecycle.RegisterLateUpdate(...);
lifecycle.RegisterLateUpdateLate(...);

// 削除 ※ Action のインスタンスが完全に一致しないと削除できないので削除するつもりなら参照を取っておく
var entry = lifecycle.RegisterUpdateEarly(...);
lifecycle.RemoveUpdateEarly(entry);

最初と最後にアプリ/システム用途のステージとして Initialize Finalize もある。(合計5ステージ)

lifecycle.RegisterUpdateInitialize(...);
// --> Early -> 無印 -> Late が続いて
lifecycle.RegisterUpdateFinalize(...);

ちょっとステージが多すぎる気もするけど、登録しない限りは内部に配列も確保しないしループごとにヌルチェックするだけだからそこまでコストはかかってないハズ。性質上インスタンスの生存期間も長いだろうしたったの15個、問題ない。

グローバルアップデートマネージャー的なモノ

アップデートマネージャーより短命なインスタンスとそれに依存したアクションを登録するケース。

var globalLC = LifecycleBehaviour.Create("Global Lifecycle", true);  // アプリ起動中ずっと存命

instance.DestroyWith(token);
globalLC.RegisterUpdate(() => instance.NoErrorUntilDispose(), token);  // トークンで登録解除する

// 同じトークンを渡して登録解除されるようにしておかないと、依存しているインスタンスが破棄されてしまいエラーに
globalLC.RegisterUpdate(() => instance.NoErrorUntilDispose());

TODO: RunOnMainThread もどうせなら欲しい

Google API にあるやつ。なんか長い。

Firebase API のやつ。さらに長い。

余談:アップデートマネージャーの必要性

稀に(でもない?) 外部向けなのに Update LateUpdate でコンポーネントの実行順を制御しちゃってる Unity ライブラリなんかがあったりするけど、アプリケーションレベルの実行順制御機構をライブラリで使ってしまうと大抵面倒なことになる。

ライブラリ単位では当然問題ないが、いざアプリに組み込もうとすると色々とつじつまが合わなくなる。

Unity のライフサイクルの数が足りない、カスタマイズしたい、あれが Update だから LateUpdate 使うしかない、UnityEngine.LowLevel.PlayerLoop でゴリゴリにカスタマイズしよう、って問題の根本的な原因は安易な Update LateUpdate の利用による実行順制御。

単純/決め打ちで構わないのでアップデートマネージャーを作っておくと、各アップデートマネージャーの実行順を制御するだけになって大分楽になるのでおススメ。

変に MonoBehaviour を嫌っているのに実行順の制御は MonoBehaviour のライフサイクルに強く依存してる、とかいうライブラリみるとマジで、、、って感じですよね。気を付けたいですね。

MonoBehaviour を作ったら必ず自分のライフサイクルを使わなきゃいけない訳じゃない。Unity の API の中には Gizmos みたいに特定のライフサイクルでしか実行できないものもあるけど、MonoBehaviour 自身のライフサイクルで実行しなきゃならないって制限を課されてる API は存在しない。

なので事情があって一つの処理を複数の MonoBehaviuor に分けざるを得ないとしても、本当は一緒に処理したかった MonoBehaviour 群の中心となるコンポーネントの Update で他のコンポーネントの更新処理を順序立てて呼び出してやれば本来は済む話。単純な処理ならアップデートマネージャーなんて大袈裟なモノを作る必要もない(中心となるコンポーネントが結果的にその役割を担ってるけど)

その辺さえ押さえとけば特定のデバイス/ネットワークからの入力処理だけはどうしても早めに実行したい! っていうよくある状況を ScriptExecutionOrder で設定する程度で十分になる。

// 同じライブラリ内の実行順制御に Update LateUpdate 使われちゃうとこういう単純なことも出来なくなる
// メソッドを公開してくれてればなんとか出来るけど、デフォルトのまま非公開のケースが多いし対象が多かったら終わる
class GeneralManagerOfUpdateManagers : MonoBehaviour
{
    public LifecycleBehaviour lifecycle1;
    public LifecycleBehaviour lifecycle2;
    public LifecycleBehaviour lifecycle3;

    void OnEnable()
    {
        lifecycle1.enabled = false;
        lifecycle2.enabled = false;
        lifecycle3.enabled = false;
    }
    void Update()
    {
        lifecycle3.Update();  // 各ライブラリ内のステージ実行順は維持しながらアプリ全体の順序を制御
        ○○.Update();
        △△.LateUpdate();     // 単に○○の後に実行したいから Late なだけで本来 Update で実行すべきもの
        lifecycle1.Update();  // そもそもライブラリが自分の抱えてるコンポーネントの実行順を適切に制御
        lifecycle2.Update();  // してればこういう余計なつじつま合わせは要らない
    }
    void LateUpdate()
    {
        lifecycle1.LateUpdate();
        lifecycle3.LateUpdate();
        lifecycle2.LateUpdate();
    }
    void FixedUpdate()
    {
        lifecycle2.FixedUpdate();
        lifecycle3.FixedUpdate();
        lifecycle1.FixedUpdate();
    }
}

おわりに

特別なことしなくても Unity のシーンとかコンポーネントにピュア C# クラスの寿命を紐づけられるようになる destroyCancellationToken 便利。

勢いで作り切ったからアプリに組み込んで使うのはこれからだけど Unity コンポーネントの寿命の紐づけを禁止して GameObject を必ず DontDestroyOnLoad するようにすれば信頼性は上がりそう。

アップデートマネージャーは MonoBehaviour が最低千個はないと意味が出てこないからインベントリのアイテム一覧とかかなー 使うなら。

--

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

2
2
0

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
2
2