UnityでMV(R)P+Zenjectでテストを行いマルチシーンも試してみる

はじめに

UnityでのUI周りの設計としてMV(R)Pというものが有名です。 ViewとModelを分離して疎結合にできます。 ネットで調べればMV(R)Pについてはたくさん記事が出てくるので今更記事を書くのもあれですが、 自分でもかんたんなMV(R)Pを試してみたので忘備録としてメモします。

  • Unity: 2019.2.2f1
  • UniRx: 7.1.0
  • Zenject: 7.3.1

追記 2019/08/26

  • IfNotBound()を利用するように変更。
  • Dispose()でSubscribeを破棄するように変更。

MV(R)P

UniRxのunitypackageをReleases · neuecc/UniRxからダウンロードし導入します。

次の3つのクラスを用意します。

まずはTestHPView.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

public class TestHPView : MonoBehaviour {
public TextMeshProUGUI text;
public Button button;

public IObservable<Unit> OnDamage => button.OnClickAsObservable ();

public void DisplayHP (int hp) {
text.text = hp.ToString ();
}
}

次にTestHPModel.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using UniRx;
using UnityEngine;

public class TestHPModel : MonoBehaviour {
private IntReactiveProperty _hp = new IntReactiveProperty (100);
public IReadOnlyReactiveProperty<int> HP => _hp;
public void Damage (int damage) {
var tmp = _hp.Value - damage;
if (tmp < 0) {
_hp.Value = 0;
} else {
_hp.Value = tmp;
}
}
}

最後にTestPresenter.csを用意します。

1
2
3
4
5
6
7
8
9
10
11
12
13
using UniRx;
using UnityEngine;

public class TestPresenter : MonoBehaviour {
public TestHPView TestHPView;
public TestHPModel TestHPModel;
void Start () {
TestHPModel.HP.Subscribe (TestHPView.DisplayHP);
TestHPView.OnDamage.Subscribe (
_ => TestHPModel.Damage (20)
);
}
}

シーンにボタンとTextMeshProUGUIを配置します。

スクリプトを貼り付けて参照もドラッグ&ドロップで解決します。

実行してみると問題なく動作するはずです。

Zenjectを導入する

MV(R)PとZenjectは相性がよいです。 ModelやPresenterからMonoBehaviourを取り除けたり、 テストが書きやすくなったりします。

Zenjectをアセットストアから導入します。

エラーが出るのでSampleGame1(Beginner)を削除します。

【Unity】ZenjectをImportするとエラーが出る - Qiita

さきほどのクラスを修正します。

まずはTestHPModel.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using UniRx;

public class TestHPModel {
private IntReactiveProperty _hp = new IntReactiveProperty (100);
public IReadOnlyReactiveProperty<int> HP => _hp;
public void Damage (int damage) {
var tmp = _hp.Value - damage;
if (tmp < 0) {
_hp.Value = 0;
} else {
_hp.Value = tmp;
}
}
}

必要なくなったMonoBehaviourを削除します。

次にTestPresenter.cs

1
2
3
4
5
6
7
8
9
10
using UniRx;

public class TestPresenter {
public TestPresenter (TestHPView testHPView, TestHPModel testHPModel) {
testHPModel.HP.Subscribe (testHPView.DisplayHP);
testHPView.OnDamage.Subscribe (
_ => testHPModel.Damage (20)
);
}
}

コンストラクタでTestHPViewとTestHPModelを受け取るように変更します。

ZenjectのSceneContextをシーンに配置します。

インストーラを書いていきます。

「Create > Zenject > MonoInstaller」を選択します。

TestInstaller.csという名前で保存しました。 TestInstaller.csの中身を書いていきます。

1
2
3
4
5
6
7
8
using Zenject;

public class TestInstaller : MonoInstaller {
public override void InstallBindings () {
Container.Bind<TestHPModel> ().AsCached ();
Container.Bind<TestPresenter> ().AsCached ().NonLazy ();
}
}

このMonoInstallerを適当なオブジェクトにアタッチしておきます。

SceneContextにインストーラを追加します。

TestHPView.csにZenjectBindingをアタッチして設定します。

これで実行してみると次のような感じ。

問題なく動いています。

テストを書いてみる

書くのが楽なModelのテストを行います。 Viewのテストは面倒なのでスキップします。

Assets/Testsディレクトリを作成します。

「Window > General > Test Runner」を開いて 「Create EditMode Test Assembly Folder」を実行し EditModeテスト用のディレクトリを作成します。

Assets/ScriptsにAssembly Definitionを作成します。

作成したAssembly Definition Referencesを適切に設定します。

EditoModeテストのAssembly DefinitionのReferencesも設定します。

テストコードを書きます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

namespace Tests {
public class TestHPModelTest {
[Test]
public void HPの初期値は100 () {
var hpModel = new TestHPModel ();
Assert.That (hpModel.HP.Value, Is.EqualTo (100));
}

[Test]
public void ダメージを受けるとHPが減少する() {
var hpModel = new TestHPModel ();
Assert.That (hpModel.HP.Value, Is.EqualTo (100));
hpModel.Damage (30);
Assert.That (hpModel.HP.Value, Is.EqualTo (70));
}

[Test]
public void ダメージ0ではHPは変化しない() {
var hpModel = new TestHPModel ();
Assert.That (hpModel.HP.Value, Is.EqualTo (100));
hpModel.Damage (0);
Assert.That (hpModel.HP.Value, Is.EqualTo (100));
}

[Test]
public void HP以上のダメージを受けたらHPは0になる() {
var hpModel = new TestHPModel ();
Assert.That (hpModel.HP.Value, Is.EqualTo (100));
hpModel.Damage (120);
Assert.That (hpModel.HP.Value, Is.EqualTo (0));
}

[Test]
public void 負のダメージはHPを変化させない() {
var hpModel = new TestHPModel ();
Assert.That (hpModel.HP.Value, Is.EqualTo (100));
hpModel.Damage (-50);
Assert.That (hpModel.HP.Value, Is.EqualTo (100));
}
}
}

Damageメソッドを呼んだらHPが減ることを確認します。 ダメージが0の場合やHPを超えるダメージの場合も検証しています。 ダメージがマイナスの値だった場合には何もしないことにします。

テストを実行してみるとダメージがマイナスの値だった場合にテストが落ちています。

TestHPModel.csを修正します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using UniRx;
using UnityEngine;

public class TestHPModel {
private IntReactiveProperty _hp = new IntReactiveProperty (100);
public IReadOnlyReactiveProperty<int> HP => _hp;
public void Damage (int damage) {
if (damage < 0) return;

var tmp = _hp.Value - damage;
if (tmp < 0) {
_hp.Value = 0;
} else {
_hp.Value = tmp;
Debug.Log (tmp);
}
}
}

テストが無事とおりました。


次にMoqを使う例としてPresenterのテストを書いてみます。

まずはZenjectの中にあるzipを解凍します。

次にMoq-Net46を解凍します。

EditModeテストのAssembly DefinitionファイルのReferencesにMoq.dllを設定します。 ZenjectとZenject-TtestFrameworkもReferenceに追加します。

次にMoqを使うためにコードをInterfaceを使うように修正します。

ITestHPView.csを作成します。

1
2
3
4
5
6
7
using System;
using UniRx;

public interface ITestHPView {
IObservable<Unit> OnDamage { get; }
void DisplayHP (int hp);
}

TestHPViewをインタフェースを実装するように変更します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

public class TestHPView : MonoBehaviour, ITestHPView {
public TextMeshProUGUI text;
public Button button;

public IObservable<Unit> OnDamage => button.OnClickAsObservable ();

public void DisplayHP (int hp) {
text.text = hp.ToString ();
}
}

ITestHPModel.csを作成します。

1
2
3
4
5
6
using UniRx;

public interface ITestHPModel {
IReadOnlyReactiveProperty<int> HP { get; }
void Damage (int damage);
}

TestHPModel.csがインタフェースを実装するようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using UniRx;

public class TestHPModel : ITestHPModel {
private IntReactiveProperty _hp = new IntReactiveProperty (100);
public IReadOnlyReactiveProperty<int> HP => _hp;
public void Damage (int damage) {
if (damage < 0) return;

var tmp = _hp.Value - damage;
if (tmp < 0) {
_hp.Value = 0;
} else {
_hp.Value = tmp;
}
}
}

TestPresenterをインタフェースを使うように変更します。

1
2
3
4
5
6
7
8
9
10
using UniRx;

public class TestPresenter {
public TestPresenter (ITestHPView testHPView, ITestHPModel testHPModel) {
testHPModel.HP.Subscribe (testHPView.DisplayHP);
testHPView.OnDamage.Subscribe (
_ => testHPModel.Damage (20)
);
}
}

インストーラーを変更します。

1
2
3
4
5
6
7
8
using Zenject;

public class TestInstaller : MonoInstaller {
public override void InstallBindings () {
Container.Bind<ITestHPModel> ().To<TestHPModel> ().AsCached ();
Container.Bind<TestPresenter> ().AsCached ().NonLazy ();
}
}

Zenject BindingのBind Typeを変更します。

Moqでのテストはこのようにインタフェースが必要になってくるので設計にも影響が出てきます。 場合によっては設計が複雑になってしまうかもしれません。 モックを使ってまで単体テストを作るかどうかは考えたほうが良いかもしれません。

テストコードを書いていきます。 Assets/Tests/EditMode/TestPresenterTest.csを作ります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using Moq;
using NUnit.Framework;
using UniRx;
using Zenject;

namespace Tests {
[TestFixture]
public class TestPresenterTest : ZenjectUnitTestFixture {
Mock<ITestHPModel> _mockModel;
Mock<ITestHPView> _mockView;
Subject<Unit> _onDamageSubject;
IntReactiveProperty _hpIntReactiveProperty;

[SetUp]
public void SetUp () {
_mockModel = new Mock<ITestHPModel> ();
_mockView = new Mock<ITestHPView> ();
_onDamageSubject = new Subject<Unit> ();
_mockView.Setup (x => x.OnDamage).Returns (_onDamageSubject);
_hpIntReactiveProperty = new IntReactiveProperty (100);
_mockModel.SetupGet (x => x.HP).Returns (_hpIntReactiveProperty);

Container.BindInstance<ITestHPModel> (_mockModel.Object);
Container.BindInstance<ITestHPView> (_mockView.Object);
Container.Bind<TestPresenter> ().AsCached ();
}

[Test]
public void ViewのOnDamageが通知されるとModelのDamageが引数20で呼ばれる() {
var presenter = Container.Resolve<TestPresenter> ();
_onDamageSubject.OnNext (Unit.Default);
_mockModel.Verify (x => x.Damage (20), Times.Once);
}

[Test]
public void 初期化されるとViewのDisplayHPが呼ばれる() {
var presenter = Container.Resolve<TestPresenter> ();
_mockView.Verify (x => x.DisplayHP (100), Times.Once);
}

[Test]
public void ModelのHPが変更されるとViewのDisplayHPが呼ばれる() {
var presenter = Container.Resolve<TestPresenter> ();
_mockView.Verify (x => x.DisplayHP (100), Times.Once);
_hpIntReactiveProperty.Value = 60;
_mockView.Verify (x => x.DisplayHP (60), Times.Once);
}
}
}

ZenjectUnitTestFixtureを継承したクラスでテストを行っています。

Zenject/WritingAutomatedTests.md at master · modesttree/Zenject

ZenjectUnitTestFixtureは次のようなものだそうです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public abstract class ZenjectUnitTestFixture
{
DiContainer _container;

protected DiContainer Container
{
get
{
return _container;
}
}

[SetUp]
public virtual void Setup()
{
_container = new DiContainer();
}
}

[SetUp]でコンテナを作成し、Containerプロパティでアクセスできるようにしています。

今回はコンストラクタインジェクションなのでZenjectのコンテナを使わなくても 手動でMockオブジェクトをコンストラクタに渡してやればなんとかなりますが。 Fieldに[Inject]をつけている場合などは コンテナを使ってインジェクトする必要が出てくるでしょう。

SetUpでモックオブジェクトを作成してコンテナに登録しています。

各テストではモックオブジェクトのメソッドが正しく呼ばれたかを確認しています。

テストを実行してみると無事すべてのテストがとおることを確認できます。

マルチシーンに対応させる

マルチシーンに対応させてみます。 目標はシーンを遷移してもHPが保持されること。

まずは何も考えずマルチシーンにしてみます。

最初にシーン遷移用のボタンを配置します。

シーン遷移用のSceneLoader.csスクリプトを作ります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using UniRx;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class SceneLoader : MonoBehaviour {
public Button button;
public string sceneName;

void Start () {
button.onClick
.AsObservable ()
.Subscribe (_ => SceneManager.LoadScene (sceneName));
}
}

シーンをコピーし、それぞれのシーンの名前を互いにsceneNameに割り当てます。

Build Settingsでシーンを追加します。

実行してみるとこのようになります。

残念ながらシーンを読み込むとHPの値がリセットされてしまっています。 これはZenjectのSceneContextを利用していることによるものです。

解決する簡単な方法はProjectContextを使う方法です。

Assets/Resourcesフォルダを作り「Create > Zenject > ProjectContext」します。

Installerを2つに分けます。

1
2
3
4
5
6
7
8
using Zenject;

public class TestInstaller : MonoInstaller {
public override void InstallBindings () {
// Container.Bind<ITestHPModel> ().To<TestHPModel> ().AsCached ();
Container.Bind<TestPresenter> ().AsCached ().NonLazy ();
}
}
1
2
3
4
5
6
7
using Zenject;

public class TestHPModelInstaller : MonoInstaller {
public override void InstallBindings () {
Container.Bind<ITestHPModel> ().To<TestHPModel> ().AsCached ();
}
}

作成したMonoInstallerのうちTestHPModelの方をProjectContextに結びつけます。

これで実行してみると次のようになります。

SceneContextがあるシーンを読み込むと必ずProjectContextが読み込まれます。 ゲーム中に常に必要なマネージャなど、ゲーム全体で使い続けるものは このProjectContextを利用すると良さそうです。


HPはゲーム中で常に必要になりそうですが、シーン間で受け渡したい値にいよっては そうではないものもあるでしょう。 また、これはシーンのテストをしたい場合などにはじゃまになりそうです。

そこでもうひとつ別の方法を試してみます。 ZenjectSceneLoaderを利用してシーン間での受け渡しをやります。

ZenjectSceneLoaderでシーン間のパラメータ渡しをシンプルに実現する - Qiita

さきほど作成したProjectContextは破棄します。

TestInstaller.csをもとに戻します。

1
2
3
4
5
6
7
8
using Zenject;

public class TestInstaller : MonoInstaller {
public override void InstallBindings () {
Container.Bind<ITestHPModel> ().To<TestHPModel> ().AsCached ();
Container.Bind<TestPresenter> ().AsCached ().NonLazy ();
}
}

SceneLoader.csを次のように書き換えます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using UniRx;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using Zenject;

public class SceneLoader : MonoBehaviour {
[Inject]
ZenjectSceneLoader _sceneLoader;

[Inject]
ITestHPModel _hpModel;

public Button button;
public string sceneName;

void BindTestHPModel (DiContainer container) {
container.Bind<ITestHPModel> ().FromInstance (_hpModel);
}
void Start () {
button.onClick
.AsObservable ()
.Subscribe (_ => {
_sceneLoader.LoadScene (
sceneName,
LoadSceneMode.Single,
BindTestHPModel
);
});
}
}

残念ながらこれではうまく動きません。 シーンを遷移したとき二重にBindされてしまいます。

Installerを修正して二重にバインドされないよう変更します。

1
2
3
4
5
6
7
8
using Zenject;

public class TestInstaller : MonoInstaller {
public override void InstallBindings () {
Container.Bind<ITestHPModel> ().To<TestHPModel> ().AsCached ().IfNotBound ();
Container.Bind<TestPresenter> ().AsCached ().NonLazy ();
}
}

IfNotBound()を付け足しました。

これでシーンをまたいでHPの値を持ち越すことができました。


グローバルなマネージャはProjectContextを利用し、 そうでない場合は後者の方法も検討してみると良さそうです。


今回紹介した以外にもあと2つZenjectのマルチシーン用の機能があるそうです。

【Unity】Zenjectをマルチシーンで使うときの3つの方法 - かせノート。

それぞれ使い分けていきたいですね。

Subscribeの解除

今回のマルチシーンでは移動したSceneの数だけPresenterが生成されます。 PresenterでSubscribeしているわけですが、これの解除を行っていないため 無駄にメソッドがコールされてしまいます。

試しにTestView.csにDebug.Log(hp)を追加してみます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

public class TestHPView : MonoBehaviour, ITestHPView {
public TextMeshProUGUI text;
public Button button;

public IObservable<Unit> OnDamage => button.OnClickAsObservable ();

public void DisplayHP (int hp) {
Debug.Log (hp);
text.text = hp.ToString ();
}
}

実行してみるとシーンを遷移した数だけDisplayHP()が呼び出されていることがわかります。

そこでPresenterにIDisposableを実装させます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using UniRx;
using UnityEngine;

public class TestPresenter : IDisposable {
private IDisposable hpSubscribe;
private IDisposable onDamageSubscribe;
public TestPresenter (ITestHPView testHPView, ITestHPModel testHPModel) {
hpSubscribe = testHPModel.HP.Subscribe (testHPView.DisplayHP);
onDamageSubscribe = testHPView.OnDamage.Subscribe (
_ => testHPModel.Damage (20)
);
}

public void Dispose () {
Debug.Log ("Call Presenter Dispose()");
hpSubscribe.Dispose ();
onDamageSubscribe.Dispose ();
}
}

Installerを編集します。

1
2
3
4
5
6
7
8
using Zenject;

public class TestInstaller : MonoInstaller {
public override void InstallBindings () {
Container.Bind<ITestHPModel> ().To<TestHPModel> ().AsCached ().IfNotBound ();
Container.BindInterfacesAndSelfTo<TestPresenter> ().AsCached ().NonLazy ();
}
}

BindInterfacesAndSelfTo()を使ってIDisposableが呼ばれるようにしています。

【Unity】【Zenject】非MonoBehaviourをDIする際にMonoBehaviourのように振舞わせる - LIGHT11

これでシーンを遷移してもDisplayHP()が呼ばれるのは一度だけになりました。

おわりに

MV(R)P+Zenject+Unity Test Runnerと 流行り全部入りみたいな記事になりました。

今回のソースコードを入れたGitHubのリポジトリ