VContainerのInject対象コンポーネントをシーンセーブ時に自動的に収集して保持する方法について書きました。
はじめに
VContainerはUnityで使われるDIフレームワークです。
これまでよく使われてきたDIフレームワークのExtenject(旧Zenject)と比較すると、高速である・参照解決時にフレームワーク側ではアロケーションが発生しない・ビルド時に含まれるサイズが小さい、などが特徴的です。
自分もUnityでゲーム制作をする際によく使うのですが、VContainerは実行速度を担保するため、またその設計思想上、シーン内に存在するMonoBehaviourに対して自動的にはインジェクトを行いません(LifetimeScope
のAutoInjectGameObjects
フィールドに設定したGameObject
とその子には参照解決を行うことができます)。
VContainerを開発した方であるhadashiAさんが書かれているドキュメントには自動的にインジェクトを行わない理由が述べられており、GameObject
やMonoBehaviour
の生成時に処理を挟むために用意されている良い方法がないこと、VContainerのようなDIコンテナをUnityで用いるメリットの一つとしてMonoBehaviour
のような寿命が不安定なViewコンポーネントから外部へ処理の起点を移せるということがあるが、その場合はMonoBehaviourにインジェクトするのではなくMonoBehaviourをインジェクトするのが自然な形になること、などがあげられています。
この思想は個人的にはとても共感できるものですが、その一方でMessagePipeのようなDI前提のライブラリを扱う際には、MonoBehaviourにInjectをする場面が多くなると思います(MonoBehaviour
がイベントの発行側になりえるため)。
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
の実装を示します。
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()
を呼び出す必要があります。
using VContainer;
public class GameLifetimeScope : AutoCollectLifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
// 基底のConfigureを呼び出す
base.Configure(builder);
}
}
次に、AutoCollectLifetimeScope
のCollectGameObjects()
メソッドをシーンセーブ時に自動的に呼び出す処理を書いていきます。
#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()
メソッドをシーンセーブ時のイベントに登録し、シーン内のすべてのAutoCollectLifetimeScope
のCollectGameObjects()
をセーブ時に呼び出します。
このスクリプトをプロジェクト内の任意のディレクトリ(UNITY_EDITOR
で囲んでいるのでEditor
ディレクトリ以外でも可)に置くことで、シーンセーブ時にInject対象コンポーネントの自動収集が行われるようになります。
無事自動的に収集されました。
さいごに
事前に収集を行えば実行時のオーバーヘッドも少ないので、試してみてはいかがでしょうか。