👾

[Unity] SRDebuggerとVContainerを用いて簡単で安全なデバッグ機能を実装する

に公開

はじめに

Unityでのゲーム開発時にデバッグ用のメニューを作ることはよくあると思います。エディタ上であればエディタ拡張で作ったり、実機でも使いたい場合は専用の画面を作ったりすることもあると思います。ただ、デバッグメニューはうまく実装しないとコード中に #if UNITY_EDITOR などを書いてまわらないといけなくなるなど、案外設計力が求められる部分でもあると思っています。

今回はSRDebuggerとVContainerを用いてデバッグ機能を簡単に安全に実装してみた事例を紹介したいと思います。

SRDebuggerとVContainerについては各ドキュメントを参照のうえ、すでに導入済みとして説明していきます。

また、本記事ではデバッグ用の Scripting Define Symbol として !DISABLE_SRDEBUGGER でデバッグ処理を扱っていきます。

DISABLE_SRDEBUGGER はSRDebuggerのasmdefでこれが有効になっている場合はSRDebuggerが省かれるようになっています。今回はSRDebuggerにかなり依存したデバッグ機能になるのでこちらをそのまま利用します。

SRDebuggerのトリガー設定

SRDebuggerはデフォルトで画面の左上端をトリプルタップすることで表示できます。

他にも各端を設定で選べるのですが、自分の環境では呼び出しの手軽さと競合しづらい操作として、4本指タップでサッと開けるようにしたいと思い実装しました。

SRDebuggerManager.cs を作成して管理しています。

#if !DISABLE_SRDEBUGGER
using System;
using SRDebugger;
using UnityEngine;
using VContainer.Unity;

public class SRDebuggerManager : IStartable, ITickable, IDisposable
{
    readonly DynamicOptionContainer _optionContainer = new();
    bool _prevTriggered;

    public void Start()
    {
        if (!SRDebug.IsInitialized)
        {
            SRDebug.Init();
        }
        
        SRDebug.Instance.AddOptionContainer(_optionContainer);

        // (例)デフォルトオプションの追加
        _optionContainer.AddOption(
            OptionDefinition.FromMethod("Execute Debug Method1", ExecuteDebugMethod1, "Common Menu", int.MaxValue)
        );

        _optionContainer.AddOption(
            OptionDefinition.FromMethod("Execute Debug Method2", ExecuteDebugMethod2, "Common Menu", int.MaxValue)
        );

        void ExecuteDebugMethod1()
        {
            // デバッグ処理1
        }

        void ExecuteDebugMethod2()
        {
            // デバッグ処理2
        }
    }

    public void Tick()
    {
        var currentTouchCount = Input.touchCount;
        if (currentTouchCount < 4)
        {
            _prevTriggered = false;
            return;
        }

        if (_prevTriggered)
        {
            return;
        }

        _prevTriggered = true;
        if (SRDebug.Instance.IsDebugPanelVisible)
        {
            SRDebug.Instance.HideDebugPanel();
        }
        else if (IsActiveSrDebuggerTrigger())
        {
            SRDebug.Instance.ShowDebugPanel(DefaultTabs.Options);
        }
    }

    public void Dispose()
    {
        SRDebug.Instance.RemoveOptionContainer(_optionContainer);
    }

    static bool IsActiveSrDebuggerTrigger()
    {
        // 特定の画面を開いている場合はSRDebuggerを表示しない設定などを書く
        return true;
    }
}
#endif

SRDebuggerManagerは後の例のLifetimeScopeでRegisterしています。

また、エディタではSRDebuggerの設定から↑→↓←キーで開けるようにするのが個人的におすすめですが、キー移動系のゲームでは別の方がいいかもしれません。

MVP構成で特定のView(LifetimeScope)のときのみデバッグ機能を表示する

以下のように、PresenterがModelとViewの橋渡しをして、ViewとModelは疎結合になっているようなMVPの構成がVContainerで構築されているとします。

public class MyPresenter
{
    private readonly MyView _view;
    private readonly MyModel _model;

    public MyPresenter(MyView view, MyModel model)
    {
        _view = view;
        _model = model;
    }
    
    public void AddHp()
    {
         _model.Hp += 10;
         _view.UpdateHp(_model.Hp);
    }
}

public class MyView : MonoBehaviour
{
    public void UpdateHp(int hp)
    {
        Debug.Log(hp);
    }
}

public class MyModel
{
    public int Hp;
}

これにSRDebuggerのデバッグメニューを追加してPresenterのAddHpメソッドを実行してみようと思います。

デバッグメニューは独立したクラスとして、VContainerで切り離しやすくしておくというコンセプトで実装しています。

#if !DISABLE_SRDEBUGGER
using System;
using SRDebugger;
using VContainer.Unity;

public class MyDebugOption : IStartable, IDisposable
{
    const string CategoryName = "MyDebugOption";
    readonly DynamicOptionContainer _dynamicOptionContainer = new();
    
    readonly private MyPresenter _presenter;
    
    public MyDebugOption(MyPresenter presenter)
    {
        _presenter = presenter;
    }
    
    public void Start()
    {
        SRDebug.Instance.AddOptionContainer(_dynamicOptionContainer);
        var optionDefinition = OptionDefinition.FromMethod(
            "Execute",
            _presenter.AddHp,
            CategoryName,
            0
         );
        _dynamicOptionContainer.AddOption(optionDefinition);
        // 他にもデバッグメニューを追加したければ _dynamicOptionContainer.AddOptionしていく
    }
    
    public void Dispose()
    {
        SRDebug.Instance.RemoveOptionContainer(_dynamicOptionContainer);
    }
}
#endif

LifetimeScopeは以下のようにします。今回 SRDebuggerManager も例として含めています。

using UnityEngine;
using VContainer;
using VContainer.Unity;

public class MyLifetimeScope : LifetimeScope
{
    [SerializeField]
    private MyView _view;

    protected override void Configure(IContainerBuilder builder)
    {
#if !DISABLE_SRDEBUGGER
        builder.RegisterEntryPoint<SRDebuggerManager>();
        builder.RegisterEntryPoint<MyDebugOption>();
#endif

        builder.Register<MyPresenter>(Lifetime.Scoped);
        builder.Register<MyModel>(Lifetime.Scoped);
        builder.RegisterComponent(_view);
    }
}

あとはシーン上に MyLifetimeScopeMyView をアタッチしたオブジェクトを配置して再生します。

最終的にこのようになります。

VContainerを使うことで、MyLifetimeScopeのRegisterを追加するだけでModel, View, Presenterクラスへの影響を最小限にデバッグメニューを追加できます。

この実装によって、MyLifetimeScopeがビルドされたときにMyView用のデバッグメニューがオプションに追加され、MyLifetimeScopeが破棄されたときにデバッグメニューも消えるというような挙動を実現することができます。

VContainerが導入されていないプレーンな環境でも、MonoBehaviourのStartとOnDestroyなどで生存管理すれば同じようなオプション追加は出来ると思います。

このような設計にしておくのが良さそうだと思っていますが、さらなる課題は少しあります。

問題点1: Scripting Define Symbolをどうにかできないか

デバッグ機能を実装するうえで、できる限り気を遣いすぎなくても本番環境に入ってしまわない仕組みになっているとありがたいところです。

#if !DISABLE_SRDEBUGGER の書き忘れで入ってしまうとか本番用のコンパイルがコケるとかはよくあるミスで、できれば書かなくてもいいような環境になっていたいですよね。

いい仕組みにできないか考えてはみたものの、結局どこかでは書かないといけないので、依存関係を明確にするためにもLifetimeScopeには書くのがいいのかなという所感です。

asmdef単位で分けられるとEditorフォルダのように最も安全に切り離すことができますが、LifetimeScopeクラスがDebugクラスとMVPクラスの両方を参照する必要があり、asmdefで分けようとすると、LifetimeScope/MVPクラス/Debugクラスをそれぞれ別のasmdefにする必要があります。
レイヤー単位でこのようにasmdefを切っているならasmdefで分けることで、晴れて#if !DISABLE_SRDEBUGGERから脱却できるのですが、自分の環境では機能単位でasmdefを切りたかったので、Debug機能の実装のためにそこまで細かくasmdefを分けるコストの方が高いのでやめました。

自分の環境では本番ビルド時にSRDebuggerをフォルダごと削除するようにしたので、 SRDebuggerを使って実装されているクラスがもし本番ビルド向けに残ってしまっていてもそこでコンパイルエラーになるようになっており、自分の環境ではそれが落とし所のように思っています。

(なのでクラス自体を #if !DISABLE_SRDEBUGGER で囲っています)

問題点2: デバッグ用メソッドをpublicにする必要がある

これはデバッグ処理ですし、リフレクションを使うとかでいいような気がします。
デバッグしやすいように適切にメソッドを切り出すなどの設計も大切になってくるのではないでしょうか。

void ExecutePresenterMethod()
{
    var method = typeof(MyPresenter).GetMethod("AddHp", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
    method?.Invoke(_presenter, null);
}

おわりに

デバッグ機能を作ることでゲームの開発や動作確認の効率は格段に上がるため、デバッグ機能を追加する心理的ハードルやコストを低くするために初期段階から設計しておくことは大切だと思います。

実装アイデアの参考になれば幸いです。

Happy Elements

Discussion