5. Pure C#を活用する
Unityと結婚しない方法、それは、MonoBehaviourを使わずPure C#によってクラスを設計することです。
なぜMonoBehaviourを使いたくないのか
Unityでコードを書く場合、一般的にはMonoBehaviourを継承します。これによって Start() や Update() などのUnityのライフサイクルメソッドが利用できたり、GameObjectにアタッチできるようになります。このようにUnity上でコードを動かすためにはMonoBehaviourが必須なので、そもそもMonoBehaviourを使わないという選択肢すら思い浮かばないかもしれません。
しかし、実はMonoBehaviourを使っていると色々と悪いことが起こります。まず、コンストラクタが使えません。よって第2回で生焼けオブジェクトについて説明したように、オブジェクトが生焼けになってしまいます。また、インスタンスを生成するにはいちいちGameObjectにアタッチしなくてはいけません。クラスが増えれば増えるほどこの手間は大きくなるし、アタッチし忘れも頻発します。他にも、ライフサイクルがUnityに支配されること、Unityがないとテストができないこと、などなど、とにかくMonoBehaviourを継承していて良いことは全然ありません。
Pure C#でコードを書く
では実際にPure C#でコードを書いてみましょう。先程のダメージ計算とスコアの例をPure C#で書き直してみます。
// Pure C#で書かれた上位レベルのクラス — MonoBehaviourを継承しない
using R3;
public class DamageCalculator
{
public int Calculate(int attack, int defense)
{
return Math.Max(attack - defense, 0);
}
}
public class ScoreRule
{
readonly ReactiveProperty<int> score = new(0);
public ReadOnlyReactiveProperty<int> Score => score;
public void AddScore(int amount)
{
score.Value += amount;
}
public void Dispose()
{
score.Dispose();
}
}
public class EnemyManager
{
readonly DamageCalculator damageCalculator;
readonly ScoreRule scoreRule;
// コンストラクタでDIする。生焼けにならない!
public EnemyManager(DamageCalculator damageCalculator, ScoreRule scoreRule)
{
this.damageCalculator = damageCalculator;
this.scoreRule = scoreRule;
}
public void OnEnemyDefeated(int playerAttack, int enemyDefense)
{
int damage = damageCalculator.Calculate(playerAttack, enemyDefense);
scoreRule.AddScore(damage * 10);
}
}
// → 全てPure C#。Unityが無くても動く
// → コンストラクタで依存性を注入するので生焼けにならない
// → 単体テストが容易ViewはGameObjectを扱うため、流石にMonoBehaviourを使う必要があります。しかし、Presenterはその必要がないので、Pure C#で書くことができます。
// ViewはMonoBehaviourを使う — UnityのUIに依存するのは仕方がない
using R3;
using UnityEngine;
using UnityEngine.UI;
public class ScoreView : MonoBehaviour
{
[SerializeField] Text scoreText;
public void SetScore(int score)
{
scoreText.text = $"Score: {score}";
}
}
// PresenterもPure C#で書ける — 橋渡しをするだけなのでMonoBehaviourは不要
public class ScorePresenter : IDisposable
{
readonly CompositeDisposable compositeDisposable = new();
public ScorePresenter(ScoreRule scoreRule, ScoreView scoreView)
{
scoreRule.Score.Subscribe(score =>
{
scoreView.SetScore(score);
}).AddTo(compositeDisposable);
}
public void Dispose()
{
compositeDisposable.Dispose();
}
}
// → ViewはMonoBehaviourを使う(UIの操作にUnityが必要だから)
// → しかしPresenterとScoreRuleはPure C#。Unityに依存しないここで、今まではGameObjectにアタッチすることでUnityがインスタンスを生成してくれ、DIもインスペクターから行えました。しかし、Pure C#になったことでそれらを全部自分でやる必要があります。そのため、各クラスは [SerializeField] の代わりにコンストラクタから必要なインスタンスを受け取るようにします。また、インスタンスの生成とDIを担当し、エントリーポイントを動かすクラスを作成します。このようなクラスをComposition Rootといいます。Composition Root自身は、自分を生成してくれるクラスが他にないのでこれもMonoBehaviourを使う必要があります。
// Composition Root: 全てのインスタンスを生成し、依存性を注入する
// MonoBehaviourを使う — Unityにインスタンスを生成してもらう必要があるため
public class GameLifeTimeScope : MonoBehaviour
{
// ViewはMonoBehaviourなのでインスペクターから注入
[SerializeField] ScoreView scoreView;
ScorePresenter scorePresenter;
void Start()
{
// Pure C#のインスタンスを自分で生成する
var damageCalculator = new DamageCalculator();
var scoreRule = new ScoreRule();
var enemyManager = new EnemyManager(damageCalculator, scoreRule);
// PresenterもPure C#なのでコンストラクタで生成・DI
scorePresenter = new ScorePresenter(scoreRule, scoreView);
// これで全ての依存関係が組み上がった!
}
void OnDestroy()
{
scorePresenter?.Dispose();
}
}
// → GameLifeTimeScopeが「何を生成し、何をどこに渡すか」を全て管理する
// → 各クラスは自分の依存先がどこから来るかを知る必要がない
// → GameLifeTimeScopeを見れば、プロジェクト全体の依存関係が一目で分かるこうすることで、Viewなどの下位で最低限MonoBehaviourを使うだけで、他のクラスは全てPure C#で書くことができます。
MonoBehaviourを使う場面
先程の例のように、MonoBehaviourを全てPure C#で代替できるわけではありません。ViewのクラスはGameObjectにアタッチするためにMonoBehaviourが必要ですし、Composition RootもUnityにインスタンスを生成して貰わなければ動きません。しかし、逆に言えばそれ以外のクラスは基本的にMonoBehaviourを使わなくても良いということです。つまり、ViewとComposition RootだけがMonoBehaviourを使い、その他は全てPure C#で書くのが理想です。