6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

横国ゲーム制作部Advent Calendar 2022

Day 2

シーン内のInject対象コンポーネントを自動で収集する [VContainer]

Posted at

VContainerのInject対象コンポーネントをシーンセーブ時に自動的に収集して保持する方法について書きました。

はじめに

VContainerはUnityで使われるDIフレームワークです。
これまでよく使われてきたDIフレームワークのExtenject(旧Zenject)と比較すると、高速である・参照解決時にフレームワーク側ではアロケーションが発生しない・ビルド時に含まれるサイズが小さい、などが特徴的です。
自分もUnityでゲーム制作をする際によく使うのですが、VContainerは実行速度を担保するため、またその設計思想上、シーン内に存在するMonoBehaviourに対して自動的にはインジェクトを行いません(LifetimeScopeAutoInjectGameObjectsフィールドに設定したGameObjectとその子には参照解決を行うことができます)。
VContainerを開発した方であるhadashiAさんが書かれているドキュメントには自動的にインジェクトを行わない理由が述べられており、GameObjectMonoBehaviourの生成時に処理を挟むために用意されている良い方法がないこと、VContainerのようなDIコンテナをUnityで用いるメリットの一つとしてMonoBehaviourのような寿命が不安定なViewコンポーネントから外部へ処理の起点を移せるということがあるが、その場合はMonoBehaviourインジェクトするのではなくMonoBehaviourインジェクトするのが自然な形になること、などがあげられています。
この思想は個人的にはとても共感できるものですが、その一方でMessagePipeのようなDI前提のライブラリを扱う際には、MonoBehaviourにInjectをする場面が多くなると思います(MonoBehaviourがイベントの発行側になりえるため)。

ViewMonoBehavioiur.cs
using MessagePipe;
using UnityEngine;
using VContainer;

public class ViewMonoBehaviour : MonoBehaviour
{
    [Inject] private IPublisher<SampleEvent> sampleEventPublisher;

    private void Update()
    {
        // 何らかの処理
        
        // イベントを発行
        sampleEventPublisher.Publish(new SampleEvent());
    }
}

このような場合は、MonoBehaviourを自動的に収集できた方が毎回AutoInjectGameObjectsに突っ込む手間が省けて楽です。
しかしZenjectのように実行時にシーン内の全てのコンポーネントに対してリフレクションを使ってインジェクトを行うのも実行速度の面で気になります。
そこで今回は、シーンのセーブ時に参照の収集を行い、LifetimeScopeのフィールドに参照を保持しておく形式で実装することにします。

実装

最初にLifetimeScopeの実装を示します。

AutoCollectLifetimeScope.cs
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using UnityEngine.SceneManagement;
using VContainer;
using VContainer.Unity;

public abstract class AutoCollectLifetimeScope : LifetimeScope
{
    /// <summary>
    /// 自動で収集を行うか
    /// </summary>
    [SerializeField] protected bool autoCollect = true;

    /// <summary>
    /// 収集したGameObjectを格納するフィールド
    /// </summary>
    [SerializeField] protected GameObject[] autoCollectGameObjects;

    protected override void Configure(IContainerBuilder builder)
    {
        builder.RegisterBuildCallback(resolver =>
        {
            foreach (var autoCollectGameObject in autoCollectGameObjects)
            {
                try
                {
                    // autoCollectGameObjectsにはシーン内のInject属性が付加されたメンバを持つGameObjectが全て格納されているため
                    // このLifetimeScopeでは解決できない参照を要求している可能性がある
                    resolver.InjectGameObject(autoCollectGameObject);
                    Debug.Log($"Resolve: {autoCollectGameObject.name}");
                }
                catch (VContainerException)
                {
                    // 参照解決が行えなかった時に例外を握りつぶす
                    Debug.Log($"{GetType().Name} cannot resolve: {autoCollectGameObject.name}");
                }
            }
        });
    }

#if UNITY_EDITOR
    /// <summary>
    /// 渡されたシーン内のInject対象を全探索してautoCollectGameObjectsに格納する
    /// </summary>
    /// <param name="scene">探索対象のシーン</param>
    public virtual void CollectGameObjects(Scene scene)
    {
        if (autoCollect) autoCollectGameObjects = FindAllGameObjectsRequiringInjection(scene);
    }

    /// <summary>
    /// 渡されたシーン内のInject対象を全探索して返す
    /// </summary>
    /// <param name="scene">探索対象のシーン</param>
    /// <returns>Inject対象のGameObjectの配列</returns>
    private static GameObject[] FindAllGameObjectsRequiringInjection(Scene scene)
    {
        var monoBehaviours = new List<MonoBehaviour>();
        var rootObjects = scene.GetRootGameObjects();
        foreach (var rootObject in rootObjects)
        {
            monoBehaviours.AddRange(rootObject.GetComponentsInChildren<MonoBehaviour>());
        }

        var injectingObjects = new List<GameObject>(monoBehaviours.Count);
        foreach (var monoBehaviour in monoBehaviours)
        {
            if (HasInjectAttribute(monoBehaviour.GetType()))
            {
                Debug.Log($"Find GameObject requiring injection: {monoBehaviour.name}");
                injectingObjects.Add(monoBehaviour.gameObject);
            }
        }

        return injectingObjects.ToArray();
    }

    /// <summary>
    /// 渡された型がInject属性が付加されたメンバを持っているかをリフレクションを用いて調べる
    /// </summary>
    /// <param name="type">調査対象の型(クラス・構造体など)</param>
    private static bool HasInjectAttribute(Type type)
    {
        var members = type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance |
                                      BindingFlags.DeclaredOnly);
        foreach (var memberInfo in members)
        {
            var injectAttributes = memberInfo.GetCustomAttributes(typeof(InjectAttribute));
            foreach (var _ in injectAttributes)
            {
                return true;
            }
        }

        return false;
    }
#endif
}

注意点として、エディタ上でのみ呼び出されることが想定されるメソッド群はUNITY_EDITORで囲んでビルド時に含まれないようにしています。ランタイムでも呼び出しを行いたい場合はこれを外してください。
参照解決を自動で行いたいLifetimeScopeを作るときはこのAutoCollectLifetimeScopeを継承して実装することになります。
実際に継承したLifetimeScopeのConfigure()では、autoCollectGameObjectsに対するインジェクトを実行するため基底のConfigure()を呼び出す必要があります。

GameLifetimeScope.cs
using VContainer;

public class GameLifetimeScope : AutoCollectLifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        // 基底のConfigureを呼び出す
        base.Configure(builder);
    }
}

次に、AutoCollectLifetimeScopeCollectGameObjects()メソッドをシーンセーブ時に自動的に呼び出す処理を書いていきます。

SceneSavingProcess.cs
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine.SceneManagement;

public static class SceneSavingProcess
{
    [InitializeOnLoadMethod]
    private static void RegisterSceneSavingEvent()
    {
        // シーンセーブ時のイベントにOnSavingを登録
        EditorSceneManager.sceneSaving += OnSaving;
    }

    private static void OnSaving(Scene scene, string path)
    {
        // 参照収集を実行
        ExecuteCollecting(scene, FindAutoCollectLifetimeScopes(scene));
    }

    /// <summary>
    /// シーン内の全てのAutoCollectLifetimeScopeを検索して返す
    /// </summary>
    private static List<AutoCollectLifetimeScope> FindAutoCollectLifetimeScopes(Scene scene)
    {
        var results = new List<AutoCollectLifetimeScope>();
        foreach (var rootObject in scene.GetRootGameObjects())
        {
            results.AddRange(rootObject.GetComponentsInChildren<AutoCollectLifetimeScope>());
        }

        return results;
    }

    /// <summary>
    /// 渡されたAutoCollectLifetimeScopeリストのすべてのCollectGameObjectsを呼び出す
    /// </summary>
    /// <param name="scene">CollectGameObjectsに渡すシーン</param>
    private static void ExecuteCollecting(Scene scene, List<AutoCollectLifetimeScope> autoCollectLifetimeScopes)
    {
        foreach (var autoCollectLifetimeScope in autoCollectLifetimeScopes)
        {
            autoCollectLifetimeScope.CollectGameObjects(scene);
            EditorUtility.SetDirty(autoCollectLifetimeScope);
        }
    }
}
#endif

SceneSavingProcessでは自身のOnSaving()メソッドをシーンセーブ時のイベントに登録し、シーン内のすべてのAutoCollectLifetimeScopeCollectGameObjects()をセーブ時に呼び出します。
このスクリプトをプロジェクト内の任意のディレクトリ(UNITY_EDITORで囲んでいるのでEditorディレクトリ以外でも可)に置くことで、シーンセーブ時にInject対象コンポーネントの自動収集が行われるようになります。

スクリーンショット (212).png
スクリーンショット (213).png

無事自動的に収集されました。

さいごに

事前に収集を行えば実行時のオーバーヘッドも少ないので、試してみてはいかがでしょうか。

6
5
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
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?