LoginSignup
66
44

More than 3 years have passed since last update.

2019年をふわっとまとめる 〜Unityにおけるアーキテクチャの自分なりの解〜

Last updated at Posted at 2019-12-31

はじめに

 2019年は、設計の年でした!

Unity3種の神器

・UniRx *1
・UniTask *2
・Zenject *3(Extenject *4)

これらがないと開発できない体になってしまった。

MV(R)P期

 UniRxといえばこれ!というくらいよくある書き方。去年から学び始めて今年の5月くらいまではずっとこの書き方をしていました。

mvrp.cs
using System;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace ProjectName.Scripts
{
    // Modelは、ScriptableObjectにするかPresenterないでnew して生成していた
    public class Model : ScriptableObject, IDisposable
    {
        private readonly ReactiveProperty<int> _countReactiveProperty = new ReactiveProperty<int>();
        public IReactiveProperty<int> CountReactiveProperty => _countReactiveProperty;

        public void CountUp()
        {
            _countReactiveProperty.Value++;
        }

        public void Dispose()
        {
            _countReactiveProperty?.Dispose();
        }
    }

    public class Presenter : MonoBehaviour
    {
        [SerializeField] private Model _model = default;
        [SerializeField] private View _view = default;

        private void Start()
        {
            Bind();
            SetEvent();
        }

        // Modelからパラメータの変更通知は全てここに書いていた
        private void Bind()
        {
            _model.CountReactiveProperty
                .TakeUntilDestroy(this)
                .Subscribe(_view.UpdateCount);
        }

        // Viewからの入力受け取りは全てここに書いていた
        private void SetEvent()
        {
            _view.OnCountUpAsObservable()
                .TakeUntilDestroy(this)
                .Subscribe(_ => _model.CountUp());
        }
    }

    public class View : MonoBehaviour
    {
        [SerializeField] private Text _text = default;
        [SerializeField] private Button _button = default;

        public IObservable<Unit> OnCountUpAsObservable()
        {
            return _button.OnClickAsObservable();
        }

        public void UpdateCount(int count)
        {
            _text.text = count.ToString();
        }
    }
}

 ViewとModelを独立させるだけもそこそこ書けていた。ただ、ScriptableObjectがやったら多くなったりInspectorでポチポチしたり、Presenterが肥大化したりと問題点も何かとあったり。。。
 このあたりでZenjectを知って本格的に設計について考えだすようになった。

Clean Architecture への突入

 いざ設計について考えだすと言っても、知識皆無だったのでどこに踏み出せばいいかわからず・・・。巷で噂のClean Architectureからとりあえず、と手をつけ始めたのが全ての始まり。いろんなサイトをみて回るもみんな書いてることが違う・・・^p^ とにかくいろんな記事を読んで試してふわふわした知識をひたすら身につけていた。7月あたりにお勧めされていた「Clean Architecture 達人に学ぶソフトウェアの構造と設計」を購入して知識を整理しだした(この本には大変お世話になりました)。そして2019年での最終的な理解は以下のように。

ディレクトリ構成

スクリーンショット 2019-12-30 23.45.14.png
uml.png

各レイヤーは、Assembly Definitionによって1つ上のレイヤしか参照できないように制限してあります。Applicationへの参照は特に制限していない。本を買う少し前までは依存性逆転の原則をよく理解していなくて、IOutputPortやらがViewレイヤにあったりした。

Domain

 Domaiは、最上位のレイヤーで、ロジックは全てここに書かれています。

Entity

Entityは、状態の管理、数値計算を行います。このレイヤはどのレイヤにも依存せず、基本的に変更も行わない。そして全ての処理は必ずこのレイヤを通過する。

Entity.cs
using System;
using UniRx;

namespace ProjectName.Scripts.Domain.Entity
{
    // このように値の更新と更新通知は別のinterfaceに分けた方がいいよなぁ
    // と思いつつも、つい一緒にまとめてしまう
    public interface IEntity
    {
        IReadOnlyReactiveProperty<int> ReactiveProperty { get; }
        void Update(int value);
    }

    // このEntityが、防御力を管理している場合、防御力の変化とダメージ計算は用途が違うので
    // interfaceも別にする
    public interface IDamageReduceEntity
    {
        int Calculation(int damage);
    }

    public class Entity : IEntity, IDamageReduceEntity, IDisposable
    {
        private readonly ReactiveProperty<int> _reactiveProperty = default;
        public IReadOnlyReactiveProperty<int> ReactiveProperty => _reactiveProperty;

        public Entity()
        {
            _reactiveProperty = new ReactiveProperty<int>();
        }

        // 状態の更新、場合によっては別のEntityによって計算された値をUseCaseから受け取ることもある
        public void Update(int value)
        {
            _reactiveProperty.Value = value;
        }

        // 例えば防御力など、受けたダメージから防御力の数値を引いた結果をUseCaseに返して
        // HPを管理しているEntityに渡すことも
        public int Calculation(int damage)
        {
            return damage - _reactiveProperty.Value;
        }

        public void Dispose()
        {
            _reactiveProperty?.Dispose();
        }
    }
}

 Entityに実装するinterfaceは、メソッドやプロパティを持ちすぎないように気を付けています。ここが肥大化すると以下のクラスで責務が集中したり、クラスによって使わないメソッドがあったりと全体に影響が出てしまいます。個人的な目安は、2つ以上のメソッドやプロパティを持ち始めたら、実際に使うシチュエーションを考えて見直すようにしています。

Use Case

 UseCaseは、各Entityを用いてロジックをゲーム内で使える形に変換して提供します。時にはDataレイヤからDBにアクセスしてEntityに書き込んだりもします。

UseCase.cs
using ProjectName.Scripts.Application.ValueObject;
using ProjectName.Scripts.Domain.Entity;
using UniRx;
using UniRx.Async;

namespace ProjectName.Scripts.Domain.UseCase
{
    // 本来は、TakeDamageとFindCharacterは全く用途が違うので別のクラスに分ける
    public interface IUseCase
    {
        IReadOnlyReactiveProperty<int> OnDefenseChangeAsObservable();
        void TakeDamage(int damage);
        void FindCharacter(string charecterId);
    }

    public interface IData
    {
        UniTask<CharacterData> FindCharacter(string characterId);
        CharacterData Find(string id);
    }

    public class UseCase : IUseCase
    {
        private readonly ILifeEntity _lifeEntity = default;
        private readonly IDamageReduceEntity _damageReduceEntity = default;
        private readonly IEntity _entity = default;
        private readonly IData _data = default;

        public UseCase(
            ILifeEntity lifeEntity,
            IDamageReduceEntity damageReduceEntity,
            IData data
        )
        {
            _lifeEntity = lifeEntity;
            _damageReduceEntity = damageReduceEntity;
            _data = data;
        }

        // EntityのReactivePropertyをそのまま流しているだけなので必要なのか?というお気持ちにになることもしばしば
        public IReadOnlyReactiveProperty<int> OnDefenseChangeAsObservable()
        {
            return _entity.ReactiveProperty;
        }

        public void TakeDamage(int damage)
        {
            _lifeEntity.TakeDamage(_damageReduceEntity.Calculation(damage));
        }

        public async void FindCharacter(string characterId)
        {
            var characterData = await _data.FindCharacter(characterId);
            // 受け取った結果を現在参照しているCharacterを管理するEntityに書き込む
        }
    }
}

 しょっちゅう肥大化するUseCase君。この辺はまだまだ慣れてなくてうまく設計できない。。。
EntityのReactivePropertyやSubjectをそのまま流すだけになることもあるのでこのレイヤはいらない子なのではとなることも。

Presentation

 表示やら入力やら、ユーザが触れる部分がこのレイヤになります。

Presenter

 全ての起点、Zenjectへの依存、Subscribeを許可しているのはこのレイヤーのみ(一部例外が・・・)。必ずIInitializable、IDisposableが実装されている。逆にこれ以外の実装を持つことはほとんどない。

Presenter.cs
using System;
using ProjectName.Scripts.Domain.UseCase;
using UniRx;
using Zenject;

namespace ProjectName.Scripts.Presentation.Presenter
{
    public interface IOutputPort
    {
        void ChangeDefense(int value);
    }

    public interface IInputPort
    {
        // 上位レイヤからのイベントはOnを付ける
        // View からの入力はOnを付けないようにしている
        IObservable<int> TakeDamageAsObservable();
    }

    public class Presenter : IInitializable, IDisposable
    {
        private readonly IOutputPort _outputPort = default;
        private readonly IInputPort _inputPort = default;
        private readonly IUseCase _useCase = default;

        private readonly CompositeDisposable _disposable = default;

        public Presenter(
            IOutputPort outputPort,
            IInputPort inputPort,
            IUseCase useCase
        )
        {
            _outputPort = outputPort;
            _inputPort = inputPort;
            _useCase = useCase;
            _disposable = new CompositeDisposable();
        }

        // 一時期、入力はController、出力はPresenterに分けようかと考えていたが
        // 面倒になったのと、下記方法でさほど問題を感じなかったのでPresenterに入力も出力も全て書くようにした
        public void Initialize()
        {
            Bind();
            SetEvent();
        }

        // MV(R)P期と同じく上位レイヤからのイベント通知監視はこちらに書く
        private void Bind()
        {
            _useCase.OnDefenseChangeAsObservable()
                .Subscribe(_outputPort.ChangeDefense)
                .AddTo(_disposable);
        }

        // View からの入力イベント監視はこちら側に書く
        private void SetEvent()
        {
            _inputPort.TakeDamageAsObservable()
                .Subscribe(_useCase.TakeDamage)
                .AddTo(_disposable);
        }

        public void Dispose()
        {
            _disposable?.Dispose();
        }
    }
}

 Viewが増えるたびにPresenterがどんどん増えていくので管理が結構大変になる。上手な管理方法や整理方法を考え中。。。

View

 MonoBehaviourを継承するレイヤ。IPoolableを実装する場合のみ、Zenjectへの依存を許可している。

View.cs
using System;
using ProjectName.Scripts.Presentation.Presenter;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace ProjectName.Scripts.Presentation.View
{
    // View間の参照はViewのinterfaceに任せる
    public interface IDamageable
    {
        void TakeDamage(int damage);
    }

    public class View : MonoBehaviour, IOutputPort, IInputPort, IDamageable
    {
        [SerializeField] private Text _defenseText = default;
        [SerializeField] private Button _button = default;

        private readonly Subject<int> _damageSubject = new Subject<int>();

        public IObservable<int> TakeDamageAsObservable()
        {
            return _damageSubject.Publish().RefCount();
        }

        // Button などの入力もIObservableで返すように統一
        public IObservable<Unit> ButtonClickAsObservable()
        {
            return _button.OnClickAsObservable();
        }

        public void ChangeDefense(int value)
        {
            _defenseText.text = value.ToString();
        }

        public void TakeDamage(int damage)
        {
            _damageSubject.OnNext(damage);
        }

        private void OnDestroy()
        {
            _damageSubject.OnCompleted();
            _damageSubject.Dispose();
        }
    }
}

Data

 Dataレイヤは通信やらUnity外のデータアクセスを担当します。小中規模だとこの辺あまり使わないのであんまり自信なし。。。

Gateway

 Gadewayは、通信もしくはRepositoryへのアクセスを担当します。

Gateway.cs
using System;
using ProjectName.Scripts.Application.DTO;
using ProjectName.Scripts.Application.ValueObject;
using ProjectName.Scripts.Domain.UseCase;
using UniRx.Async;
using UnityEngine;
using UnityEngine.Networking;

namespace ProjectName.Scripts.Data.Gateway
{
    public interface IRepository
    {
        bool Contains(string id);
        CharacterData Find(string id);
    }

    public class Gateway : IData
    {
        private readonly IRepository _repository = default;

        public Gateway(IRepository repository)
        {
            _repository = repository;
        }

        public async UniTask<CharacterData> FindCharacter(string characterId)
        {
            var form = new WWWForm();
            form.AddField("character_id", characterId);

            var uri = "";
            using (var r = UnityWebRequest.Post(uri, form))
            {
                var result = await r.SendWebRequest();
                var dto = JsonUtility.FromJson<CharacterDTO>(result.downloadHandler.text);
                return new CharacterData(dto);
            }
        }

        public CharacterData Find(string id)
        {
            if (!_repository.Contains(id))
            {
                throw new NullReferenceException($"Not Found ID : {id}");
            }

            return _repository.Find(id);
        }
    }
}

 通信系はここに書くべきなのかなぁと思いつつ。ここには書いてないが、CancellationToken渡そうとすると結構エグい。

Repository

 ローカルのJsonやExcelを読んだり、ScriptableObjectにしたり。

Repository.cs
using System.Collections.Generic;
using System.Linq;
using ProjectName.Scripts.Application.DTO;
using ProjectName.Scripts.Application.ValueObject;
using ProjectName.Scripts.Data.Gateway;
using UnityEngine;

namespace ProjectName.Scripts.Data.Repository
{
    [CreateAssetMenu(fileName = "Repository", menuName = "Repository/Repository")]
    public class Repository : ScriptableObject, IRepository
    {
        [SerializeField] private List<CharacterDTO> _characters;

        public bool Contains(string id)
        {
            return _characters.Any(data => data.CharacterId == id);
        }

        public CharacterData Find(string id)
        {
            var dto = _characters.First(data => data.CharacterId == id);
            return new CharacterData(dto);
        }
    }
}

Application

Applicationレイヤは上記のレイヤとは独立していて、レイヤの参照は特に制限していない。

Value Object

 Value Objectは、各レイヤ間で受け渡すデータ構造を定義している。

CharacterData.cs
using ProjectName.Scripts.Application.DTO;

namespace ProjectName.Scripts.Application.ValueObject
{
    public class CharacterData
    {
        public string Id { get; }
        public string Name { get; }

        public CharacterData(CharacterDTO dto)
        {
            Id = dto.CharacterId;
            Name = dto.CharacterName;
        }
    }
}

Factory

 Factoryの定義は全部ここ。FactoryのみDiContainerをInjectすることを許可している。

Factory.cs
using ProjectName.Scripts.Application.ValueObject;
using ProjectName.Scripts.Presentation.View;
using Zenject;

namespace ProjectName.Scripts.Application.Factory
{
    public class ViewFactory : PlaceholderFactory<View>
    {
    }

    public class ScreenFactory : IFactory<ScreenEnum, View>
    {
        // FactoryのみDiContainerをInjectすることを許可している。
        private readonly DiContainer _container = default;
        private readonly View[] _views = default;

        public ScreenFactory(DiContainer container, View[] views)
        {
            _container = container;
            _views = views;
        }

        public View Create(ScreenEnum screen)
        {
            return _container.InstantiatePrefabForComponent<View>(_views[(int) screen]);
        }
    }
}

 UIの遷移にはScreenFactoryを作って、画面をStackで管理する方法をよく使ったり。

DTO

 外部から受け取るデータの構造はここに書く。[Serializable]を書かなきゃいけないことをよく忘れる。

CharacterDTO.cs
using System;
using UnityEngine;

namespace ProjectName.Scripts.Application.DTO
{
    [Serializable]
    public class CharacterDTO
    {
        [SerializeField]
        private string character_id;
        public string CharacterId => character_id;
        [SerializeField]
        private string character_name;
        public string CharacterName => character_name;

    }
}

Signal

 Signalは、Presenter → UseCase → Entity → UseCase → Presenter という流れを省略する意図で使っている。例えば音など、画面遷移など、1つのクラスに依存が集中することが多い割に中身は単純なクラスなど。なんか使いこなせてる感じがしない。

Signal.cs
using ProjectName.Scripts.Application.ValueObject;

namespace ProjectName.Scripts.Application.Signal
{
    public class SoundSignal
    {
        public SoundEnum SoundEnum { get; }

        public SoundSignal(SoundEnum soundEnum)
        {
            SoundEnum = soundEnum;
        }
    }
}

Installer

 Domain、Dataレイヤは、大体最上位シーンのSceneContextにBindしている。その他はPlefab単位でInstallerを作って1Prefabに1GameObjectContextという形にしてある。GameObjectContextをやたらめったら使う方法がいいのかは謎。特に画面遷移では、画面を生成破棄しているので、パフォーマンスよくない気がしてる。

Signal.cs
using ProjectName.Scripts.Presentation.Presenter;
using ProjectName.Scripts.Presentation.View;
using Zenject;

namespace ProjectName.Scripts.Installer
{
    // SceneContextにBindするときはScriptableObjectInstaller
    // それ以外のときはMonoInstallerを使うことが多い
    // Installerが肥大化したしたときは、用途やレイヤなどの粒度でInstaller<T>に区切るようにする
    public class Installer : MonoInstaller<Installer>
    {
        public override void InstallBindings()
        {
            // 使うのはほぼBindInterfacesTo<T>のみ
            // 以前はMonoBehaviourのBindにはZenjectBindingを使っていたが、
            // FromComponentOnRootを見つけてからはこっちに以降
            // (ドキュメントはよく読もう)
            Container.BindInterfacesTo<View>().FromComponentOnRoot();
            Container.BindInterfacesTo<Presenter>()
                .AsSingle();
        }
    }
}

Prefab運用

NestedPrefabをゴリゴリに使う。UIではPrefabの集合をScreenと定義して、画面の切り替えをScreenの生成破棄、表示非表示で管理している。
 基本的に1Prefabにつき1InstallerになるのでInstaller以下とPrefabs以下のディレクトリ構成は同じになる。

結局設計よくわからない

 約半年間Clean Architectureを勉強してきたが、結局これが最適解かと言われるとなんか違うよなぁとなる。アウトゲームではうまく機能するが、インゲームではUIとオブジェクトのScopeが噛み合わないような気がしてならない。今のところインゲーム用のGameObjectContextを用意して、それに専用のCanvasを生成させたりしている。
 レイヤの切り方もまだ冗長かなと思うこともしばしば。特にEntityからPresenterに繋ぐだけのUseCaseがいらない子のように感じたり。オニオンアーキテクチャなり別のレイヤの切り方も勉強しなきゃなぁというのが来年の課題。

おわりに

 半年、設計の勉強をしてきたけど奥が深すぎてズブズブと沼に・・・。このへんは慣れなのかなぁ。まだまだ小中規模の開発でしか試してないのでなんとも言えなさ。
 来年は他の設計にも軽く触ってみようかな。

全てのきっかけをくれた知見の塊「Unityゲーム開発者ギルド」はいいぞ。

参考

*1 UniRx
*2 UniTask
*3 Zenject
*4 Extenject
 

66
44
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
66
44