yotiky Tech Blog

とあるエンジニアの備忘録

Unity - 設計・実装のコツ

アーキテクチャや設計パターンを始める前に。

リーダビリティを確保して保守性を高める。

想定読者

  • 筆者は Unity 以前に別の .NET 実行環境での開発を多く行ってきた背景があります
    • デスクトップアプリ、Webアプリ、ゲームのバックエンド、B2B、B2Cなど
  • Unity 初心者や似たような経歴で Unity に慣れない方、またはゲームループにとっつきにくい方に向いてると思います

元スライド

スライド一覧

1

2

3

4

5

6

7

8

9

10

11

おまけ

ReactivePorperty

Subject & IObservable は非同期的なイベントで使うが、変更通知を持つプロパティとしては ReactiveProperty を使う。 IReadOnlyReactiveProperty は IObservable を実装するので、Subject & IObservable とまったく同じように購読できる。 変更通知≒非同期的なイベント と似た性質を持つが、通知がない時でも .Value で値を読み取ることができる点が異なり本質はプロパティである。

private readonly ReactiveProperty<bool> isHoge = new ReactiveProperty<bool>(false);
public IReadOnlyReactiveProperty<bool> IsHoge => isHoge;
thomething.IsHoge
    .Subscribe(x => 
    {
        // 値が変わった時の処理
    })
    .AddTo(this);

IObservableからReactivePropertyを生成することもできる。

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 を伝搬させることは、処理の起点≒エントリーポイントを自分で作ることに相当する。

https://docs.unity3d.com/ja/2019.4/uploads/Main/monobehaviour_flowchart.svg

docs.unity3d.com

サンプルコード

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;
        }
    }

サンプルプロジェクト

github.com