LoginSignup
53
43

More than 1 year has passed since last update.

【Unity】いまさらUnityのUIの効率的な開発手法を解説してみた【UI】

Last updated at Posted at 2022-12-20

TL;DR

結論:アトミックデザインを意識して作ると良いです。

はじめに

Unity2018.3で抜本的に仕組みが変わったUI(Prefab)システム.
そこで過去の経緯を含めて今時の効率よくUIを開発する方法を解説してみました。

また、UIは一般的に「簡単に作れる」と思われがちですが、設計を考えないで開発した場合、
あらゆる問題を引き起こします。

そのため、設計を行わないで作った時のアンチパターンも紹介していきます。

対象読者

  • Unity 初心者〜中級者
  • UNITE 基準ならBeginner ~ Intermidiate

過去の歴史的経緯

~Unity5 時代

Unity4~Unity5時代 (AD2014~2016) ではUnity4.6 でuGUI は存在こそしましたが、殆どが
NGUIという有償アセットを購入して開発されることがほとんどでした。

そのころはPrefabの子要素に別のPrefabを置くと、親Prefabに吸収されてしまうため、
子要素のPrefabを変更する場合、それを使用している箇所を頑張って手動なりで探して逐一更新する必要がありました。

また、この頃は iPod touch 等でもアプリを動かす場合もあり、メモリ効率化のために、画面単位でAtlasを作るなどを行なっていたために、同一アイコンが各種画面Atlas に複製されて存在していたために、「機能変更」に非常に弱い作りにもなり得ていました。

例) キャラアイコンを変更する→プロフィール画面、バトル画面、メンバー画面、デッキ画面全てを修正

徐々にノウハウが蓄積されてきたUnity5.x後半~Unity2018.2

Unity5.x後半~Unity2017前半あたりから、徐々にuGUI が改善されNGUI ではなくuGUIでの開発事例が増えてきました。
(NGUIの作者がUnityと協力してuGUI を開発してきたため)

この頃ではstatic(静的)なUIはCanvasのOverlay設定で作られたり、RectTransform を用いて開発されるため、よりWeb っぽい開発方法(Anchor やmargin, pivot の設定が容易) になってきました。

しかし依然としてPrefabの修正コストは高く、共通要素は「Inspectorに登録したPrefab」もしくは「Resouces.Load」によって動的に生成するしか実装手段がありませんでした。

ついにコミュニティ熱望のNestedPrefab/PrefabVariants が登場したUnity2018.3~

Unity2018.3にてPrefab周りの大規模な修正が入りました。
Prefab in Prefab を実現する NestedPrefab と Prefab のちょっとしたパラメータ違いを容易に作り出せる Prefab Variants です。

また、この機能が入るにあたり、Prefabの編集ワークフローも大きく変わりました。
そのため、運用系コンテンツは修正コストの観点からUnity2018.2 までしか更新できなくて、新規プロジェクトからUnity2018.3~ という事もちらほらありました。

Nested Prefab

いまではお馴染みとなったPrefab in Prefab を実装可能にしたNested Prefab。
Prefab 内部に配置したPrefab は「参照情報」として管理されるため、共通パーツを様々な部分で使い回すことが可能になりました。
スクリーンショット 2022-11-16 17.27.16.png
スクリーンショット 2022-11-16 17.27.27.png

Prefab Variants

あるPrefab でパラメータ違い(サイズ違い、色違い、使用テクスチャ違い等)を作る際、今までは元のPrefabを複製してパラメータを変更しないと、元のPrefab使用箇所全てに影響が出てしまいました。
また、1個のパラメータを変更するだけなのに、ほとんど同じ構成のPrefabを複数リソースとして保持しないといけないことから、無駄にアプリのサイズを増やす原因にもなっていました。

そこでPrefab Variants を使うことで、元のPrefabは保持しつつ、派生Prefabは「オリジナルへの参照情報」と「差分パラメータ情報」のみを持つことで表現ができる様になりました。
そのため派生したPrefab Variantsは必要最小限の容量になっています。

Original

Variants

今時のUIの開発手法

前提としてNestedPrefab とPrefabVariants を多用します。
Prefab in Prefab ができた結果

  • 共通パーツは徹底的に使い回す
  • パラメータ違いはPrefabVariants

という方針で作っていきます。

AtomicDesign

AtomicDesign(アトミックデザイン)とは、UI画面を「パーツ単位」で作成していき、それらを組み合わせて作成していくという手法です。

詳しくはDeNA Design の方々が公開している記事を参照ください。

このAtomicDesignですが、いまのUnityのPrefab作成環境と抜群にマッチします。
また、この手法に変えてからですが、経験則で恐縮ですがチームのUI開発工数が最大30%程度コストカットできたので、
特に人数が少ない小規模開発では必須の開発手法だと思います。

UnityとAtomicDesign

パーツ単位でUIを作成する = 必要最小限なPrefab を用意すると言い換えることができます。
具体的には

  • 基底となるUIPrefabを作る
  • 派生先となる要素を追加したPrefabはPrefab Variantとして表現する
    • 分子として作られるUIも他の原子となるUIパーツを追加することで対応(NestedPrefab)

という方針で作っていきます。

UI の基本設計

AtomicDesign では、パーツUI自体はなるべく以下の要件を満たせると良いです。

  • UI(View)はその要素を制御することに特化する
  • 基底パーツUI(原子UI) は独立して動くようにする
  • 原子は自身の要素以外には干渉しない

これらを満たす設計としておすすめなのは Model-View-ViewModel (MVVM) です。

MVVM

MVVM は以下の3つで構成されています。

要素 説明
Model ロジック記述クラス。(ピュアクラス可能)
ViewとViewModel をBind する役目も兼務。
基本的にはViewModel に対する操作を行う主体。
ViewModel ModelとViewをつなぐパラメータ定義クラス。
基本的にはObservableな形でパラメータを露出させる。MV(R)P のReactiveProperty(Presenter) 相当の機能を有する
View UI。
MonoBehaviour必須。
初期化時に与えてもらったViewModel を介して外とやりとりをする。

一般的なUI開発手法だと以下のように View側に public なAPI(メソッド) を設けてコールします。
しかし、このような手法だと UI側の修正ロジック側 にダイレクトに影響してしまいます。
そうすると UI変更 に対する開発コストがあがってしまいます。

スクリーンショット 2022-11-22 14.10.21.png

そこで以下の図のように ロジック(Model) は露出パラメータであるViewModelだけとやり取りを行い、UI(View)側もViewModel側とのみやりとりを行うようにすれば各々の修正の影響をViewModel側で堰き止めることが可能です。
特にModel側で更新したい値をどうViewに反映するかはView側で責任を持って管理が可能なため、1パラメータが複数箇所のUIを更新しなければならない場合など、更新箇所の選定はView内部で完結するため、Model側は反映の仕方を気にせずとにかくViewModelに伝達することに集中できます。

スクリーンショット 2022-11-22 14.10.15.png

MVVM以外にアーキテクチャといえば MVCやMVPがありますが、殊UnityのUI開発を行う際には ModelController(Presenter) の違いが分かりづらかったりします。
しかしMVVMでは 上記の表のように明示的に役割が分割され、特にViewModel はUIのパラメータ1つにつき1つのPropertyと対応させることで、旧来のpublic なプロパティを公開するような感覚で利用することが可能になります。

AtomicDesign でUIを作る

原子を作る

Text表示

手始めに最も使われるであろうテキスト表示制御用のパーツを作っていきます。

  1. Hierarchyウィンドウで UIText - TextMeshPro を選択
  2. 生成された Text(TMP) オブジェクトをProject ウィンドウにドラッグ&ドロップでPrefab化
  3. 生成されたPrefabをリネーム&Textコンポーネントの初期設定
    3-1. Colorを黒に
    3-2. AutoSizeを設定
    3-3. ExtraSettings 内部の RaycastTargetのチェックを外す
    • こちらに関してはPreset機能でも良いですが、ここで対応するのが誤動作防止でおすすめです
  4. 制御用の BaseTextView Scriptを貼り付ける
BaseTextView.cs
using cova.ui.common;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.Assertions;

namespace cova.ui
{
    public class BaseTextView : BaseView<BaseTextView.ViewModel>
    {
        public class ViewModel : BaseViewModel
        {
            public readonly StringReactiveProperty Text = new StringReactiveProperty(null);

            public ViewModel()
            {
                Text.AddTo(m_disposable);
            }
        }


        [SerializeField] private TextMeshProUGUI m_text = null;


        /// <summary>
        /// 初期化メソッド
        /// </summary>
        /// <param name="viewModel"></param>
        /// <typeparam name="T"></typeparam>
        public override void Initialize(ViewModel viewModel)
        {
            Assert.IsNotNull(viewModel, "[BaseTextView] viewModel is NULL");
            viewModel.AddTo(m_disposable);

            viewModel.Text.Subscribe(msg =>
            {
                m_text.text = msg;
            }).AddTo(m_disposable);
        }
    }
}

BaseView.cs
using System;
using UniRx;
using UnityEngine;

namespace cova.ui.common
{
    /// <summary>
    /// View の基底クラス
    /// </summary>
    public abstract class BaseView<TViewModel> : MonoBehaviour, IDisposable where TViewModel : BaseViewModel
    {
        protected readonly CompositeDisposable m_disposable = new CompositeDisposable();

        /// <summary>
        /// 初期化メソッド
        /// </summary>
        /// <param name="viewModel"></param>
        /// <typeparam name="T"></typeparam>
        public abstract void Initialize(TViewModel viewModel);

        public virtual void Dispose()
        {
            if (m_disposable.IsDisposed) return;
            m_disposable.Dispose();
        }
    }
}

BaseViewModel.cs
using System;
using UniRx;

namespace cova.ui.common
{
    /// <summary>
    /// ViewModel の基底クラス
    /// </summary>
    public abstract class BaseViewModel : IDisposable
    {
        protected readonly CompositeDisposable m_disposable = new CompositeDisposable();

        public virtual void Dispose()
        {
            if (m_disposable.IsDisposed) return;
            m_disposable.Dispose();
        }
    }
}

Button

Button もおそらくテキストについで多くの箇所で利用されるUI要素です。
ダイアログの選択ボタンや、UIの閉じる/戻るボタン、アイコン兼ステータス詳細を開くためのボタンなど、ゲーム開発では多岐にわたって利用されます。
よって、Buttonパーツを作っておくとかなり恩恵を受けられます。

早速作ってみましょう。

  1. Hierarchyウィンドウで UIButton - TextMeshPro を選択
  2. 生成された Button オブジェクトの子要素を削除
  3. Button オブジェクトをリネーム& Project ウィンドウにドラッグ&ドロップでPrefab化
  4. 制御用の BaseButtonView Scriptを貼り付ける
BaseButtonView.cs
using cova.ui.common;
using UniRx;
using UnityEngine;
using UnityEngine.Assertions;
using UnityEngine.UI;

namespace cova.ui
{
    public class BaseButtonView : BaseView<BaseButtonView.ViewModel>
    {
        public class ViewModel : BaseViewModel
        {
            public readonly BoolReactiveProperty IsInteractable = new BoolReactiveProperty(true);
            public readonly ReactiveProperty<Sprite> ButtonImage = new ReactiveProperty<Sprite>(null);

            public Subject<Unit> OnButtonClicked = new Subject<Unit>();

            public ViewModel()
            {
                IsInteractable.AddTo(m_disposable);
                ButtonImage.AddTo(m_disposable);

                OnButtonClicked.AddTo(m_disposable);
            }
        }

        [SerializeField] private Button m_button = null;
        [SerializeField] private Image m_buttonImg = null;


        /// <summary>
        /// 初期化メソッド
        /// </summary>
        /// <param name="viewModel"></param>
        /// <typeparam name="T"></typeparam>
        public override void Initialize(ViewModel viewModel)
        {
            Assert.IsNotNull(viewModel, "[BaseButtonView] viewModel is NULL");
            Assert.IsNotNull(m_button, "[BaseButtonView] m_button is NULL");
            Assert.IsNotNull(m_buttonImg, "[BaseButtonView] m_buttonImg is NULL");
            viewModel.AddTo(m_disposable);

            viewModel.IsInteractable.Subscribe(isInteractable =>
            {
                m_button.interactable = isInteractable;
            }).AddTo(m_disposable);
            
            viewModel.ButtonImage.Subscribe(sprite =>
            {
                m_buttonImg.sprite = sprite;
            }).AddTo(m_disposable);
            
            
            m_button.OnClickAsObservable().Subscribe(_ => {viewModel.OnButtonClicked.OnNext(Unit.Default); }).AddTo(m_disposable);

        }
    }
}

ここで大事なこととして「原子」は最小限のパーツで構成すべきなので、これ以外の要素が必要な場合は次項で説明する分子Prefabで作成した方が良いです。

分子UIを作ってみる

CloseButton

おそらくよく使うボタンとしてx画像をあてがった「閉じる/戻る」ボタンかと思います。
こちらはシンプルで、先ほど作った BaseButton.prefab のPrefabVariant を作ってCloseButton.prefab を作ります。

PrefabVariant は元のPrefabを選択中の右クリックメニューにあります。
スクリーンショット 2022-11-22 16.57.49.png

生成すると以下のようにPrefabの1面に斜線がかかっているPrefabが生成されます。
スクリーンショット 2022-11-22 16.57.04.png

このVariant 内部でSpriteを更新することでCloseボタンを作ります。
スクリーンショット 2022-11-22 13.34.58.png

FAQ

  • Q1. BaseButton.prefab を修正ではダメなのか?
    • 一般的なUIでは問題ないですが、今回は 用途が限定 されていて 複数画面で共通化するパーツ なためにPrefabVariants として生成しています。そのため以降は「CloseButton」のみに対する修正を行なっても他のボタンには影響が出ません。
  • Q2. 原子と何が違うの?
    • 化学でいうCu のような 原子 = 分子 なものがUIにもあります。必ずしも組み合わせたものだけが分子ということではありません。

ラベル付きボタン

原子で BaseButton.prefabBaseText.prefab があるので、せっかくなのでこれらを組み合わせてデフォルトで生成されるボタンと同じものを作ってみましょう。

  1. BaseButton.prefab からPrefabVariants を生成 & CommonButton とリネーム
  2. CommonButtonのprefab編集画面を開き、子要素に BaseText.prefab を配置
    スクリーンショット 2022-11-22 17.05.53.png
  3. BaseButtonView コンポーネントを削除して、作成する CommonButtonView コンポーネントを貼り付ける
    スクリーンショット 2022-11-22 17.09.46.png
    3-1. SerializedFieldに要素を設定する
CommonButtonView.cs

using UniRx;
using UnityEngine;
using UnityEngine.Assertions;

namespace cova.ui
{
    public class CommonButtonView : BaseButtonView
    {
        public new class ViewModel : BaseButtonView.ViewModel
        {
            public readonly BaseTextView.ViewModel textViewModel = new BaseTextView.ViewModel();

            public ViewModel() : base()
            {
                textViewModel.AddTo(m_disposable);
            }
        }

        [SerializeField] protected BaseTextView m_text = null;


        /// <summary>
        /// 初期化メソッド
        /// </summary>
        /// <param name="viewModel"></param>
        /// <typeparam name="T"></typeparam>
        public void Initialize(ViewModel viewModel)
        {
            base.Initialize(viewModel);
            Assert.IsNotNull(viewModel, "[CommonButtonView] viewModel is NULL");
            Assert.IsNotNull(m_text, "[CommonButtonView] m_text is NULL");
            viewModel.AddTo(m_disposable);

            m_text.Initialize(viewModel.textViewModel);
        }
    }
}

いかがでしょうか。
先ほどベースのクラスがあるために機能拡張がたったこれだけのコードで済みました。

特に、再度 [SerializedField] TextMeshProUGUI m_text= null; のようなメンバ変数定義や、それらを取り扱う処理(SetterやGetter等) を一切書かずに、既存のViewコンポーネントの初期化を行うだけでOKです。

このように既存機能の拡張を行う場合、最小単位のパーツが出来上がることで追加のコード量を大きく減らし、要素の再利用を行えます。

ViewModelも、元々のBaseButton.ViewModelを継承して、新規にBaseText.ViewModelをプロパティとして追加するだけで利用可能になるので非常に追加コストも低く抑えられています。

そういえばModel クラスの設計は?

理想的に言えばModelクラスはこのような基底クラスを実装すればOKです。

BaseModel.cs
using System;
using UniRx;

namespace cova.ui.common
{
    /// <summary>
    /// Model の基底クラス
    /// </summary>
    public abstract class BaseModel<TView, TViewModel> : IDisposable
    where TViewModel: BaseViewModel
    where TView: BaseView<TViewModel>
    {
        protected readonly CompositeDisposable m_disposable = new CompositeDisposable();

        /// <summary>
        /// 連動
        /// </summary>
        /// <param name="view"></param>
        /// <param name="viewModel"></param>
        public abstract void Bind(TView view, TViewModel viewModel);
        
        public virtual void Dispose()
        {
            if (m_disposable.IsDisposed) return;
            m_disposable.Dispose();
        }
    }
}

BaseButtonModel であれば

BaseButtonModel.cs

using cova.ui.common;
using UniRx;

namespace cova.ui
{
    /// <summary>
    /// Model の基底クラス
    /// </summary>
    public class BaseButtonViewModel : BaseModel<BaseButtonView, BaseButtonView.ViewModel> 
    {
        /// <summary>
        /// 連動
        /// </summary>
        /// <param name="view"></param>
        /// <param name="viewModel"></param>
        public override void Bind(BaseButtonView view, BaseButtonView.ViewModel viewModel)
        {
            view.Initialize(viewModel);
            view.AddTo(m_disposable);

            //ViewModelに渡すパラメータを取得する様々な処理
            ...
        }
    }
}

という具合になります。

しかしながら ModelクラスでBind するための View, ViewModelはどこから渡すのか? という疑問も生じるかと思います。
こちらに関しては現在時点でも最適解が筆者自身は見出せておりませんが、いくつか解決策はあります。

参照取得用のMonoBehaviour クラスを用意する

Initializer.cs
using cova.ui;
using UniRx;
using UnityEngine;

public class Initializer : MonoBehaviour
{
    [SerializeField] private BaseButtonView m_view = null;
    protected readonly CompositeDisposable m_disposable = new CompositeDisposable();

    // Start is called before the first frame update
    void Start()
    {
        var model = new BaseButtonModel().AddTo(m_disposable);
        var vm = new BaseButtonView.ViewModel().AddTo(m_disposable);
        
        model.Bind(m_view, vm);
    }
    public virtual void Dispose()
    {
        if (m_disposable.IsDisposed) return;
        m_disposable.Dispose();
    }
}

このような形で参照を用意する方法が解決策その1です。
現状Modelクラスはピュアクラス実装しているのでSerializedField による参照受け取りができません。
ピュアクラスなのでどこでも利用可能というメリットはあるものの、こういう部分ではMonoBehaviour の力に頼らざるを得ません。

ModelクラスをMonoBehaviour 継承クラスにする

BaseModelBehaviour.cs
using System;
using UniRx;

namespace cova.ui.common
{
    /// <summary>
    /// Model の基底クラス
    /// </summary>
    public abstract class BaseModel<TView, TViewModel> : IDisposable, MonoBehaviour
    where TViewModel: BaseViewModel
    where TView: BaseView<TViewModel>
    {
     (中略)
    }
}

こうやってしまえば ViewクラスをSerializedField で参照設定できる反面、常に「ロジックとMonoBehaviourが必要」ということになり、無駄にGameObject を増やす原因にもなってしまいます。
そのためパフォーマンスを考えると、1番目のパターンでScene の初期化Script等で参照を渡して済ますのが良いと思います。

動的生成タイミングで用意する

SampleLoader.cs

async UniTask Load(AssetReference viewAsset, CancellationToken token)
{
    try{
      (中略)
      GameObject viewObject = await LoadAsync(viewAsset, token);
      var view = viewObject.GetComponent<BaseButtonView>();
      var model = new BaseButtonModel().AddTo(m_disposable);
      var vm = new BaseButtonView.ViewModel().AddTo(m_disposable);
        
      model.Bind(m_view, vm);
      (中略)
}

みたいな形でLoad処理でGetComponent してきて、そこにModel, ViewModelクラスを生成してあてがう方法。

DI にお任せ

Zenject 等のDIコンテナにstaticなUIであれば重複しない要素であれば登録しておくことで外側のScriptから
もらう方法です。

Example_GetFromDI.cs
[Inject]
Hoge(BaseButtonView view)
{
      var model = new BaseButtonModel().AddTo(m_disposable);
      var vm = new BaseButtonView.ViewModel().AddTo(m_disposable);
        
      model.Bind(view, vm);
}

ただし動的に生成するUI(ダイアログ等)だとDIに任せづらいので、その場合は3番目の手法を選択せざるを得ないと思います。

検証環境

項目 バージョン情報等
Unity Unity2021LTS
OS MacOSX12.4

サンプルリポジトリ

まとめ

  • 効率よく開発するには AtomicDesign を意識しましょう
    • PrefabVariants とNestedPrefab を多用しよう
  • パーツの再利用が出来るようにScript を作っていきましょう
    • MVVM パターンがオススメ
    • Disposeは忘れずに。

参考

53
43
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
53
43