Unity 5.3のMulti Scene EditingをUniRxによるシーンナビゲーションで統合する

今回はUnity Advent Calendar 2015のための記事になります。昨日はtsubaki_t1さんによるUnity初心者を脱するためのデバッグ入門…的なやつでした。私はとりあえずVisual Studioでアタッチしてステップ実行、でしょうか……。最近はiOSのIL2CPPのスタックトレースが行番号出してくれなくて禿げそうというのが社内のホットトピックスらすぃ。

去年もUnity Advent Calendarには参加していて、その時はUnityのコルーチンの分解、或いはUniRxのMainThreadDispatcherについてという内容でした。今回も引き続き、私の作成しているUniRx - Reactive Extensions for Unityのお話ということでお願いします。とはいえ、中身的にはMulti Scene Editingや、シーン間での引数渡しをやるのにどうすればいいのか、みたいなところなので、Rxのメソッドは特に説明なくバンバン出てきますが、Rxワカラナイ人はそのへんは雰囲気で流し読みしてもらって、シーン遷移についてのお話を読み取ってもらえれば嬉しいですねん。

Multi Scene Editing

Multi Scene Editingは初出が2014/8/4のUnity Blogの記事でしょうか、1年経ってやっと正式リリース、までまもなく!ですね、5.3から搭載されることになりました。実際どういうことになるかというと、ヒエラルキーウィンドウがこんな感じに。

image

シーン加算で読み込んだシーンがヒエラルキー上でもきっちり分けられます。DontDestroyOnLoadがついたものは専用のところに隔離される。シーンを削除する場合も、そのまま指定してサクッと消したり、マージできたりと、随分とシーン管理がやりやすくなりました。Unity 5.3からはいよいよシーン加算で管理する時代が到来する!

コード的にはUnityEngine.SceneManagement.SceneManagerに全部のAPIがつまってます。基本的にはLoadScene/Asyncか、UnloadSceneぐらいで事足りるのではないでせうか。

// SceneA -> SceneBへボタン押したら加算
// 別にRx使う必要性はないけど無駄に使うエディション
button.OnClickAsObservable()
  .SelectMany(_ => SceneManager.LoadSceneAsync("SceneB", LoadSceneMode.Additive).AsObservable())
  .Subscribe(_ => { /* 完了時の処理何かあれば */ });

この程度だとRx使う必要性はゼロですが、一応、LoadSceneAsyncの戻り値であるAsyncOperationはAsObservableで直接サクッとRx的に変換可能です。

シーン間に引数を渡す

どういうこっちゃって話ですが、新しいシーンに遷移なり加算したいってことは、引数を渡したくて然りだと思うのです。そのシーンを表示する際の初期引数が。例えばアイテム一覧画面から、アイテムの詳細画面を出すなら、アイテムのIDを渡したいよね、とかね。別にAndroidやiOSアプリでも、ウェブのURLのクエリストリングなりなんなりでも、そんなのは普通によくある話です。さて、SceneManagerはその辺りのことは、別になにも面倒みてくれません。じゃあグローバル変数を経由してやりとりするのかというと果てしなくビミョウというかスパゲティ化まったなし。せっかく画面画面がシーンで独立しているなら、値の依存関係もシーン内に抑えてやりたい。

というわけで、遷移/加算時に引数を渡せるシーン遷移機構を作りましょう。

材料として使うのはUniRxのPresenterBaseです。これは何かというと、子要素の初期化の順序をコントロールするのと、値の受け渡しができる仕組みです。ご存知のとおりUnityのGameObjectの初期化順序は不定(Execution Orderでおおまかに指定できるけど、細かいコントロールのために使うものではない)ですが、PresenterBaseの管理下におくことで、Startフェーズにて決められた順序で起動するようにコード上で設定できます。

この性質は、シーンに引数が渡される、つまり全てのルートになるという条件にぴったりです!というわけで、引数を受け取るための基底クラス、SceneBaseをPresenterBaseを継承して作りましょう。

public abstract class SceneBase : PresenterBase
{
    // これがシーン遷移時にセットされる引数を表す
    public object Argument { get; set; }

    // 受け渡されたかどうかを管理するフラグ
    public bool IsLoaded { get; set; }

    protected override void OnAwake()
    {
        // 初期化が完了した際はロード済みと強制的にマークするおまじない
        this.InitializeAsObservable().Subscribe(_ => IsLoaded = true);
    }
}

こんなもので、割とあっさりめに。実際のシーンのクラスは

// このどうでもいいクラスを引数として渡していくということにする
public class Nanika
{
    public int HogeHoge { get; set; }
    public string Hugahuga { get; set; }
}

// 遷移元クラス、適当なボタン押したらSceneBに遷移する
public class SceneA : SceneBase
{
    public Button button;

    protected override IPresenter[] Children
    {
        get { return EmptyChildren; }
    }

    protected override void BeforeInitialize()
    {
    }

    protected override void Initialize()
    {
        button.OnClickAsObservable().Subscribe(_ =>
        {
            // 直接SceneManager.LoadSceneAsyncを呼ぶのではなく、
            // 独自に作成したNavigationService.NavigateAsync経由で引数を渡して遷移/加算する
            var arg = new Nanika { HogeHoge = 100, Hugahuga = "Tako" };
            NavigationService.NavigateAsync("SceneB", arg, LoadSceneMode.Additive).Subscribe();
        });
    }
}

// 遷移先クラス、Argumentに引数が渡されてきてる
public class SceneB : SceneBase
{
    protected override IPresenter[] Children
    {
        get { return EmptyChildren; }
    }

    protected override void BeforeInitialize()
    {
    }

    protected override void Initialize()
    {
        // 前のシーンから渡された引数が取れる
        var arg = Argument as Nanika;
        Debug.Log("HogeHoge:" + arg.HogeHoge + " HugaHuga:" + arg.Hugahuga);
    }
}

ちょっと長いですが、言いたいのは遷移元ではNavigationService.NavigateAsyncを使って引数を渡して遷移先を指定する。遷移先ではArgumentに渡されたものをキャストして取り出す。といった感じです。

作る上での制約としては、必ず各シーンに単一のSceneBaseがヒエラルキーの頂上にある必要があります。こんな感じに。

image

うーん、随分と大きな制約であり不格好ですね……、この手の制約は実際のトコ、ないほうが望ましいです。別に、この手のヘンテコな制約をつけるのがアーキテクチャ、ではないです。自由なほうがよほど良いのです。とはいえしかし、どうにもならなかったので、そこは受け入れるしかなかったということで。この辺が今のところの手札でできる精一杯の形かなぁ。

NavigationService

では、肝心要のNavigationServiceの実装を見ましょう!

public static class NavigationService
{
    public static IObservable<Unit> NavigateAsync(string sceneName, object argument, LoadSceneMode mode = LoadSceneMode.Single)
    {        
        return Observable.FromCoroutine<Unit>(observer => HyperOptimizedFastAsyncOperationLoad(SceneManager.LoadSceneAsync(sceneName, mode), observer))
            .Do(_ =>
            {
                // 型ベースでたぐり寄せる。Find系は避けたいとはいえ、シーン遷移時に一発だけなのでコスト的には許容できるでしょう。
                var scenes = GameObject.FindObjectsOfType<SceneBase>(); 
                var loadedScene = scenes.Single(x => !x.IsLoaded); // 一個だけになってるはず #雑
    
                loadedScene.IsLoaded = true;
                loadedScene.Argument = argument; // PresenterBase.BeforeInitializeが走る前にセットする
            });
    }
    
    static IEnumerator HyperOptimizedFastAsyncOperationLoad(AsyncOperation operation, IObserver<Unit> observer)
    {
        if (!operation.isDone) yield return operation;
    
        observer.OnNext(Unit.Default);
        observer.OnCompleted();
    }
}

なんてことはなく、LoadSceneAsyncが完了した時点でヒエラルキーに新しいシーンがぶちまけられているので、それのBeforeInitializeが走る前にArgumentにセットしておいてやる、というだけの割と単純なものです。ポイントは、BeforeInitializeの走るタイミングはStartということです。順序的に、LaodSceneAsyncが完了した時点で、新しいシーンのGameObjectのAwakeは走っています。なので、Awakeの前にArgumentを渡すのは何をどうやっても不可能です。しかし、Startの前に割り込むことは可能です。そこでルールとして遷移先のシーンでの初期化はStart以降に限定し(PresenterBaseがその辺を抽象化しているので実装者が意識する必要はない)、NavigateAsyncでは可能な限り最速のタイミングでArgumentをセットしにいきます。その秘訣がHyperOptimizedFastAsyncOperationLoadというフザケタ名前のコルーチンです。

yield return null vs yield return AsyncOperation

別にHyperOptimizedFastAsyncOperationLoadの中身は、見たまんまの超絶単純な yield return AsyncOperation です。そして、それこそが秘訣なのです。何を言ってるかというと……

IEnumerator WaitLoadAsyncA(AsyncOperation operation)
{
    while (!operation.isDone)
    {
        yield return null;
        Debug.Log(operation.progress); // 読み込み状態のプログレス通知
    }
}

IEnumerator WaitLoadAsyncB(AsyncOperation operation)
{
    yield return operation;
}

両者の違い、分かるでしょうか? WaitLoadAsyncA のほうはプログレスを受け取るためにyield return nullでisDoneを監視するスタイル。WaitLoadAsyncBは直接待つスタイル。結果的に、どちらも待つことができます。プログレス通知は大事なので、WaitLoadAsyncAのようなスタイルを多用するほうが多いのではないかなー、と思います。WWWとか。が、しかし、両者には非常に大きな違いがあります。それは、完了時のタイミング。

image

わざわざ無駄に画像を作ってまで声を大にして言いたいんですが、直接AsyncOperationをyieldすれば、AwakeとStartの間に割り込めます。yield return nullでは普通に1フレ後になるのでStartまで完了しちゃってます。これは超絶デカい違いです、この微妙なコントロールが死ぬほど大事です。きっと役に立ちます。どこかで。ちなみに一番最初に説明したAsyncOperation.AsObservableという神メソッドはyield return nullで待ってます。クソですね。カスですね。ゴミですね。すみません……(これは次のUniRxのリリースではプログレス通知を使わない場合は直接yieldするように変更します、それまでの間は手動コルーチン作成で対応してください)

もう一つ、コルーチンの駆動を各SceneのStartCoroutineで行うと、LoadSceneMode.Single(遷移)の場合、遷移元シーンが破壊された瞬間に紐付いてるコルーチンも強制的に止まる(そしてDestroyは遷移先シーンのAwakeの前)ため、Argumentを渡すという行為は不可能です。が、UniRxのFromCoroutineで駆動させると、中立であるMainThreadDispatcherによるコルーチン駆動となるため、元のシーンが壊れるとかそういうのとは無関係にコルーチンが動き続けるため、その手の制限と付き合わなくても済みます。この辺は実際UniRx強い。

シーン表示を遅らせる

実は、今のとこ別にRx使う必要性はあんまありません、なくても全然出来るレベルです(まぁコルーチンが破壊される件は回避しにくいですが)。それではあんまりなので、もう一歩次のレベルに行きましょう。例えばシーン遷移時に、引数を元にネットワークからデータを読み取って、その間はNow Loadingで待つ。ダウンロードが完了したら表示する。こうした、なんとなく良くありそうな気がする話を、NavigationServiceで対応させてみましょう。

animation

この、あんまり良くわからない例、SceneAボタンを押すとヒエラルキーにSceneBが表示されているけれど画面上には表示されていない、実際にはネットワークからデータをダウンロードしていて、それが完了したら、その結果と共にSceneBが表示される。というものです。なるほど……?

まず、SceneBaseにPrepareAsyncメソッドを追加します。

public abstract class SceneBase : PresenterBase
{
    public object Argument { get; set; }
    public bool IsLoaded { get; set; }

    // このPrepareAsyncメソッドを新設する
    public virtual IObservable<Unit> PrepareAsync()
    {
        return Observable.Return(Unit.Default);
    }

    protected override void OnAwake()
    {
        this.InitializeAsObservable().Subscribe(_ => IsLoaded = true);
    }
}

PrepareAsyncが完了するまで表示を待機する、といった感じで、それをIObservableによって表明しています。これで遷移先のSceneBクラスを書き換えると

public class SceneB : SceneBase
{
    public WwwStringPresenter display; // インスペクターから貼り付けてUnityEngineによるデシリアライズ時にセットされる(Awake前)

    string wwwString = null;

    protected override IPresenter[] Children
    {
        get { return new[] { display }; } // Sceneにぶら下がってる子をここで指定する(コードで!原始的!)
    }

    // 呼ばれる順番はPrepareAsync -> BeforeInitialize -> Initialize

    public override IObservable<Unit> PrepareAsync()
    {
        var url = Argument as string; // 前のシーンからURL、例えば http://unity3d.com/ が送られて来るとする

        // ネットワーク通信が完了するまでこのシーンの表示を待機できる
        // (もし自分で試して効果が分かりにくかったら Observable.Timer(TimeSpan.FromSeconds(5)) とかに差し替えてください、それで5秒後表示になります)
        return ObservableWWW.Get(url)
            .Select(x => // 本当はForEachAsyncを使いたいのですがまだ未リリース。
            {
                wwwString = x; // 副作用さいこー
                return Unit.Default;
            });
    }

    protected override void BeforeInitialize()
    {
        // この時点で通信が完了してるので、小階層に渡す。
        display.PropagateArgument(wwwString); // PresenterBase.PropagateArgumentで伝搬するルール
    }

    protected override void Initialize()
    {
    }
}

変えたところは、PrepareAsyncでWWW通信を挟んでいるところ。これが完了するまではシーン全体の表示が始まらない(BeforeInitializeが呼ばれない)です。表示に関しては、この程度の超絶単純な例では直接SceneBにTextをぶら下げたほうがいいんですが、無駄に複雑にするために、ではなくてPropagateArgumentの例として、もう一個、下にUI要素をぶら下げてます。それがWwwStringPresenterで、

public class WwwStringPresenter : PresenterBase<string>
{
    public Text displayView;

    protected override IPresenter[] Children
    {
        get { return EmptyChildren; }
    }

    protected override void BeforeInitialize(string argument)
    {
    }

    // 親からPropagteArugmentで渡されてくる
    protected override void Initialize(string argument)
    {
        displayView.text = argument;
    }
}

こんな感じに、親(この場合だとSceneB)から値が伝搬されます、適切な順序で(ふつーにやってるとGameObjectの生成順序は不定なので、値の伝搬というのは単純なようで深く、やりようが色々あるテーマだったり)。さて、一見複雑というか実際、色々ゴテゴテしてきてアレな気配を醸しだしてきましたが、実際どんな状態なのかというと、こんな感じ。

image

この分かったような分からないような図で言いたいことは、値の流れです。シーン間はNavigateAsyncによりArgumentが引き渡され、シーン内ではPresenterBaseによって構築されたチェーンがPropagateArgumentにより、ヒエラルキーの上流から下流へ流れていきます。これにより、グローバルでの変数保持が不要になり、値の影響範囲が局所化されます。スコープが狭いというのは基本的にいいことです、見通しの良さに繋がりますから。分かっちゃいても実現は中々むつかしい、に対する小道具を色々揃えておくと動きやすい。

NavigateAsync最終形

おお、そうだ、PrepareAsyncに対応したNavigateAsyncのコードを出し忘れている!こんな形になりました。

public static class NavigationService
{
    public static IObservable<Unit> NavigateAsync(string sceneName, object argument, LoadSceneMode mode = LoadSceneMode.Single)
    {
        return Observable.FromCoroutine<Unit>(observer => HyperOptimizedFastAsyncOperationLoad(SceneManager.LoadSceneAsync(sceneName, mode), observer))
            .SelectMany(_ =>
            {
                var scenes = GameObject.FindObjectsOfType<SceneBase>();
                var loadedScene = scenes.Single(x => !x.IsLoaded);

                loadedScene.IsLoaded = true;
                loadedScene.Argument = argument;

                loadedScene.gameObject.SetActive(false); // 一旦非Activeにして止める

                return loadedScene.PrepareAsync() // PrepareAsyncが完了するまで待つ
                    .Do(__ =>
                    {
                        loadedScene.gameObject.SetActive(true); // Activeにして動かしはぢめる
                    });
            });
    }

    static IEnumerator HyperOptimizedFastAsyncOperationLoad(AsyncOperation operation, IObserver<Unit> observer)
    {
        if (!operation.isDone) yield return operation;

        observer.OnNext(Unit.Default);
        observer.OnCompleted();
    }
}

足したコードは、Argumentをセットしたら即座にSetActive(false)ですね。これで画面に非表示になるのは勿論、Startも抑制されます。そうしてStartが止まっている間にPrepareAsyncを呼んでやって、終わったら再度 SetActive(true) にする、ことによりStartが発生しだして、PresenterBaseの初期化機構が自動で上流→下流への起動を開始します。

まとめ

実際にはPrepareAsyncだけでは足りなくて、シーンから出る時、シーンから戻ってきた時、機能としてシーンをキャッシュしてやろうとか、遷移でパラメータ渡ってくる前提だと開発時にパラメータが足りなくてダルいので任意で差し込めるようにする/開発用デフォルト用意するとか、色々やれることはあります、し、やったほうがいいでしょふ。それらも全てUniRx上で、IObservableになっていることにより、表現がある程度は容易になるのではないかと思います。非同期を表現する入れ物、が必要だというのは至極当然の答えになるのですけれど、そこにUniRxが一定の答え、定番を提供できているんじゃないかなー、と思いますね!些か長い記事となってしまいましたが、これに限らず応用例の発想に繋がってくれれば何よりです。

Advent Calendarの次は、@Miyatinさんです!

UniRx vNext

ところで実はいまものすごい勢いで作り変えています!性能もかなり上が(って)るんですが、割と分かりやすく大きいのは、スタックトレースが物凄く見やすくなります。意味不明度が極まった複雑なスタックトレースはRx名物でデバッガビリティが最低最悪だったのですが、相当まともになってます。例えば、以下の様なふつーのチェーンのDebug.Logで表示されるスタックトレースは

var rxProp = new ReactiveProperty<int>();
rxProp
    .Where(x => x % 2 == 0)
    .Select(x => x)
    .Take(50)
    .Subscribe(x => Debug.Log(x));

rxProp.Value = 100;

Before

image

After

image

劇的!Unityのスタックトレースの表示形式に100%フォーカスして、読みやすさ第一にハックしたので、圧倒的な読みやすさだと思います。スタックトレース芸極めた。普通にWhere.Select.Take.Subscribeがそのまま表示されてますからね。勿論、メソッドコール数が減っているのは単純に性能にも寄与しています。ここまでやれば文句もないでせう。

そんなvNextの完成時期ですが、今までやるやる詐欺すぎたのですが、そろそろ実際本当に出します。来週ぐらいには本当に出します。これは意地でも仕上げます(想像通りだけれど作業量は多いわコーナーケースの想定が複雑すぎて頭が爆発しそうになるしで辛い……)。というわけでもうちょっとだけ待っててください。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive