クラス設計をしながらHoloLensアプリを作ってみた

投稿者: | 2017-12-06

神クラス作っていませんか?

HoloLensが日本で発売されてもうすぐ一年。いくつかアプリを作ってきましたが、少しアプリの規模が大きくなってくるとコードの全体像が分かりにくくなってきたり、修正が大変になってきたりと困ることが増えてきました。

これは設計を場当たり的にテキトーにやってしまっている、というのが原因の一つかと思います。今後もHoloLensアプリを作っていこうと思っているので、ここいらでいっちょ設計について向き合ってみようと思ったのが事の次第です。

このエントリでは設計技法自体の解説ではなく、いろいろ調べてみた内容に基づいて、HoloLensでの定規アプリをお題に実践してみたという内容になります。

設計技法自体が知りたい方は、先日開催された「プログラマのためのUnity勉強会 」にてとりすーぷ(@toRisouP)さんが発表された「Unity開発で使える設計の話+Zenjectの紹介」を見てみるといいと思います。

また、お題とする定規アプリについてはMRTKのサンプルに入っているものと内容は同じものです。

なお、本エントリではある程度UniRxを知っている前提で書かれています。UniRxを知らない方はさわりだけでも調べておくとよいと思います。

本エントリはOculus Rift Advent Calendar 2017の6日目のエントリとなります!

要件と仕様

お題となるサンプルがあるので逆説的となりあまり参考にはなりませんが、今回作るアプリの要件と仕様を以下の通りとしました。

要件

  • HoloLensを使って物の長さを測る
  • HoloLensを使って多角形の面積を測る

これらの要件を実現するため、アプリの仕様を以下の通りとしました。

仕様

  • 長さを測るLineモードと、多角形の面積を測るPolygonモードを用意する
  • メニューを用意し、モードに応じたボタンを用意する
  • ボタンの色を変えることによって、選択しているモードがわかるようにする

Lineモードの仕様

  • AirTapすることにより空間上に基準点(Sphere)を設置する
  • 設置した2つの基準点を線で結び、その上に測定結果を表示する
  • 設置した後でも基準点はマニピュレーション操作によって動かすことができる
  • 測定後でも基準点を動かせば、移動後の測定結果がリアルタイムに表示される
  • 測定後は基準点をGazeした状態で「Delete」と発話することによって、線ごと削除する

Polygonモードの仕様

  • AirTapすることにより空間上に基準点(Sphere)を設置する
  • 3点以上設置した状態で「Close」と発話することによって多角形が作られ面積が測定される
  • 測定結果は多角形内に表示される
  • 測定後でも基準点を動かせば、移動後の測定結果がリアルタイムに表示される
  • 測定後は基準点をGazeした状態で「Delete」と発話することによって、線ごと削除する

クラス設計

最終的に出来上がったクラス図は以下の通りです。

設計するにあたっては以下の点に注意しました。

  • 複雑性を下げるため循環参照を避ける
  • クラスの責務をできるだけ一つに限定する(単一責任原則)
  • 異なる概念間は抽象化の手法を使って関係を持たせるようにする

設計の過程を以下に示します。

主要な要素の洗い出し

まずはアプリ内の主要な要素を名前空間として洗い出しました。このアプリは線の長さや多角形の面積を測定するものですので、測定を行う「Sizer」と図形を表す「Figure」という要素が必要だと考えました。また、AirTapやボイスコマンドなどを通じたユーザの操作を表す「UserOperation」という要素も必要になるかと考えました。さらに、今回のアプリではボタンによってモードを切り替えるようにしたいので、ボタンによる操作ができる「Menu」や、現在のモードを管理する「AppManager」という要素が必要と考えました。

図形(線、多角形)の設計

まずはわかりやすいところからやっていこうということで、測定の結果である図形について設計しました。図形については基準点を配置して線や多角形を作っていくので、それぞれ「Point」「Line」「Polygon」としてクラス化しました。

LineとPolygonは両方とも複数のPointによって構成されるので、コンポジションの関係にあります。また、LineとPolygonは音声コマンドで削除できるといった共通事項を抽出して抽象化しました。

抽象クラスとしてBaseFigureを用意し、その具象クラスをLineとPolygonとしました。それに伴いImplという名前空間も新たに用意しています。

測定部の設計

測定部分についてはLineモードとPolygonモードで測定値の内容(長さか面積か)が変わるので、別のクラスにすべきと考えLineSizerとPolygonSizerを用意しました。とはいえ「基準点を設置して、その設置結果をもとに測定する」という部分は共通化できると思ったので、図形と同様、こちらも抽象クラスBaseSizerを用意しました。

Lineモードでの測定方法ですが、こちらは基準点が2つ設置されたタイミングで結果を表示させたいので、UniRxのReactiveCollectionを使いPointのReactiveCollectionの要素数が2となったタイミングでLineクラスのインスタンスを生成するようにします。

Polygonモードについても、基本はLineモード同様に基準点を配置し線を作りつつ多角形を作っていくので、LineSizerと同様、PointクラスのReactiveCollectionを使って線を作っていきます。そして最初の点と最後の点を結ぶためのメソッドPolygonClose()を使って多角形を作ることとしました。

したがって、BaseSizerクラスに共通要素であるPointクラスのReactiveCollectionを持たせました。

Sizerの測定結果としてFigureを生成するという形にしているのですが、ここはインタフェースを介さず直接Figureの初期化メソッド(Initialize())を呼び出すようにしています。というのもLineSizerが生成するのはLineで、PolygonSizerが生成するのはPolygonであるというのは自明であるので、下手に抽象化するのはかえってわかりにくいと考えたためです。

ユーザ操作とクラス間の関係

次はユーザがこの測定器をどのように使っていくかということを考えながら操作部分を設計していこうと思います。

仕様で決めた通り、ユーザはAirTapにより基準点を配置していきます。ですので「点を配置する」という操作を行うPointSetExecutorを用意しました。このPointSetExecutorクラスから、各種Sizerクラスに基準点を設置するよう指示します。

このときクラス間の結合度を低くするためインタフェースIPointSettableを介してアクセスするようにします。このIPointSettableは抽象クラスBaseSizerにて実装されますが、メソッドの実態はLineSizerかPolygonSizerのどちらかのインスタンスが持つこととなります。このとき、インタフェースを介することによってPointSetExecutorはどちらのインスタンスのメソッドが動くのか気にすることもありませんし、上位モジュールである使う側(UserOperation)の仕様(IPointSettable)を下位モジュール(Sizer)に強制することができるので機能追加も容易になっています。(依存性逆転原則、オープンクローズド原則)

実装の際にもLineSizer→PolygonSizerの順番で作りましたが、片方のコードを気にすることなく独立して実装することができたので効果が実感できました。

なお、このあたりはとりすーぷ(@toRisouP)さんとのやりとりが大変参考になりました。(今回のネタとは別の話でしたが…)

さて、この基準点を配置するという操作はAirTapで行うという仕様になっています。MRTKを使う場合、インタフェースIInputClickHandlerを実装することになるのですが、今回作ったクラス図ではこのインタフェースもUserOperation内に入れました。これはクラス図を俯瞰したときにHoloLens特有のジェスチャーイベントをサブスクライブするのがどのクラスなのかを判別しやすくため、セオリーに反するかもしれませんがクラス図に入れてみました。

続いてのユーザによる操作は音声操作部です。仕様上では測定が終わった図形の削除と、Polygonモードにて多角形を完成させる操作を音声で行うこととしています。これらの処理はBaseFigureにてDelete()として、PolygonSizerにてPolygonClose()としてそれぞれ用意しています。そこでこれらのメソッドをインタフェースとして抽出して、基準点の設置と同様、UserOperationに配置します。

(PlantUMLのレイアウト操作難しい…)

音声認識はVoiceCommandExecutorクラスで行うこととしています。

さて、もう一つのユーザの操作として、設置した後の基準点をマニピュレーション操作によって動かすことができる、というものがあります。こちらもMRTKを使う場合はIManipulationHandlerインタフェースを使うこととなるのでこれをUserOperationに配置し、Pointクラスが実装することとしました。

マニピュレーション操作で基準点を移動させた後、移動後の位置によって線の長さや多角形の面積の表示を更新させる必要があります。ここではPointクラスに自分の位置座標についてのReactivePropertyを持たせ、それをFigureの具象クラス(LineやPolygon)が位置の変更をサブスクライブしてメトリクスを更新するものとしました。

これで主要な機能の設計は一通り完了です。

モードの切り替えについての設計

残りはメニュー操作によるモードの切り替えです。今回は下の図のようにCubeをAirTapするとモードが切り替わり、現在のモードのボタンを緑にするというとても簡素なものです。

UnityではUIを設計するときにはMVP(Model-View-Presenter)パターンを適用すると良いとよく言われています。今回の場合ですとボタン代わりのCubeがViewとなり、選択中のモードによって色が変化するというViewロジックを持っています。ではModelは何なのかといいますと、今回の場合は「モード」がModelになるかと考えました。そこでModelを表すAppStateManagerクラスとViewを表すMenuButtonクラス、そしてそれらを仲立ちするMenuPresenterクラスを用意しました。




ViewであるMenuButtonには自分がどのモードのボタンなのかを表すAppState側の変数を持たしておきます。そしてボタンがAirTapされたときに、「〇〇モードのボタンが押されたよー」というイベントを発火するため、AppStateを型パラメータとするSubject型の変数を内部に持ち、それらをIObservable型のプロパティとして公開します。PresenterであるMenuPresenterがそれをサブスクライブし、ModelであるAppStateManagerの状態を変えていきます。

AppStateManagerではAppState型のReactivePropertyを公開し、MenuPresenterから状態を変更できるようにします。AppStateが変化するとイベントが発火するので、これもまたMenuPresenterがサブスクライブし、MenuButtonのViewロジックを使ってボタンの色を変化させます。



また、モードの変化によって切り替わるのはボタンの色の以外に、使用する測定器(Sizer)が変化します。そこで現在使用している測定器を管理するためのSizerManagerクラスを用意しました。SizerManagerではSizerクラスの具象クラスを保持しており、StateパターンでUserOperationが使うSizerを切り替えるようにしています。外部には各Sizerの共通インタフェースであるIPointSettable型のインスタンスとして公開しており、インタフェース経由で機能を使うようにしています。

クラス設計は以上です。最終的なクラス図は以下の通りとなりました。(再掲)

実装

GitHubに公開しておりますので詳細はそちらをご覧ください。ここではいくつかポイントを絞って説明します。

測定部の実装

LineSizerとPolygonSizerの共通部となる抽象クラスBaseSizerのコードです。

using HoloMeasurement.AppManager;
using HoloMeasurement.Figure;
using HoloMeasurement.UserOperation;
using UniRx;
using UnityEngine;

namespace HoloMeasurement.Sizer
{
    public abstract class BaseSizer : MonoBehaviour, IPointSettable
    {
        [SerializeField]
        protected GameObject _linePrefab;

        // 現在設置している基準点のReactiveCollection
        // コレクションに追加されたときのイベントを具象クラスで実装する
        protected ReactiveCollection<Point> _pointList = new ReactiveCollection<Point>();

        private void Start()
        {
            // モードが変更された場合、設置途中の基準点を全部削除する
            AppStateManager.Instance.CurrentState
                .Subscribe(state =>
                {
                    DeleteHalfwayPoints();
                })
                .AddTo(gameObject);

            OnStart();
        }

        protected abstract void OnStart();  // サブクラスのStartの代替
        protected abstract void DeleteHalfwayPoints();

        /// <summary>
        /// 基準点の設置
        /// </summary>

        /// <param name="prefab"></param>
        /// <param name="position"></param>
        public virtual void SetPoint(GameObject prefab, Vector3 position)
        {
            var go = Instantiate(prefab, position, Quaternion.identity);
            var point = go.GetComponent<Point>();
            _pointList.Add(point);
        }

        /// <summary>
        /// 基準点間の線の生成
        /// </summary>

        /// <param name="previous"></param>
        /// <param name="last"></param>
        /// <returns></returns>
        protected GameObject GenerateLine(Point previous, Point last)
        {
            var previousPos = previous.Position.Value;
            var lastPos = last.Position.Value;

            var centerPos = (lastPos + previousPos) * 0.5f;
            var direction = lastPos - previousPos;
            var distance = Vector3.Distance(lastPos, previousPos);

            var line = Instantiate(_linePrefab, centerPos, Quaternion.LookRotation(direction));
            line.transform.localScale = new Vector3(0.005f, 0.005f, distance);

            return line;
        }
    }
}

シーン上に配置している基準点(Point)についてのReactiveCollectionを持っているのがミソで、このコレクションに要素を追加したときのイベントハンドラにて基準点間の線を生成していきます。

具象クラスの1つであるLineSizerの実装は次の通りです。

using UnityEngine;
using UniRx;
using HoloMeasurement.Figure;
using HoloMeasurement.Figure.Impl;

namespace HoloMeasurement.Sizer.Impl
{
    public class LineSizer : BaseSizer
    {
        protected override void OnStart()
        {
            // 基準点が追加されたときのイベントをサブスクライブ
            // 設置された基準点が2つめならばLineを生成する
            _pointList
                .ObserveAdd()
                .Where(_ => _pointList.Count == 2)
                .Subscribe(_ =>
                {
                    var previousPoint = _pointList[0];
                    var lastPoint = _pointList[1];
                    var line = GenerateLine(previousPoint, lastPoint);
                    CreateAggregationObject(lastPoint, previousPoint, line);
                    _pointList.Clear(); // 生成が終わったらコレクションをクリア
                })
                .AddTo(gameObject);
        }

        public override void SetPoint(GameObject prefab, Vector3 position)
        {
            base.SetPoint(prefab, position);
        }

        /// <summary>
        /// Lineインスタンスを生成
        /// </summary>

        /// <param name="lastPoint"></param>
        /// <param name="previousPoint"></param>
        /// <param name="line"></param>
        private void CreateAggregationObject(Point lastPoint, Point previousPoint, GameObject line)
        {
            var root = new GameObject();
            root.name = "Line";
            lastPoint.transform.parent = root.transform;
            previousPoint.transform.parent = root.transform;
            line.transform.parent = root.transform;

            var lineComponent = root.AddComponent<Line>();
            lineComponent.Initialize(previousPoint, lastPoint, line);
        }

        protected override void DeleteHalfwayPoints()
        {
            foreach(var point in _pointList)
                Destroy(point.gameObject);

            _pointList.Clear();
        }
    }
}

LineSizerによって生成される図形であるLineクラスのコードです。

using UnityEngine;
using UniRx;

namespace HoloMeasurement.Figure.Impl
{
    public class Line : BaseFigure
    {
        private Point _start;
        private Point _end;
        private GameObject _line;

        private bool _isInitialize = false;

        /// <summary>
        /// LineSizerからコールされる初期化メソッド
        /// </summary>

        /// <param name="start"></param>
        /// <param name="end"></param>
        /// <param name="line"></param>
        public void Initialize(Point start, Point end, GameObject line)
        {
            if (!_isInitialize)
            {
                _isInitialize = true;
                _start = start;
                _end = end;
                _line = line;

                // 基準点の位置が変更された場合は、線を再描画する
                _start.Position
                    .Subscribe(_ => ReculcLine())
                    .AddTo(gameObject);

                _end.Position
                    .Subscribe(_ => ReculcLine())
                    .AddTo(gameObject);
            }
        }

        /// <summary>
        /// 線の再描画
        /// </summary>

        private void ReculcLine()
        {
            var previousPos = _start.Position.Value;
            var lastPos = _end.Position.Value;

            var centerPos = (lastPos + previousPos) * 0.5f;
            var direction = lastPos - previousPos;
            var distance = Vector3.Distance(lastPos, previousPos);

            _line.transform.position = centerPos;
            _line.transform.rotation = Quaternion.LookRotation(direction);
            _line.transform.localScale = new Vector3(0.005f, 0.005f, distance);
        }

        public override void DeleteFigure()
        {
            // TODO: DestroyではなくObjectPoolingパターンに直したい
            Destroy(_start.gameObject);
            Destroy(_end.gameObject);
            Destroy(_line.gameObject);
            Destroy(gameObject);
        }
    }
}

初期化時に設定しているPointクラスのReactivePropertyの変更イベントをサブスクライブし、基準点の位置が変更されたときに線の再描画メソッドを呼び出すようにしています。

UniRxを使うと、相互依存を避けることができるうえ、「〇〇が起きたら××する」というイベント駆動を1ユニットとしてコードが書けるので可読性も上がると思います。

メニュー操作部分の実装

メニュー操作についてはMVPパターンを適用しています。

まずModelに相当するAppStateManagerクラスのコードは以下の通りです。

using HoloToolkit.Unity;
using System;
using UniRx;

namespace HoloMeasurement.AppManager
{
    public enum AppState
    {
        Line,
        Polygon,
    }

    [Serializable]
    public class AppStateReactiveProperty : ReactiveProperty<AppState>
    {
        public AppStateReactiveProperty() { }
        public AppStateReactiveProperty(AppState initialState) : base(initialState) { }
    }

    public class AppStateManager : Singleton<AppStateManager>
    {
        private AppStateReactiveProperty _currentState = new AppStateReactiveProperty(AppState.Line);
        public AppStateReactiveProperty CurrentState { get { return _currentState; } }
    }
}

モード種別の列挙体を用意し、そのReactivePropertyをプロパティとして公開しています。

続いてViewとなるMenuButtonクラスです。

#if NETFX_CORE || NET_4_0
using System;
#endif

using HoloMeasurement.AppManager;
using HoloToolkit.Unity.InputModule;
using UniRx;
using UnityEngine;

namespace HoloMeasurement.UserOperation.Menu
{
    public class MenuButton : MonoBehaviour, IInputClickHandler
    {
        [SerializeField]
        // 自身がどのモードを表すボタンなのか
        private AppState _buttonType;

        
        private Subject<AppState> _onClickedButton = new Subject<AppState>();
        // 外部からはイベントサブスクライブだけできるようIObservable型としてプロパティを公開
        public IObservable<AppState> OnClickAsObservable {
            get { return _onClickedButton; }
        }

        /// <summary>
        /// AirTapされたときにどのモードのボタンなのかを併せてイベント発火
        /// </summary>

        /// <param name="eventData"></param>
        public void OnInputClicked(InputClickedEventData eventData)
        {
            _onClickedButton.OnNext(_buttonType);
        }

        /// <summary>
        /// Viewロジック
        /// 引数で渡された現在のモードが自身のボタン種別と一致していたら色を緑にする
        /// </summary>

        /// <param name="currentState"></param>
        public void SetButtonColor(AppState currentState)
        {
            if(_buttonType == currentState)
            {
                GetComponent<Renderer>().material.color = Color.green;
            } else
            {
                GetComponent<Renderer>().material.color = Color.gray;
            }
        }
    }
}

ボタン側ではAirTapされたときにイベントが発火するようSubject型のオブジェクトを用意しています。

続いてModelとViewの間でロジックをコントロールするMenuPresenterクラスのコードは以下の通りです。

using HoloMeasurement.AppManager;
using UnityEngine;
using UniRx;

namespace HoloMeasurement.UserOperation.Menu
{
    public class MenuPresenter : MonoBehaviour
    {
        private void Start()
        {
            // メニュー内のすべてのボタンを取得
            var buttons = GetComponentsInChildren<MenuButton>();

            // View⇒Model
            foreach(var button in buttons)
            {
                // MenuButtonのAirTapを検知したら、モードを変更する
                button.OnClickAsObservable
                    .Subscribe(buttonType =>
                    {
                        AppStateManager.Instance.CurrentState.Value = buttonType;
                    })
                    .AddTo(gameObject);
            }

            // Model⇒View
            AppStateManager.Instance.CurrentState
                .Subscribe(state =>
                {
                    // モードの変更を検知したら、ボタンの色を更新する
                    foreach(var button in buttons)
                    {
                        button.SetButtonColor(state);
                    }
                })
                .AddTo(gameObject);
        }
    }
}

MVPパターンの思想通り、ModelとViewは互いにその存在を知らずとも目的の動作を行えるようロジックの分離を行うことができました。

感想

・設計技法は実践しないと身につかない
当たり前中の当たり前なことですが、世の中にはオブジェクト指向設計についての書籍や情報が溢れていますが、車に例えるなど汎用的というかイマイチ腑に落ちないところがありました。やはり自分の興味のある分野で、小規模なアプリをお題にして実践してみることで考え方やメリット/デメリットが見えてくると感じました。

・UniRxは強い
Observerパターンを駆使したReactiveなコードが書きやすくなるだけでなく、UniRx.Trigger系を使うことによって各種Unity関連のイベントハンドラをシンプルに書けるのが強いと思いました。たとえば衝突イベントで複数の処理を行いたい場合だとOnCollisionEnter()内でifやswitchで条件分岐させて書かなくてはいけませんが、UniRx.Triggerを使えばコードブロックでそれぞれ分けることができるので可読性がとても向上すると思います。UniRxにはもっといろいろなテクニックがあると思いますので、これからも継続して調査&活用していきたいと思います。

・クラスの粒度について
原則に忠実にしたがって設計していくと、どうしてもクラス(Unityコンポーネント)の数が増えていく傾向にあると思います。そうすると一つのGameObjectにアタッチするコンポーネントが増えてしまい管理するのが大変になってしまうと感じました。RequireComponentをひたすら書く、ZenjectといったDiの仕組みを導入するといった対応策があるかと思いますが、クラス化する際の粒度についてもよく考える必要があると思いました。

明日は…

@toofuさんによる「VR用のTwitterクライアントでユニティちゃんから通知をもらう話」です!