#はじめに
Zenject/Extenjectを使った方がなんか良いらしい。じゃぁ使い方を調べよう。そんな具合でそこから入ったものの、だいぶ飲み込むのに苦労した経験があります。特にZenject/Extenjectは多機能ですので、余計に要点を把握しにくいのではないかと思う所があります。
ですんで、そういったものを使う前の具体的な話からしていって、意義を書き留めておきたいなと思います。
#用語整理
###DI
Dependency Injection。依存性の注入。詳細は後述。
###DIコンテナ
DIをしてくれるフレームワーク。Zenject/Extenject、VContainer等。
###Zenject/Extenject
DIをしてくれるフレームワークの一種。基本的にZenjectもExtenjectも同じものだが、政治的要因でZenjectからExtenjectが分離した歴史を持つ。現在はExtenjectが本流だが、そういう経緯からExtenjectのことも含めてZenjectと呼称することが多い。本稿ではZenject/Extenjectと表記する。
###VContainer
DIをしてくれるフレームワークの一種。最近リリースされた。Zenject/Extenjectよりもシンプルで軽いらしい。
#今実装すること、将来実装すること
ゲーム制作も順調です。次のことを実装しようと思います。
- ハイスコアの記録
- 将来的にはサーバーに保存してスコアランキングを表示する
サーバーはまだ選定すら済んでいません。とりあえず、PlayerPrefsを使って保存することにしましょう。1ゲーム終了時にはGameControllerクラスのOnFinishedが実行されるとします。まずはこんなコードになるでしょう。
using UnityEngine;
public class GameController
{
・・・
private void OnFinished(int score, int currentHighScore)
{
if (score < currentHighScore) return;
PlayerPrefs.SetInt("Score", score);
PlayerPrefs.Save();
}
}
慣れた方ならGameControllerというネーミングに警戒心を覚えるでしょう。しかもそこに保存するコードを直接書くなんて!実際にここは今後書き換える可能性が非常に高いです。将来的にサーバーに保存したい所ですから。ハイスコアの保存に関することは一つのクラスにまとめておきましょう(単一責任の原則)。
using UnityEngine;
public class HighScoreRepository
{
public bool Save(int score, int currentHighScore)
{
if (score < currentHighScore) return false;
PlayerPrefs.SetInt("Score", score);
PlayerPrefs.Save();
return true;
}
}
public class GameController
{
private readonly HighScoreRepository _highScore = new HighScoreRepository();
・・・
public void OnFinished(int score, int currentHighScore)
{
_highScore.Save(score, currentHighScore);
}
}
Repositoryというのは永久保存する場所という意味です。HDDやサーバーなどです。これで少しは安眠できるでしょう。
#依存とその問題
サーバーが決まり、契約も済ませました。いつでも使える状態です。じゃぁHighScoreRepositoryを書き換えましょう。ただ、開発中はPlayerPrefsの方が便利なので、前のコードも残しておきたい所です。コメントアウトしておきましょうかね。コメントアウトする場所をちょっと弄れば、切り替えられます。
using UnityEngine;
///*
public class HighScoreRepository
{
public bool Save(int score, int currentHighScore)
{
if (score < currentHighScore) return false;
//サーバー保存処理
return true;
}
}
//*/
/*
public class HighScoreRepository
{
public bool Save(int score, int currentHighScore)
{
if (score < currentHighScore) return false;
PlayerPrefs.SetInt("Score", score);
PlayerPrefs.Save();
return true;
}
}
//*/
ちょっと不細工ですね。GameControllerを弄って、if文で切り替えられるようにしましょうか?それもまたリスクがあります。今はOnFinishedだけですが、今後他の場所でHighScoreRepository.Saveを実行する可能性はないでしょうか?そこでちゃんと忘れずにifで切り替えられるでしょうか?怖いですね。
GameControllerがHighScoreRepositoryに依存している状態です。HighScoreRepositoryやその周辺をいじるときは、GameControllerもうまく歩調を合わせてやらないといけません。面倒ですね。
#DI:依存性の注入
ここで出てきます。依存性の注入。GameControllerでは依存するHighScoreRepositoryを次のように書いていました。
private readonly HighScoreRepository _highScore = new HighScoreRepository();
これをGameControllerの外でやります。外で作るだけではあまり状況は変化しないので、HighScoreRepositoryをInterfaceにして、外で切り替えられるようにします。例えばこうします。
public interface IHighScoreRepository
{
bool Save(int score, int currentHighScore);
}
using UnityEngine;
public class HighScoreRepositoryPlayerPrefs : IHighScoreRepository
{
public bool Save(int score, int currentHighScore)
{
if (score < currentHighScore) return false;
PlayerPrefs.SetInt("Score", score);
PlayerPrefs.Save();
return true;
}
}
public class HighScoreRepositoryServer : IHighScoreRepository
{
public bool Save(int score, int currentHighScore)
{
if (score < currentHighScore) return false;
//サーバー保存処理
return true;
}
}
public class GameController
{
private readonly IHighScoreRepository _highScore;
public GameController(IHighScoreRepository highScoreRepository)
{
_highScore = highScoreRepository;
}
public void OnFinished(int score, int currentHighScore)
{
_highScore.Save(score, currentHighScore);
}
}
HighScoreRepositoryはInterfaceにしました。これは上述通り。で、注目するところはGameControllerのコンストラクタ。ここでIHighScoreRepositoryをもらうようにします。HighScoreRepositoryに依存していたわけですが、これを外から注入、つまり入れてやります。これが依存性の注入です。GameControllerにとってはPlayerPrefsかサーバーかどこかよく分からんけど、とりあえず良い所に保存される、という認識になります。
したがってGameControllerを作るときはこんな感じになります。
using UnityEngine;
public class GameControllerLoader : MonoBehaviour
{
private GameController _gameController;
void Start()
{
IHighScoreRepository highScore;
bool debugMode = true;
if (debugMode)
{
highScore = new HighScoreRepositoryPlayerPrefs();
}
else
{
highScore = new HighScoreRepositoryServer();
}
_gameController = new GameController(highScore);
}
}
#サーバーのみに必要なパラメータ
そういえば、せっかくハイスコアランキングに登録するのに名前が表示されないなんてちょっと寂しいですね。PlayerPrefsには必要ない要素でしたが。どうしましょうか?
IHighScoreRepository.Saveの引数に追加するのも一つです。しかし、PlayerPrefsには不要で、サーバー保存時のみに必要です。GameControllerも書き直さなきゃいけません。面倒ですね。じゃぁこうしましょう。
public interface INameGetter
{
string Get();
}
public class NameGetterConst : INameGetter
{
public string Get()
{
return "仕様書無しさん";
}
}
public class HighScoreRepositoryServer : IHighScoreRepository
{
private readonly INameGetter _name;
public HighScoreRepositoryServer(INameGetter name)
{
_name = name;
}
public bool Save(int score, int currentHighScore)
{
if (score < currentHighScore) return false;
//サーバー保存処理
//ここで _name.Get() を使う。
return true;
}
}
using UnityEngine;
public class GameControllerLoader : MonoBehaviour
{
private GameController _gameController;
void Start()
{
IHighScoreRepository highScore;
bool debugMode = true;
if (debugMode)
{
highScore = new HighScoreRepositoryPlayerPrefs();
}
else
{
var name = new NameGetterConst();
highScore = new HighScoreRepositoryServer(name);
}
_gameController = new GameController(highScore);
}
}
GameControllerを書き換えなくても済みました。HighScoreRepositoryServerの生成時に直接文字列を入れても良いのですが、将来はサーバー保存時に名前入力ダイアログを出したいので、またInterfaceにしました。今はサーバー保存の実装に注力して後で切り替えます。
#Loaderの肥大化
割とシンプルなプログラムですが、GameControllerLoaderは割と大きくなってきます。これからもどんどん大きくなるでしょう。単一責任の原則にしたがってクラスを作っていけば、それなりの数になります。この生成を管理するとなると面倒です。
はい、お待たせしました。ここで出てくるのがDIコンテナです。
##Zenject/Extenjectの場合
Zenject/Extenjectをインポートした後、SceneContextを作ります。で、MonoInstallerのスクリプトを作成、それを空のGameObjectにアタッチして、SceneContextのMonoInstallerにアタッチします。※この辺りの詳しい利用方法は検索すれば出てくると思います。
スクリプトは以下のようになります。
using Zenject;
public class Installer : MonoInstaller
{
public override void InstallBindings()
{
bool debugMode = true;
if (debugMode)
{
Container.Bind<IHighScoreRepository>().To<HighScoreRepositoryPlayerPrefs>().AsSingle();
}
else
{
Container.Bind<INameGetter>().To<NameGetterConst>().AsSingle();
Container.Bind<IHighScoreRepository>().To<HighScoreRepositoryServer>().AsSingle();
}
Container.Bind<GameController>().AsSingle();
}
}
using UnityEngine;
using Zenject;
public class GameControllerLoader : MonoBehaviour
{
[Inject] private GameController _gameController;
}
先ほどと同じように動くはずです。GameControllerLoader.StartがInstaller.InstallBindingsに移ったような感じです。
詳しく見ていきましょう。まず_gameControllerに[Inject]という属性がついています。Zenject/Extenjectはこれを探してきます。見つかったら、ここにインスタンスを放り込みます。
この放り込まれるインスタンスの型は予めZenject/Extenjectに伝えておかねばなりません。それがInstaller.InstallBindingsの
Container.Bind<GameController>().AsSingle();
です。AsSingle()はインスタンスを1個だけ作るという意味です。今回はGameControllerのInjectが1カ所しかありませんが、複数書かれる場合もあります。そのとき、常に同じ一つのインスタンスが挿入される、という意味です。
このようにZenject/ExtenjectはGameControllerのインスタンスを作ってくれる訳ですが、GameControllerのコンストラクタには引数がありました。IHighScoreRepositoryです。これについても何を挿入したら良いか、予めZenject/Extenjectに伝えておかねばなりません。それが
bool debugMode = true;
if (debugMode)
{
Container.Bind<IHighScoreRepository>().To<HighScoreRepositoryPlayerPrefs>().AsSingle();
}
else
{
Container.Bind<INameGetter>().To<NameGetterConst>().AsSingle();
Container.Bind<IHighScoreRepository>().To<HighScoreRepositoryServer>().AsSingle();
}
です。
debugMode==trueの時、IHighScoreRepositoryを挿入しなければいけない時はHighScoreRepositoryPlayerPrefsを作ってそれを入れて、という意味になります。
debugMode==falseだとHighScoreRepositoryServerになりますが、このコンストラクタでさらにINameGetterが要りますので、INameGetterをNameGetterConstに指定します。
##VContainerの場合
まずVContainerをインポートします(manifest.jsonに「"nuget.mono-cecil": "0.1.6-preview"」を追加するのを忘れずに)。で、下記のスクリプトを作って、空のGameObjectにアタッチします。InspectorにGameControllerLoader欄ができてるので、そこに上述のGameControllerLoaderのGameObjectを放り込んでおきます。
using UnityEngine;
using VContainer;
using VContainer.Unity;
public class GameLifetimeScope : LifetimeScope
{
[SerializeField] private GameControllerLoader gameControllerLoader;
protected override void Configure(IContainerBuilder builder)
{
bool debugMode = true;
if (debugMode)
{
builder.Register<IHighScoreRepository,HighScoreRepositoryPlayerPrefs>(Lifetime.Singleton);
}
else
{
builder.Register<INameGetter,NameGetterConst>(Lifetime.Singleton);
builder.Register<IHighScoreRepository, HighScoreRepositoryServer>(Lifetime.Singleton);
}
builder.Register<GameController>(Lifetime.Singleton);
builder.RegisterComponent(gameControllerLoader);
}
}
やってることはZenject/Extenjectと同じです。唯一違うのがこいつです。
builder.RegisterComponent(gameControllerLoader);
これはHierarchyにあるGameObjectについて、インジェクションをして欲しい対象を指定しなければいけません。Zenject/Extenjectはこれを自動でやってくれてたんですね。
##インジェクションの種類
[Inject]属性のあるフィールドやコンストラクタで必要なインスタンスを放り込んでくれることをそれぞれ、フィールドインジェクションやコンストラクタインジェクションと言います。メソッドインジェクションというのもあります。
[Inject]
public void Construct(GameController gameController)
{
//コンストラクタ代わりに実行される
}
Zenject/Extenject、VContainer共通です。
原則的にはコンストラクタによるインジェクションを基本とします。そもそも何かに依存するのを避けるために、DIコンテナを使わない方法から出発しました。なのにDIコンテナがないと動かないというのはちょっと矛盾します。
とはいえ、MonoBehaviourはコンストラクタを持てないので、代わりにフィールドインジェクションやメソッドインジェクションを使います。
#DIコンテナは重い?
まぁ重いとは言われます。リフレクションと言って、ソースコードの文字列を解析して、インジェクションが必要な所があれば、都度そこにインスタンスを放り込むという処理を行っているためです。そのためゲームを立ち上げるときなんかにちょっと時間がかかるようになるかもしれません。支障が出るほど重くなるのはちょっと考えにくいとは思います。
#DIの意義
おおよそのやり方は上述の通りです。これで何がしやすくなるのか。例えばテストがしやすくなります。サーバーが無くてもハイスコア関係の仮実装が可能で、それによってGameControllerも動かせるようになったのは見ての通りです。
また複数人で作るのにも有効ではないでしょうか。HighScoreRepositoryServerができていないから、GameControllerのOnFinishedが完成しない、といった事態も避けられます。
設計にお悩みの方は是非試してみてください。