アーキテクチャや設計パターンを始める前に。
リーダビリティを確保して保守性を高める。
想定読者
- 筆者は Unity 以前に別の .NET 実行環境での開発を多く行ってきた背景があります
- デスクトップアプリ、Webアプリ、ゲームのバックエンド、B2B、B2Cなど
- Unity 初心者や似たような経歴で Unity に慣れない方、またはゲームループにとっつきにくい方に向いてると思います
元スライド
スライド一覧
1
2
3
4
5
6
7
8
9
10
11
おまけ
ReactivePorperty
Subject & IObservable は非同期的なイベントで使うが、変更通知を持つプロパティとしては ReactiveProperty を使う。
IReadOnlyReactiveProperty は IObservable
private readonly ReactiveProperty<bool> isHoge = new ReactiveProperty<bool>(false); public IReadOnlyReactiveProperty<bool> IsHoge => isHoge;
thomething.IsHoge .Subscribe(x => { // 値が変わった時の処理 }) .AddTo(this);
IObservable
public IReadOnlyReactiveProperty<bool> IsHoge { get; private set; } // Start や初期化処理で IsHoge = button.OnClickAsObservable .Select(_ => !IsHoge.Value) .ToReadOnlyReactiveProperty();
AsyncLazy
必ず使うわけではないので使う時だけ生成し、その際にインスタンスを保持したいという場合は AsyncLazy が使える。 AsyncLazy を使わない場合の一般的な例はプロパティのセッターを使うケース。(セッターで非同期はよくないので同期の方を使ってる)
private Texture sometimeUsing; public Texture SometimeUsing { get { if (sometimeUsing == null) { sometimeUsing = Resources.Load("SometimesUsing") as Texture; } return sometimeUsing; } }
AsyncLazy を使う場合。
// プロパティ public AsyncLazy<Texture> SometimesUsingLazy { get; private set; } // Start や初期化処理時に初期化する SometimesUsingLazy = UniTask.Lazy(async () => await Resources.LoadAsync("SometimesUsing") as Texture); // await して取り出す、2回目以降はロード済みの Texture が返ってくる var texture1 = await SometimesUsingLazy; var texture2 = await SometimesUsingLazy; var texture3 = await SometimesUsingLazy;
これで必要になった時にアクセスすると生成、保持されるようになる。 保持する対象はリソースやファイルアクセス、Webアクセスから取得するものなど1回の処理にコストがかかるものに向いている。 サーバーサイドではDBから取得するデータを定義して使うものだけ await して取り出すなどの利用シーンがあった。 覚えておけばどこかで使えるかも。
補足
SerializeField でどこまで参照関係を明確にするか
参照関係はHierarchyベースのツリー構造であるため、ノードとリーフが存在する。
Atomic Design の Molecule 相当が末端のリーフになる。 Molecule は、ボタンやテキストなどの Atom を組み合わせて作る"単一機能"。 Molecule を組み合わせた Organisms がノードとして参照関係を形成する。 "単一機能"の中では、SerializeField でも、GetComponent() でも良いが、自分か自分の配下のみを参照することは変わらない。
Sample Code では、DeckPane が Molecule、Contents が Organisms に該当する。
アプリの機能をどこで実装するか
リーフ = Molecule をUIパーツとして実装する場合、UIの制御などは Molecule で行えば良い。 アプリの機能を実装する場合は、それを束ねている Organisms で処理するのが楽。 個々に閉じた処理(UIパーツとしての振る舞い)は個々で行うべきであるが、アプリの機能を実装すると様々なところと相互作用が発生する。 末端などで処理しようとすると、末端からのイベントを受けて他の末端へ伝搬させ、更にその結果を受けて別の処理を... とイベントと処理のバインディングが煩雑になることが多い。 UIパーツはボタンなどのようにUIの機能のみを提供し、アプリの機能を含めないようにする。
Sample Code では、DeckPane はUIパーツとしてUIの制御を、Contents がアプリの機能を受け持つイメージで作ってある。
UIパーツでなくても"単一機能"として提供する場合は、閉じた内部処理はそのスクリプトの中でよしなにやればよい。 親から見えるパブリックプロパティやパブリックメソッド、イベント(UniRx)は、その"単一機能"の interface となることを意識する。
エントリーポイント
Unity の場合、エントリーポイントやWPFのStartupUrl、ASP.NETのスタートページの設定やStartupクラスのような明確な始まりを持っていない。 Unity スクリプトは、予め決められたイベント関数をそれぞれのタイミングでUnityから呼ばれることで処理が実行される。
1つのスクリプト内で実行される順は決まっているが、スクリプト間での実行順序は決まっておらず、どのスクリプトがどの順で呼ばれるかはUnityの都合次第である。 (Script Execution Order で設定はできるが、リーダビリティも保守性も低いので厳しいと思う) Main から Initialize や Update を伝搬させることは、処理の起点≒エントリーポイントを自分で作ることに相当する。
サンプルコード
Main
public class Main : MonoBehaviour { [SerializeField] private Player player; [SerializeField] private Environment environment; [SerializeField] private Navigation navigation; [SerializeField] private Network network; [SerializeField] private SampleView sampleView; async void Start() { await player.Initialize(); await environment.Initialize(); await navigation.Initialize(); await network.Initialize(); await sampleView.Initialize(network); } async void Update() { await player.InvokeOnUpdate(); await environment.InvokeOnUpdate(); await navigation.InvokeOnUpdate(); await network.InvokeOnUpdate(); await sampleView.InvokeOnUpdate(); } }
SampleView
public class SampleView : MonoBehaviour { [SerializeField] private Header header; [SerializeField] private Contents contents; [SerializeField] private Footer footer; public async UniTask Initialize(Network network) { await header.Initialize(); await contents.Initialize(network); await footer.Initialize(); } public async UniTask InvokeOnUpdate() { await header.InvokeOnUpdate(); await contents.InvokeOnUpdate(); await footer.InvokeOnUpdate(); } }
Contents
public class Contents : MonoBehaviour { [SerializeField] private HomeTab homeTab; [SerializeField] private HomePane homePane; [SerializeField] private DeckTab deckTab; [SerializeField] private DeckPane deckPane; [SerializeField] private QuestTab questTab; [SerializeField] private QuestPane questPane; private Network network; public async UniTask Initialize(Network network) { this.network = network; await homeTab.Initialize(); await homePane.Initialize(network); await deckTab.Initialize(); await deckPane.Initialize(network); await questTab.Initialize(); await questPane.Initialize(network); deckTab.OnSelected .Subscribe(_ => { // 選択された時の処理 }) .AddTo(this); } public async UniTask InvokeOnUpdate() { await homeTab.InvokeOnUpdate(); await homePane.InvokeOnUpdate(); await deckTab.InvokeOnUpdate(); await deckPane.InvokeOnUpdate(); await questTab.InvokeOnUpdate(); await questPane.InvokeOnUpdate(); } }
DeckTab
public class DeckTab : MonoBehaviour { [SerializeField] private Text tabLabel; [SerializeField] private Button tabButton; private readonly Subject<Unit> onSelected = new Subject<Unit>(); public IObservable<Unit> OnSelected => onSelected; public UniTask Initialize() { tabLabel.text = "Deck"; tabButton.OnClickAsObservable() .Subscribe(_ => { // クリック時の処理 onSelected.OnNext(Unit.Default); }) .AddTo(this); return UniTask.CompletedTask; } public UniTask InvokeOnUpdate() { return UniTask.CompletedTask; } }
DeckPane
public class DeckPane : MonoBehaviour { [SerializeField] private Text deckNameText; [SerializeField] private Button renameButton; [SerializeField] private Button editButton; [SerializeField] private GameObject cardPrefab; [SerializeField] private GameObject cardContainer; private Network network; private readonly Subject<Unit> onRenamed = new Subject<Unit>(); public IObservable<Unit> OnRenamed => onRenamed; private readonly Subject<Unit> onEdited = new Subject<Unit>(); public IObservable<Unit> OnEdited => onEdited; public async UniTask Initialize(Network network) { this.network = network; var deck = await network.API.GetCurrentDeck(); deckNameText.text = deck.Name; foreach (var card in deck.Cards) { Instantiate(cardPrefab, cardContainer.transform); } renameButton.OnClickAsObservable() .Subscribe(_ => { // クリック時の処理 onRenamed.OnNext(Unit.Default); }) .AddTo(this); editButton.OnClickAsObservable() .Subscribe(_ => { // クリック時の処理 onEdited.OnNext(Unit.Default); }) .AddTo(this); } public UniTask InvokeOnUpdate() { return UniTask.CompletedTask; } }