前回の記事はまだ「密結合」
前回の記事では、Zenject Bindingsを使ってオブジェクト参照をモジュール間で切り離す方法を紹介しました。
しかし、実はまだ大きな問題が残っているのです…。それは「Buttonクラスを入れ替えられない」という問題です。
例えば、「uGUIのButtonを押したらCubeを表示する」のは開発初期のデバッグ目的で、今度は最終的な仕様である「World空間内のボタンオブジェクトに近づいてXボタンを押したらCubeを表示する」を実装するとしましょう。
現状のスクリプトは以下のようになっています。
using UnityEngine;
using UnityEngine.UI;
using Zenject;
[RequireComponent(typeof(Renderer))]
public class ButtonObserverDI : MonoBehaviour {
[Inject]
Button button;
Renderer targetRenderer;
void Start() {
button.onClick.AddListener(OnClick);
targetRenderer = GetComponent<Renderer>();
}
void OnClick() {
targetRenderer.enabled = true;
}
}
このコードは、Buttonクラスでなければ動きません。「近くでXボタンを押されたときにonClickイベントをInvokeする」クラスProximityButton
があった場合、以下のスクリプトを新規に用意しなければなりません。
using UnityEngine;
using UnityEngine.UI;
using Zenject;
[RequireComponent(typeof(Renderer))]
public class ProximityButtonObserver : MonoBehaviour {
[Inject]
ProximityButton button;
Renderer targetRenderer;
void Start() {
button.onClick.AddListener(OnClick);
targetRenderer = GetComponent<Renderer>();
}
void OnClick() {
targetRenderer.enabled = true;
}
}
めんどくさい!!
これはDRY原則 (Don't Repeat Yourself) に反しています。OnClick関数の中身を変えたときに2つとも変えないといけないので、ちょっと間違うとデバッグ用のクラスと振る舞いが違って大惨事になる危険性もあります。なにより、既存のButtonObserverDIクラスへの参照を全部ProximityButtonObserverクラスに置き換える必要があります。たとえButtonObserverDIクラスを上書きしたとしても、ProximityButtonだけでなくToggleProximityButtonなるものが出てきたら…きりがありません。
なぜこんなことになったかというと、ButtonObserverDIクラスがButtonクラスに密結合しているからです。密結合とは、あるクラスが別のクラスに強く依存してしまい、別のクラスに取り替えの効かない状態を指します。
(ついでに言えば、Rendererクラスにも密結合しています)
疎結合にする
この現状を打開するため、ButtonObserverDIクラスを疎結合(密結合の対義語)にしていきましょう。疎結合にすることで、ButtonクラスでもProximityButtonクラスでもToggleProximityButtonクラスでも動くようにできます。
ここでは2つの方法を紹介します。
C# インターフェースを使う
ButtonクラスとProximityButtonクラスは「~されたらonClickイベントをInvokeする」という点が共通しています。そして、ButtonObserverDIクラスから必要としていたのは、まさにその点だけです。
よって、まずはこの共通点をインターフェースにします。
using System;
public interface IClickEventDispatcher {
event Action onClick;
}
そして、Button向けにこれを実装したクラスを書きます。
using System;
using UnityEngine;
public class ClickEventDispatcher : MonoBehaviour, IClickEventDispatcher {
public event Action onClick;
public void Fire() {
onClick.Invoke();
}
}
インスペクタで、ButtonのOnClickイベントにFire関数を呼ぶよう設定しておきます。
最後に、ButtonやProximityButtonと疎結合になったクラスは以下のようになります。
using UnityEngine;
using Zenject;
[RequireComponent(typeof(Renderer))]
public class ClickEventObserver : MonoBehaviour {
[Inject]
IClickEventDispatcher clickEventDispatcher;
Renderer targetRenderer;
void Start() {
clickEventDispatcher.onClick += OnClick;
targetRenderer = GetComponent<Renderer>();
}
void OnClick() {
targetRenderer.enabled = true;
}
}
インスペクタ上はこんな感じになります。
Zenject BindingのBind TypeはAll Interfaceにしておきましょう。
これで、IClickEventDispatcherインターフェースを実装しているクラスであれば、どんなクラスでもCubeの表示を切り替えることができるようになりました。
今回のケースでは、ProximityButtonをClickEventDispatcherクラスに依存させることもできますし、IClickEventDispatcherを実装したProximityButtonクラスを作ってもいいでしょう。
やれやれ、これでデバッグ用の実装を壊さずに本番の実装に取りかかれそうです。
Zenject Signalsを使う
今回のケースのようなObserverパターンを使うことが多いからか、ZenjectにはSignalsという機能があります。これを使うことでも、ButtonやProximityButtonクラスに対して疎結合にできます。
まず、ButtonやProximityButtonからFireするSignalを定義しましょう。
public class ClickSignal {}
このSignalをContainerに登録するInstallerを書きます。
using Zenject;
public class ClickSignalInstaller : MonoInstaller<ClickSignalInstaller> {
public override void InstallBindings() {
SignalBusInstaller.Install(Container);
Container.DeclareSignal<ClickSignal>();
}
}
このInstallerを任意のGameObjectにAddComponentして、Scene ContextのInstallersリストに登録しておきます。
これまで使ってきたZenject Bindingsは、選択したComponentをContainerに登録するInstallerを自動生成してくれるコンポーネントです。Zenjectは、Containerに登録された情報を使って[Inject]
のついたフィールドなどにDependency Injectionする、という感じです。
SignalをFireする側はこんな感じで書きます。
using UnityEngine;
using Zenject;
public class FireClickSignal : MonoBehaviour {
[Inject]
SignalBus signalBus;
public void Fire() {
signalBus.Fire<ClickSignal>();
}
}
Fire関数をButtonのOnClickイベントに登録しておきます。ProximityButtonの場合は直接SignalBus.Fire
してもよいでしょう。
このSignalをObserveする側はこんな感じです。メソッドに対しても[Inject]
を付けることができます。初期化でしか使わない変数であれば、こちらのほうがフィールドを減らせてよさそうです。Awake/Start関数とInjectionの前後も気にしなくてよくなります。
using UnityEngine;
using Zenject;
using UnityEngine.Events;
public class ClickObserver : MonoBehaviour {
public UnityEvent OnClick = new UnityEvent();
[Inject]
void Init(SignalBus bus) {
bus.Subscribe<ClickSignal>(() => { OnClick.Invoke(); });
}
}
ClickObserver.OnClickにRendererのenabledを登録すれば終わりです。
ClickObserverクラスからは、ButtonやProximityButtonクラスへの参照は一切なく、疎結合になっていることがわかります。
シーンやインスペクタは以下のようになります。
補足
ClickSignalクラスにフィールドを追加すると、Fireする側から情報を追加してObserverで取り出すこともできるみたいです。これを使えばCubeだけでなくSphereやCapsuleがある場合でも問題なく実装できそうですね。
まとめ
前回の記事では肝心の疎結合について何も触れていなかったので、今回は疎結合な設計の例を書いてみました。
例によってZenjectは始めたばかりなので、コメントやTwitterでいろいろ教えていただけると幸いです。
次の記事ではZenjectを使った自動テストについて書いてみようかと思います。
ではでは!