Quantcast
Channel: C#タグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 9743

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

$
0
0

はじめに

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

Unity3種の神器

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

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

MV(R)P期

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

mvrp.cs
usingSystem;usingUniRx;usingUnityEngine;usingUnityEngine.UI;namespaceProjectName.Scripts{// Modelは、ScriptableObjectにするかPresenterないでnew して生成していたpublicclassModel:ScriptableObject,IDisposable{privatereadonlyReactiveProperty<int>_countReactiveProperty=newReactiveProperty<int>();publicIReactiveProperty<int>CountReactiveProperty=>_countReactiveProperty;publicvoidCountUp(){_countReactiveProperty.Value++;}publicvoidDispose(){_countReactiveProperty?.Dispose();}}publicclassPresenter:MonoBehaviour{[SerializeField]privateModel_model=default;[SerializeField]privateView_view=default;privatevoidStart(){Bind();SetEvent();}// Modelからパラメータの変更通知は全てここに書いていたprivatevoidBind(){_model.CountReactiveProperty.TakeUntilDestroy(this).Subscribe(_view.UpdateCount);}// Viewからの入力受け取りは全てここに書いていたprivatevoidSetEvent(){_view.OnCountUpAsObservable().TakeUntilDestroy(this).Subscribe(_=>_model.CountUp());}}publicclassView:MonoBehaviour{[SerializeField]privateText_text=default;[SerializeField]privateButton_button=default;publicIObservable<Unit>OnCountUpAsObservable(){return_button.OnClickAsObservable();}publicvoidUpdateCount(intcount){_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
usingSystem;usingUniRx;namespaceProjectName.Scripts.Domain.Entity{// このように値の更新と更新通知は別のinterfaceに分けた方がいいよなぁ// と思いつつも、つい一緒にまとめてしまうpublicinterfaceIEntity{IReadOnlyReactiveProperty<int>ReactiveProperty{get;}voidUpdate(intvalue);}// このEntityが、防御力を管理している場合、防御力の変化とダメージ計算は用途が違うので// interfaceも別にするpublicinterfaceIDamageReduceEntity{intCalculation(intdamage);}publicclassEntity:IEntity,IDamageReduceEntity,IDisposable{privatereadonlyReactiveProperty<int>_reactiveProperty=default;publicIReadOnlyReactiveProperty<int>ReactiveProperty=>_reactiveProperty;publicEntity(){_reactiveProperty=newReactiveProperty<int>();}// 状態の更新、場合によっては別のEntityによって計算された値をUseCaseから受け取ることもあるpublicvoidUpdate(intvalue){_reactiveProperty.Value=value;}// 例えば防御力など、受けたダメージから防御力の数値を引いた結果をUseCaseに返して// HPを管理しているEntityに渡すこともpublicintCalculation(intdamage){returndamage-_reactiveProperty.Value;}publicvoidDispose(){_reactiveProperty?.Dispose();}}}

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

Use Case

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

UseCase.cs
usingProjectName.Scripts.Application.ValueObject;usingProjectName.Scripts.Domain.Entity;usingUniRx;usingUniRx.Async;namespaceProjectName.Scripts.Domain.UseCase{// 本来は、TakeDamageとFindCharacterは全く用途が違うので別のクラスに分けるpublicinterfaceIUseCase{IReadOnlyReactiveProperty<int>OnDefenseChangeAsObservable();voidTakeDamage(intdamage);voidFindCharacter(stringcharecterId);}publicinterfaceIData{UniTask<CharacterData>FindCharacter(stringcharacterId);CharacterDataFind(stringid);}publicclassUseCase:IUseCase{privatereadonlyILifeEntity_lifeEntity=default;privatereadonlyIDamageReduceEntity_damageReduceEntity=default;privatereadonlyIEntity_entity=default;privatereadonlyIData_data=default;publicUseCase(ILifeEntitylifeEntity,IDamageReduceEntitydamageReduceEntity,IDatadata){_lifeEntity=lifeEntity;_damageReduceEntity=damageReduceEntity;_data=data;}// EntityのReactivePropertyをそのまま流しているだけなので必要なのか?というお気持ちにになることもしばしばpublicIReadOnlyReactiveProperty<int>OnDefenseChangeAsObservable(){return_entity.ReactiveProperty;}publicvoidTakeDamage(intdamage){_lifeEntity.TakeDamage(_damageReduceEntity.Calculation(damage));}publicasyncvoidFindCharacter(stringcharacterId){varcharacterData=await_data.FindCharacter(characterId);// 受け取った結果を現在参照しているCharacterを管理するEntityに書き込む}}}

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

Presentation

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

Presenter

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

Presenter.cs
usingSystem;usingProjectName.Scripts.Domain.UseCase;usingUniRx;usingZenject;namespaceProjectName.Scripts.Presentation.Presenter{publicinterfaceIOutputPort{voidChangeDefense(intvalue);}publicinterfaceIInputPort{// 上位レイヤからのイベントはOnを付ける// View からの入力はOnを付けないようにしているIObservable<int>TakeDamageAsObservable();}publicclassPresenter:IInitializable,IDisposable{privatereadonlyIOutputPort_outputPort=default;privatereadonlyIInputPort_inputPort=default;privatereadonlyIUseCase_useCase=default;privatereadonlyCompositeDisposable_disposable=default;publicPresenter(IOutputPortoutputPort,IInputPortinputPort,IUseCaseuseCase){_outputPort=outputPort;_inputPort=inputPort;_useCase=useCase;_disposable=newCompositeDisposable();}// 一時期、入力はController、出力はPresenterに分けようかと考えていたが// 面倒になったのと、下記方法でさほど問題を感じなかったのでPresenterに入力も出力も全て書くようにしたpublicvoidInitialize(){Bind();SetEvent();}// MV(R)P期と同じく上位レイヤからのイベント通知監視はこちらに書くprivatevoidBind(){_useCase.OnDefenseChangeAsObservable().Subscribe(_outputPort.ChangeDefense).AddTo(_disposable);}// View からの入力イベント監視はこちら側に書くprivatevoidSetEvent(){_inputPort.TakeDamageAsObservable().Subscribe(_useCase.TakeDamage).AddTo(_disposable);}publicvoidDispose(){_disposable?.Dispose();}}}

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

View

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

View.cs
usingSystem;usingProjectName.Scripts.Presentation.Presenter;usingUniRx;usingUnityEngine;usingUnityEngine.UI;namespaceProjectName.Scripts.Presentation.View{// View間の参照はViewのinterfaceに任せるpublicinterfaceIDamageable{voidTakeDamage(intdamage);}publicclassView:MonoBehaviour,IOutputPort,IInputPort,IDamageable{[SerializeField]privateText_defenseText=default;[SerializeField]privateButton_button=default;privatereadonlySubject<int>_damageSubject=newSubject<int>();publicIObservable<int>TakeDamageAsObservable(){return_damageSubject.Publish().RefCount();}// Button などの入力もIObservableで返すように統一publicIObservable<Unit>ButtonClickAsObservable(){return_button.OnClickAsObservable();}publicvoidChangeDefense(intvalue){_defenseText.text=value.ToString();}publicvoidTakeDamage(intdamage){_damageSubject.OnNext(damage);}privatevoidOnDestroy(){_damageSubject.OnCompleted();_damageSubject.Dispose();}}}

Data

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

Gateway

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

Gateway.cs
usingSystem;usingProjectName.Scripts.Application.DTO;usingProjectName.Scripts.Application.ValueObject;usingProjectName.Scripts.Domain.UseCase;usingUniRx.Async;usingUnityEngine;usingUnityEngine.Networking;namespaceProjectName.Scripts.Data.Gateway{publicinterfaceIRepository{boolContains(stringid);CharacterDataFind(stringid);}publicclassGateway:IData{privatereadonlyIRepository_repository=default;publicGateway(IRepositoryrepository){_repository=repository;}publicasyncUniTask<CharacterData>FindCharacter(stringcharacterId){varform=newWWWForm();form.AddField("character_id",characterId);varuri="";using(varr=UnityWebRequest.Post(uri,form)){varresult=awaitr.SendWebRequest();vardto=JsonUtility.FromJson<CharacterDTO>(result.downloadHandler.text);returnnewCharacterData(dto);}}publicCharacterDataFind(stringid){if(!_repository.Contains(id)){thrownewNullReferenceException($"Not Found ID : {id}");}return_repository.Find(id);}}}

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

Repository

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

Repository.cs
usingSystem.Collections.Generic;usingSystem.Linq;usingProjectName.Scripts.Application.DTO;usingProjectName.Scripts.Application.ValueObject;usingProjectName.Scripts.Data.Gateway;usingUnityEngine;namespaceProjectName.Scripts.Data.Repository{[CreateAssetMenu(fileName="Repository",menuName="Repository/Repository")]publicclassRepository:ScriptableObject,IRepository{[SerializeField]privateList<CharacterDTO>_characters;publicboolContains(stringid){return_characters.Any(data=>data.CharacterId==id);}publicCharacterDataFind(stringid){vardto=_characters.First(data=>data.CharacterId==id);returnnewCharacterData(dto);}}}

Application

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

Value Object

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

CharacterData.cs
usingProjectName.Scripts.Application.DTO;namespaceProjectName.Scripts.Application.ValueObject{publicclassCharacterData{publicstringId{get;}publicstringName{get;}publicCharacterData(CharacterDTOdto){Id=dto.CharacterId;Name=dto.CharacterName;}}}

Factory

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

Factory.cs
usingProjectName.Scripts.Application.ValueObject;usingProjectName.Scripts.Presentation.View;usingZenject;namespaceProjectName.Scripts.Application.Factory{publicclassViewFactory:PlaceholderFactory<View>{}publicclassScreenFactory:IFactory<ScreenEnum,View>{// FactoryのみDiContainerをInjectすることを許可している。privatereadonlyDiContainer_container=default;privatereadonlyView[]_views=default;publicScreenFactory(DiContainercontainer,View[]views){_container=container;_views=views;}publicViewCreate(ScreenEnumscreen){return_container.InstantiatePrefabForComponent<View>(_views[(int)screen]);}}}

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

DTO

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

CharacterDTO.cs
usingSystem;usingUnityEngine;namespaceProjectName.Scripts.Application.DTO{[Serializable]publicclassCharacterDTO{[SerializeField]privatestringcharacter_id;publicstringCharacterId=>character_id;[SerializeField]privatestringcharacter_name;publicstringCharacterName=>character_name;}}

Signal

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

Signal.cs
usingProjectName.Scripts.Application.ValueObject;namespaceProjectName.Scripts.Application.Signal{publicclassSoundSignal{publicSoundEnumSoundEnum{get;}publicSoundSignal(SoundEnumsoundEnum){SoundEnum=soundEnum;}}}

Installer

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

Signal.cs
usingProjectName.Scripts.Presentation.Presenter;usingProjectName.Scripts.Presentation.View;usingZenject;namespaceProjectName.Scripts.Installer{// SceneContextにBindするときはScriptableObjectInstaller// それ以外のときはMonoInstallerを使うことが多い// Installerが肥大化したしたときは、用途やレイヤなどの粒度でInstaller<T>に区切るようにするpublicclassInstaller:MonoInstaller<Installer>{publicoverridevoidInstallBindings(){// 使うのはほぼ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
 


Viewing all articles
Browse latest Browse all 9743

Trending Articles