ZenjectでSingletonを機械的に撲滅できた話

Zenjectの導入は「元々のプロジェクトがZenjectに向いた設計になっていないと難しい」というのはよく言われてきました。

自分も最近は依存関係や単体テストの観点から書くようになってきましたが、前に作ってメンテしている巨大Singleton依存プロジェクトや、引き継いだプロジェクトでZenjectに向いた作りになっていないものには導入できないなあと思っていました。

しかし、最近目にしたizmさんの記事「怠惰な人の為の最小Zenject入門」を見て目からウロコが落ちました。

概要 良いZenject/Extenjectの入門は ZenjectチョットワカルBook にまとまっているので、それを読んで「Zenject/Extenj...

曰く「とりあえずZenject/Extenjectを入れて、ProjectContextだけを使って、グローバルなシングルトンを撲滅してみませんか。」
おおっ?ということで導入してみると、これが効果ばつぐんで感動したのでメモ的に残しておきます。

導入方法などについては上記に詳しく書いてあるので、Singleton対応の部分を自分のコードをサンプルに書いていきます。

まず、これが昔作ったゲームのSingletonのGameManagerです。

public class GameManager : SingletonMonoBehaviour<GameManager> 
{
(略)
	//GLOBAL VARIABLES
	public GameState currentState = GameState.None;
	public GameState nextState = GameState.None;
 
	//ブロックが置かれたときの判定
	public void OnBlockDrop(bool isCorrect)
	{
		(略)
	}
(略)
}

わかりやすいように //GLOBAL VARIABLES と書いてあるからOK!と当時は思っていたのかもしれません。
しかし、Singletonが持つpublicな変数やメソッドはどこからでも呼ぶことができるため、逆に言えばどこから呼ばれているかわかりません。また、実は呼ばれていない可能性もあります。スクリプトの数が少なければまだ把握できますが、Singletonが複数あって、それを使うスクリプトが数十個ある、という状態になると本当につらくなります。

これを使う(依存している)クラスはこんなです。

public class DragMove : MonoBehaviour
{
(略)
	void OnMouseUp()
	{
                if(GameManager.Instance.currentState != GameState.StageStart)
                {
                    return;
                }
 
                if(enterTarget)
                {
                    GameManager.Instance.OnBlockDrop(true);
                }
                else if (enterOther)
                {
                    GameManager.Instance.OnBlockDrop(false);
                }
    }
(略)
}

まずは、このクラスがGameManagerに依存しているので、その依存を断ち切るところから始めます。「依存関係逆転の原則」で、使うのはinterfaceですね!

「依存関係逆転の原則」=「クラスに依存せずにinterfaceに依存せよ」

まずはGameManagerを対応していきます。
GameManager用のinterfaceを作って、GameManagerはそれを実装する形にし、Singletonではなくします。

interface IGameManager {
}
 
public class GameManager : MonoBehaviour,IGameManager 
{
(略、変更なし)
}

おいちょっと、interfaceに何も定義されてないじゃない!と思うかもしれません。
でも、まずはそれでいいのです(ドヤ顔

では、依存する側のDragMoveを対応していきます。

public class DragMove : MonoBehaviour
{
        IGameManager _gameManager = default;
 
        [Inject]
        public void Construct(IGameManager gameManager)
        {
            _gameManager = gameManager;
        }
 
        if(_gameManager.currentState != GameState.StageStart)
        {
            return;
        }
 
        if(enterTarget)
        {
            _gameManager.OnBlockDrop(true);
        }
        else if (enterOther)
        {
            _gameManager.OnBlockDrop(false);
        }
}

IGameManager型の変数_gameManagerを用意して、ZenjectのInject対象になる[Inject]アトリビュートをつけたConstructメソッドでGameManagerが注入されるようにします。
そして、GameManager.Instance.HogeHogeと書いていた部分は、エディタの検索置換機能を使って、全て_gameManager.HogeHoge と書き換えてしまいます(もちろんバックアップは取っておいてくださいね)

そして注入の条件を書くInstallerには、

            Container.Bind<IGameManager>()
                .To<GameManager>()
                .FromNewComponentOnNewGameObject()
                .AsSingle()
                .NonLazy();

と記述します。(セットアップの詳細は最初に紹介したQiitaをご確認ください。)
これで、IGameManagerが要求されるとGameManagerが注入されることになります。

この時点で、コンパイルエラーが発生しています。IGameManagerに何も定義していなかったので当然ですね。
これを1つ1つ処理していきます。
例えば _gameManager.currentState がエラーになっていたら、
IGameManagerにはプロパティ

GameState currentState {get; set;}

を定義し、それを実装するGameManagerでは

public GameState currentState {get; set;}

を定義します。自動実装プロパティでうまくいかない時には、privateの変数にアクセスするプロパティを実装してください。

メソッドについても _gameManager.OnBlockDrop(true) がエラーになるので、IGameManagerに

void OnBlockDrop(bool isCollect);

を記載し、GameManagerはそれを実装する状態にします。

public class GameManager : MonoBehaviour,IGameManager 
{
(略)
	private GameState _currentState = GameState.None;
	public GameState currentState {get => _currentState;set => _currentState = value;}
	private GameState _nextState = GameState.None;
	public GameState nextState  {get => _nextState;set => _nextState = value;}
 
	//ブロックが置かれたときの判定
	public void OnBlockDrop(bool isCorrect)
	{
		(略)
	}
(略)
}
 
interface IGameManager {
	GameState currentState{get; set;}
	GameState nextState{get; set;}
	void OnBlockDrop(bool isCorrect);
}

これでコンパイルエラーが消えたと思いますので実行してみましょう。

最初にinterfaceの中身を定義しなかったのは、この手順を各依存クラスに対して行っていくと、GameManagerでpublicだったけど、実際はどこからも使われていない変数やメソッドが明らかになるためです。
まるでテスト駆動開発で、最初はエラーから始まって、エラー対応することにより実装が進む、という手順みたいですね。外から使われていないのにpublicになってる変数やメソッドが判明したら速やかにprivateにしてしまいましょう。

これで、ZenjectによってSingletonが撲滅されましたが、結局はSingleton相当のものが残っている、という事には変わりありません。しかし、依存する側は明確に[Inject]メソッドを書かなければ使用できなくなりましたし、interfaceに定義されていない項目は外からは使用されていないことが保証されるようになりました。

そして、GameManagerそのものが存在しなくても、IGameManagerを実装したモックを作成すれば、それぞれのクラスだけで単体テストが行えるようになりました。ここが非常に大きいところですね。

Zenjectを使うために設計を大きく変えるのはモチベーション的にも工数的にも厳しいものがありますが、この手順で結果的に少し良い設計が得られるならば取り組んでみる価値は大いにあると思います!

スポンサーリンク

シェアする

  • このエントリーをはてなブックマークに追加

フォローする

スポンサーリンク