こんにちは、やまだたいし( やまだ たいし (@OrotiYamatano) | Twitter )です。
以前 UnityのMVP、MV(R)Pを調べたけど、どれが正しいんだ?という記事を書きましたが、
結構閲覧されている割に一番見て欲しいリポジトリの中身を見られていないようだったので、
今回はそのリポジトリの中身の解説をしていきます。
(合わせて2019verにバージョンアップをしました)
目次
中身について
中身はただボタンを押すと、カウントアップするものです。
使用ライブラリ
Zenject(Extenject)
ZenjectとはDIフレームワークです。
DI(Dependency Injection)は依存性の注入という意味ですが、かんたんに言うと、
半自動的にインスタンスを生成してくれる機能です。(ちょっと語弊があるケド……)
シングルトン(Singleton)とかは世界に一つだけで、グローバルにどこからでもアクセスできるという利点がありますが、
逆に言うと世界のどこからでもアクセスできてしまいます。
ゲーム業界では音声の管理やキー入力の管理など好んで使われますが、業界によってはグローバル変数と同じぐらい嫌われ者です。
そこで出てきたのがサービスロケーター(Service Locator)パターンと呼ばれるもので、
コレクションとして登録しておいて必要に応じてインスタンスを生成し取り出す形です。
もう少し分かりやすく言うと、「シングルトンなファクトリークラス(サービスロケーター)にインスタンスをキャッシュしておいて必要に応じて取り出す」って感じです。
手元のインスタンスに対して代入してから使うので、グローバル変数みたいに直接にアクセスするわけではないので、多少マシです。
しかしながら、サービスロケーターでは結局ずっとキャッシュを保持し続けることになり優しくありませんし、
消す仕組みをいれたとしても「シングルトンなファクトリークラス」を参照する必要があり密結合です。
そこで出てくるのがDIパターンです。
DIパターンは先にインスタンスを生成しておいて、生成しておいたクラスをインスタンスに代入してくれます。
使用側はあまり意識せずに利用できるような仕組みです。
詳細は割愛しますが、DIパターンを書くとコード量が多くなるので、DIのコードを書かず
いい感じにDIの良さを体験できるのがDIフレームワークです。
まぁかんたんに言えば、「シングルトンを寿命管理したりスコープ管理したり出来るいい感じのヤツ」がIDフレームワークと考えてくれれば良いです。
UnityのDIフレームワークで今回利用するのがZenject(Extenject)ってわけです。
ExtenjectとはZenjectの派生リポジトリなんですが、(権利周りで)なんやかんやあって
普通最新であるはずの本家リポジトリのZenjectより最新で使いやすいのでExtenjectの方を使いましょう。
UniRx
UniRxとは
Uni(Unity)Rx(Reactive Extensions)と名前の通り
Unityでリアクティブプログラミングをするためのライブラリです。
作者さんのオレオレライブラリって訳ではなく、ちゃんとした考えに則って作られてます。(本家Rx系から機能を移植されるかは作者の気分次第かも?)
Rxの強みは非同期処理とObservableパターンのプログラミングがしやすくなることだと私は考えます。
Observableパターンとは例えばボタンが押した時に処理が反応するという物を作りたい時、
普通ならボタンを押下という処理からボタンの処理という関数を呼び出すのが普通です。
ですが、Observableパターンは逆で、ボタンを押されたかを監視して、押されたら処理を実行するという処理になります。
ん?イベント駆動型とどう違うんだ?と思った貴方!鋭い!
実際にはイベント駆動型の場合別スレッドで受け取る側を用意していてボタンを押した次のフレームなどに処理されますが、
Observableパターンの場合は同一フレーム内で即実行される。とだけ、ここでは言っておきます。
詳しくは調べてください。
というかそんなことに気がつく貴方はここの記事読まなくても良いのでは?
とにかく、Observableパターンはそのようなイベント駆動型と似ていて、ライブラリを使えば、コードを短く書ける利点があるだけでなく、感覚的に処理が書けます。
もちろん学習コストはかかりますが、慣れれば手放したくないという人が、ほとんどです。
これまでゲームはシングルスレッドで、Update(もしくはTick)処理にて毎フレームゲームオブジェクトを更新したり更新しなかったりするのが普通でした。
しかし、ゲームのオブジェクトの巨大化やシミュレーションするオブジェクト数の増加により単純にUpdateの呼び出しにさえコストを計算する必要が出てきました。
そこで、マルチスレッド化をしたり、Updateを必要なときだけ呼び出して、他の場合は呼び出さないと言ったような処理をしたいとき、
UniRxのようなイベントベース、メッセージベースの機能は便利に働きます。
(Unityは早くシングルスレッドやめろ)
UniRxは必要なときにしかUpdateを呼び出さない処理が組み込まれています。(UpdateAsObservable)
また、頻繁に呼ばないと行けない時に呼び出す関数(Observable.EveryUpdate)などもあり、
使い分けをすることで普通にUnityでUpdate関数を呼び出すより高速化が測れます。
後、MonoBehaviour を継承していないクラスでUpdate出来たり……とにかく便利
作者の一言
前回紹介したMicroCoroutineを改良して、配列をお掃除しながら走査する(かつ配列走査速度は極力最高速を維持する)ようになった
どうやって早くしてるのかとかはUnity触ったことのある人なら一度はお世話になる
皆おなじみテラシュールブログの解説が分かりやすいらしいです。
まぁ、早くなるし、短く書けるのでオススメです。
MV(R)Pとは
まず、リポジトリの中身を説明する前にレイヤ別けの考え方を説明しておきます。
というか、MVP(MVC)については私より説明がうまいページがあるので、ここを読んできてください。
MVPをリアクティブプログラミングでやるのがMV(R)Pです。
この上のリンクのページもキレイにプログラムを書けているのですが、ライブラリを用いていないためコード数が多く複雑です。
ライブラリを使うともっとスッキリ書くことが出来ます。
後、UnityはMonoBehaviourによって支えられていますが、MonoBehaviourを継承して使っているということはMonoBehaviourに依存しているということでもあります。
きれいなソースコードを目指す人はできる限りMonoBehaviourを継承しないマッサラなクラスがきれいなので出来るだけ依存しないようにしましょう。
MonoBehaviourに依存していないコード、イコールUnityにアタッチしていないコードとなるのでUnity上でのコンポーネントのアタッチの考慮をしなくていいのでキレイです。
ソースコード
では、コードの中身を解説していきたいと思います。
Model
まずは分かりやすいModel(TestModel.cs)から。
まずメンバ変数として2つ定義します。
private readonly IntReactiveProperty num = new IntReactiveProperty(); public IReadOnlyReactiveProperty<int> Num => num;
いきなり、マッサラなC#しか触ってこなかった人だと分からない型が、出てきましたね?
これはUniRxで定義されている変数の型で、IntReactiveProperty
は値が変更した際にイベントのようなもの(OnNext)を発行する型です。
intの派生形だと思ってもらえばいいです。
IReadOnlyReactiveProperty
は pablic
で定義されていますが見ての通りReadOnlyで変数 num
を参照しています。
つまり Num
を外部から使えば num
の変更したタイミングと値を取得することが出来ます。
次にコンストラクタの解説に進みます。
private TestModel(){ num.Value = 0; //値のリセット }
はい。ただnumを初期化してるだけです。
次はビジネスロジックです。
といってもただ加算するものですので、ただ足すだけです。
public void CountUp() { num.Value++; }
はい。
これで終わりです。
以下がModelの全文。
using UniRx; namespace Sample.Models { /// <summary> /// Model /// ビジネスロジックはmodelに書く /// </summary> public class TestModel { private readonly IntReactiveProperty num = new IntReactiveProperty(); public IReadOnlyReactiveProperty<int> Num => num; private TestModel(){ num.Value = 0; //値のリセット } // カウントアップの処理(ビジネスロジック) public void CountUp() { num.Value++; } } }
簡単ですね。
UniRxを使わなくてもModelはほぼ同じような中身になると思います。
View
次はView(TestView.cs)の解説です。
ViewはUnityのオブジェクトにアタッチします。
メンバ変数の解説をします。
[SerializeField] private Button countButton = null; [SerializeField] private Text text = null; //ボタンがタッチされたらPresenterに通知 public IObservable<Unit> PushButtonObservable => countButton.onClick.AsObservable();
Unityお馴染みの SerializeField
で Button
と Text
を定義します。
もちろん、Unityエディタ側で対応するオブジェクトをアタッチしておきます。
次に IObservable<Unit>
で定義してあるものですが、こちらもUniRxの機能を使っています。
IObservableと書いている通り監視者です。
ボタン( countButton
)が押された時( onClick
)に IObservable型にキャスト( AsObservable
) して返す処理を監視します。
publicになっていますので、外部からコレを参照すると、クリックしたタイミングを取得できます。
次に見た目への反映部分の処理です。
//見た目へ変更を加える(Presenterから呼ばれる) public void TextMeshUguiSet(string str) { text.text = str; }
はい。
ただ、pubicメソッドでテキストを代入してるだけです。
これで終わりです。
以下がViewの全文。
using System; using UniRx; using UnityEngine; using UnityEngine.Serialization; using UnityEngine.UI; namespace Sample.Views { /// <summary> /// Viewの設定 /// </summary> public class TestView : MonoBehaviour { [SerializeField] private Button countButton = null; [SerializeField] private Text text = null; //ボタンがタッチされたらPresenterに通知 public IObservable<Unit> PushButtonObservable => countButton.onClick.AsObservable(); //見た目へ変更を加える(Presenterから呼ばれる) public void TextMeshUguiSet(string str) { text.text = str; } } }
簡単ですね。
Unityにアタッチしてる部分なのでそんなに難しい処理は無いです。
Presenter
次に仲介者であるPresenter(TestPresenter.cs)を解説していきます。
まずは、メンバ変数の解説。
//読み取りしかしない private readonly TestView testView; private readonly TestModel upButtonModel;
メンバ変数は、先程解説したTestViewとTestModelを変数として保持しています。
privateでreadonlyなので、ViewとModelは互いに存在を知りません。
コンストラクタの解説です。
//Presenterの処理 public TestPresenter(TestModel model, TestView view) { upButtonModel = model ?? throw new ArgumentNullException(nameof(model)); testView = view ? view : throw new ArgumentNullException(nameof(view)); //ModelとViewが増えたら追記していく upButtonModel.Num.Subscribe(ViewNumUpdate); //Modelに変更があったらViewへ更新 testView.PushButtonObservable.Subscribe(_=> CountUp()); //Viewからカウントアップ通知があったらModelを更新 }
はい、ここでModelとViewの中身を入れてます。
こちらでZenjectの コンストラクタ/メソッドインジェクション
を利用しています。
後でもう少し詳しく解説しますが、「先のModelとViewの中身を入れてるんだな~」ぐらいの認識でOKです。
ArgumentNullException
は中身なければエラー吐くぐらいな感じです。
定義忘れとかのチェックのために念の為書いておきます。
で、 upButtonModel.Num.Subscribe(ViewNumUpdate);
ですが、コメント文のとおりです。
Numの解説に「 Num
を外部から使えば num
の変更したタイミングと値を取得することが出来ます。」と書きましたが、ここで使用してます。
ViewNumUpdate
はメソッドです。メソッド呼び出しをするということです。
要約するとSubscribeでupButtonModelのNumつまり、Modelのnumに変更があったら変更通知を取得でき、ViewNumUpdateメソッド呼び出しをしているということです。
testView.PushButtonObservable.Subscribe(_=> CountUp());
ですが、コチラもコメント文のとおりです。
PushButtonObservableの説明で「外部からコレを参照すると、クリックしたタイミングを取得できます。」と書きましたが、ここで使用してます。(なんせ仲介者ですからね)
こちらはCountUpメソッドを呼んでいます。
では、ViewNumUpdateメソッドとCountUpメソッドの解説です。
と言っても解説するほどの内容は無いです。
/// <summary> /// Modelのカウントアップ処理を呼ぶ /// </summary> private void CountUp() { upButtonModel.CountUp(); } /// <summary> /// ViewのTextMeshUguiSetを呼ぶ /// </summary> /// <param name="num"></param> private void ViewNumUpdate(int num) { testView.TextMeshUguiSet(num.ToString()); }
はい、各Model,Viewの処理を引き継いで渡してる(仲介してる)だけです。
これで終わりです。
以下がPresenterの全文。
using System; using Sample.Models; using Sample.Views; using UniRx; namespace Sample.Presenter { /// <summary> /// Presenter /// Modelの変更をViewに反映し、ViewのアクションをModelへ反映 /// </summary> public class TestPresenter { //読み取りしかしない private readonly TestView testView; private readonly TestModel upButtonModel; //Presenterの処理 public TestPresenter(TestModel model, TestView view) { upButtonModel = model ?? throw new ArgumentNullException(nameof(model)); testView = view ? view : throw new ArgumentNullException(nameof(view)); //ModelとViewが増えたら追記していく upButtonModel.Num.Subscribe(ViewNumUpdate); //Modelに変更があったらViewへ更新 testView.PushButtonObservable.Subscribe(_=> CountUp()); //Viewからカウントアップ通知があったらModelを更新 } /// <summary> /// Modelのカウントアップ処理を呼ぶ /// </summary> private void CountUp() { upButtonModel.CountUp(); } /// <summary> /// ViewのTextMeshUguiSetを呼ぶ /// </summary> /// <param name="num"></param> private void ViewNumUpdate(int num) { testView.TextMeshUguiSet(num.ToString()); } } }
簡単ですね。
ViewとModelの仲介をしてるだけなので難しい処理は無いです。
見ての通りZenjectを使うとPresenterがMonoBehaviourを継承せずに済み、依存性が薄いクラスが出来上がります。
Zenjectinstaller
一気に説明します。
zenjectInstallerと名前にある通り、zenjectのインストール関連を管理しています。
Containerへのインストールですね。
今回はMonoInstallerを使用します。
定義したものはMonoBehaviourのように振る舞うようになります。
インストールの定義を書かれたものがInstallerのスクリプト。
TestModelをAsCached( ContractTypeが要求されるたびにResultTypeの同じインスタンスを再利用。これは最初の使用時に遅れて生成)。
TestPresenterをAsCached( ContractTypeが要求されるたびにResultTypeの同じインスタンスを再利用。これは最初の使用時に遅れて生成)。
した上で、NonLazy(これを指定すると最初にインスタンスが生成される)
using Sample.Models; using Sample.Presenter; using Zenject; namespace Sample.ZenjectInstaller { public class SampleButtonInstaller : MonoInstaller { //zenjectでModelとPresenterのインストールする public override void InstallBindings() { Container.Bind<TestModel>().AsCached(); Container.Bind<TestPresenter>().AsCached().NonLazy(); } } }
各オブジェクトの紐付け
TestModelとTestPresenterがMonoBehabiorのように振る舞うようになりましたが、肝心な部分を解説していません。
それぞれのオブジェクトをどうやってPresenterにつなぎ込んでいるのかです。
それはズバリ、SceneContextやZenjectBindingで解消されます!
後、マッサラなクラスやUnityにアタッチしているオブジェクト()をZenjectの SceneContext
や ZenjectBinding
に設定することで依存関係を直してれます。
まとめ
ぶっちゃけ、勢いで書いてみたものの、Zenject周りの解説があってるのかとか、
サービスロケーターの説明がちゃんと正しいのかはちょっと不安です。
でも、アウトゲーム部分を作る分にはキレイなコードだと自負しています。
(インゲームに適用するのはおすすめしません)
こんな雑な説明をしていますが、一応私も業務でUnityを使ったことがある身です。
説明は下手ですが、コードは問題ないと思います。ある程度なら肥大化しても耐えうるでしょう。
Zenjectは生成時がちょっと処理負荷が重いですが、そこはちゃんと使いこなして生成タイミングをずらしたりすれば良いと思います。
疎結合のコードが出来る良いものです。
みなさんも色々設計考えながらコーディングしてみてはいかがでしょうか?
以上で解説を終わります。
おすすめの記事とか
おまけ。
Editorフォルダ配下に途中まで、スクリプト自動生成スクリプトを書きました。
指定先のフォルダのtextを参照してクラスの雛形を作るだけです。
この辺を参考にすると作れます。
(Scriptが小文字になってるのが気に食わなくてソコだけはgifと変わってます)
(あ、削除する方は小文字で変え忘れてますね……ちゃんと消えない……まぁ、いいや)
using UnityEngine; using System.IO; using System.Text; using UnityEditor; // コードの自動生成 public class MVPRU : EditorWindow { private string _baseClassName = string.Empty; private string _sceneName = string.Empty; [MenuItem("Window/MVPRU")] private static void Open() { GetWindow<MVPRU>("MVPRU"); } private void OnGUI() { EditorGUILayout.LabelField("SceneName"); _sceneName = GUILayout.TextField(_sceneName); EditorGUILayout.LabelField("Create Base Class Name"); _baseClassName = EditorGUILayout.TextField(_baseClassName); if (GUILayout.Button("CreateScript")) { string path = Application.dataPath; string namePath = "Scripts/" + _sceneName + "/"; path += "/"+namePath; CreateScriptAsset(_sceneName+".Models", _baseClassName, "Model", path + "/Models",_sceneName); //CreateScriptAsset("Script."+_sceneName+".Presenters", _baseClassName, "Presenter", path+ "/Presenters",_sceneName); CreateScriptAsset(_sceneName+".Views", _baseClassName, "View", path + "/Views",_sceneName); Debug.Log($"Create Script Path : {path}"); } if (GUILayout.Button("ClearScript")) { string path = Application.dataPath; string namePath = "script/" + _sceneName + "/"; path += "/"+namePath; RemoveScriptAsset(_baseClassName, "Model", path + "/Models"); RemoveScriptAsset(_baseClassName, "Presenter", path+ "/Presenters"); RemoveScriptAsset(_baseClassName, "View", path + "/Views"); SafeCreateDirectory(path + "/ZenjectInstaller/"); Debug.Log($"Remove Script Path : {path}"); } } private const string TemplateScriptFilePath = "ScriptTemplate/"; private static void CreateScriptAsset(string nameSpace, string baseClassName, string domainName, string filePath,string sceneName) { string templateRawText = Resources.Load($"{TemplateScriptFilePath}{domainName}.cs").ToString(); string replacedText = templateRawText.Replace("#SCRIPTNAME#", baseClassName).Replace("#NAMESPACE", nameSpace).Replace("#SCRIPTSCENENAME", sceneName); var encoding = new UTF8Encoding(true, false); if (Path.GetExtension(filePath) != "") { // If you select Non directory, then get parent directory. filePath = Directory.GetParent(filePath).FullName + "/"; } SafeCreateDirectory(filePath); filePath += "/"; string fileName = $"{baseClassName}{domainName}.cs"; File.WriteAllText(filePath + fileName, replacedText, encoding); var createdScript = AssetDatabase.LoadAssetAtPath<MonoScript>(filePath + fileName); ProjectWindowUtil.ShowCreatedAsset(createdScript); AssetDatabase.Refresh(); } private static void RemoveScriptAsset( string baseClassName, string domainName, string filePath) { if (Path.GetExtension(filePath) != "") { // If you select Non directory, then get parent directory. filePath = Directory.GetParent(filePath).FullName + "/"; } filePath += "/"; string fileName = $"{baseClassName}{domainName}.cs"; File.Delete(filePath + fileName); AssetDatabase.Refresh(); } private static DirectoryInfo SafeCreateDirectory( string path ) { return Directory.Exists( path ) ? null : Directory.CreateDirectory( path ); } }