UniRx 5.4.0 - Unity 5.4対応とまだまだ最適化

UniRx 5.4.0をリリースしました!ちょうどUnity 5.4もリリースされたので、5.4向けの修正(Warning取り除いただけですが)を出せて良かった。というわけで5.4正式対応です。リリースは前回が5月だったので3ヶ月ぶりです。5.2 -> 5.3も3ヶ月だったので、今のとこ3ヶ月スパンになってますが偶然です。

何が変わったのかというと

Add: Observable.FrameInterval
Add: Observable.FrameTimeInterval
Add: Observable.BatchFrame
Add: Observable.Debug(under UniRx.Diagnostics namespace)
Add: ObservableParticleTrigger and OnParticleCollisionAsObservable, OnParticleTriggerAsObservabl(after Unity 5.4) extension methods
Add: UniRx.AsyncReactiveCommand
Add: ReactiveCommand.BindToOnClick, `IObservable<bool>.BindToButtonOnClick`
Add: UniRx.Toolkit.ObjectPool, AsyncObjectPool
Add: UniRx.AsyncMessageBroker, asynchronous variation of MessageBroker
Add: ObserveEveryValueChanged(IEqualityComparer) overload
Add: `Observable.FromCoroutine(Func<CancellationToken, IEnumerator>)` overload
Add: ObservableYieldInstruction.IsDone property
Add: IPresenter.ForceInitialize(object argument)
Improvement: Where().Select(), Select().Where() peformance was optimized that combine funcs at internal
Improvement: MicroCoroutine performance was optimized that prevent refresh spike
Improvement: Observable.Return performance was optimized that reduced memory cost
Improvement: Observable.Return(bool) was optimzied perofmrance that allocate zero memory
Improvement: Observable.ReturnUnit was optimzied perofmrance that allocate zero memory
Improvement: Observable.Empty was optimzied perofmrance that allocate zero memory
Improvement: Observable.Never was optimzied perofmrance that allocate zero memory
Improvement: Observable.DelayFrame performance was optimized
Improvement: UnityEqualityComparer.GetDefault peformance was optimized
Improvement: AddTo(gameObject) dispose when ObservableTrigger is not activated
Improvement: AddTo(gameObject/component) performance was optimized by use inner CompositeDisposable of ObservableDestroyTrigger
Improvement: `FromCoroutine<T>(Func<IObserver<T>, IEnumerator>)` stops coroutine when subscription was disposed
Improvement: ReactiveCollection, ReactiveDictionary implements dispose pattern
Fix: ToYieldInstruction throws exception on MoveNext when reThrowOnError and has error 
Fix: ObserveEveryValueChanged publish value immediately(this is degraded from UniRx 5.3)
Fix: Prevent warning on Unity 5.4 at ObservableMonoBehaviour/TypedMonoBehaviour.OnLevelWasLoaded
Fix: Remove indexer.set of IReadOnlyReactiveDictionary
Breaking Changes: Does not guaranty MicroCoroutine action on same frame
Breaking Changes: UniRx.Diagnostics.LogEntry was changed from class to struct for performance improvement

相変わらずへっぽこな英語はおいといてもらえるとして、基本的にはパフォーマンス改善、です。

前回紹介したMicroCoroutineを改良して、配列をお掃除しながら走査する(かつ配列走査速度は極力最高速を維持する)ようになったので、より安定感もましたかな、と。その他メモリ確保しないで済みそうなものは徹底的に確保しないようになど、しつっこく性能改善に努めました。あと新規実装オペレータに関しては性能に対する執拗度がかなり上がっていて、今回でいうとBatchFrameはギチギチに最適化した実装です。既存オペレータも実装甘いものも残ってはいるので、見直せるものは見なおしてみたいですねえ。

また、9/13日にPhoton勉強会【Photon Server Deep Dive - PhotonWireの実装から見つめるPhoton Serverの基礎と応用、ほか】で登壇するので、PhotonWireではUniRxもクライアント側でかなり使っているので、その辺もちょっと話したいなと思っていますので、Photonに興味ある方もない方も是非是非。Photon固有の話も勿論しますが、普通にUnityとリアルタイム通信エンジンについての考えや、UniRx固有の話なども含めていきますので。

Debug

Debugという直球な名前のオペレータが追加されました。標準では有効化されていなくて、UniRx.Diagnosticsというマイナーな名前空間をusingするようで使えるようになります。実際どんな効果が得られるのかというと

using UniRx.Diagnostics;

---

// [DebugDump, Normal]OnSubscribe
// [DebugDump, Normal]OnNext(1)
// [DebugDump, Normal]OnNext(10)
// [DebugDump, Normal]OnCompleted()
{
    var subject = new Subject<int>();

    subject.Debug("DebugDump, Normal").Subscribe();

    subject.OnNext(1);
    subject.OnNext(10);
    subject.OnCompleted();
}

// [DebugDump, Cancel]OnSubscribe
// [DebugDump, Cancel]OnNext(1)
// [DebugDump, Cancel]OnCancel
{
    var subject = new Subject<int>();

    var d = subject.Debug("DebugDump, Cancel").Subscribe();

    subject.OnNext(1);
    d.Dispose();
}

// [DebugDump, Error]OnSubscribe
// [DebugDump, Error]OnNext(1)
// [DebugDump, Error]OnError(System.Exception)
{
    var subject = new Subject<int>();

    subject.Debug("DebugDump, Error").Subscribe();

    subject.OnNext(1);
    subject.OnError(new Exception());
}

シーケンス内で検出可能なアクション(OnNext, OnError, OnCompleted, OnSubscribe, OnCancel)が全てコンソールに出力されます。よくあるのが、何か値が流れてこなくなったんだけど→どこかで誰かがDispose済み(OnCompleted)とか、OnCompletedが実は呼ばれてたとかが見えるようになります。

超絶ベンリな可視化!ってほどではないんですが、こんなものがあるだけでも、Rxで困ったときのデバッグの足しにはなるかなー、と。

BatchFrame

BatchFrameは特定タイミング後(例えばEndOfFrameまでコマンドまとめるとか)にまとめて発火するという、Buffer(Frame)のバリエーションみたいなものです。都度処理ではなくてまとめてから発火というのは、パフォーマンス的に有利になるケースが多いので、そのための仕組みです。Bufferでも代用できなくもなかったのですが、Bufferとは、タイマーの回るタイミングがBufferが空の時にスタートして、出力したら止まるというのが大きな違いですね。その挙動に合わせて最適化されています。

// BatchFrame特定タイミング後にまとめられて発火
// デフォルトは0フレーム, EndOfFrameのタイミング
var s1 = new Subject<Unit>();
var s2 = new Subject<Unit>();

Observable.Merge(s1, s2)
    .BatchFrame()
    .Subscribe(_ => Debug.Log(Time.frameCount));

Debug.Log("Before BatchFrame:" + Time.frameCount);

s1.OnNext(Unit.Default);
s2.OnNext(Unit.Default);

実装的には、まとめる&発火のTimerはコルーチンで待つようにしているのですが、今回はそのIEnumeratorを手実装して、適宜Resetかけて再利用することで、パイプライン構築後は一切の追加メモリ消費がない状態にしてます。

Optimize Combination

オペレータの組み合わせには、幾つかメジャーなものがあります。特に代表的なのはWhere().Select()でしょう。これはリスト内包表記などでも固有記法として存在するように、フィルタして射影。よくありすぎるパターンです。また、Where().Where()などのフィルタの連打やSelect().Select()などの射影の連打、そして射影してフィルタSelect().Where()などもよくみかけます(特にWhere(x => x != null)みたいなのは頻出すぎる!)。これらは、内部的に一つのオペレータとして最適化した合成が可能です。

// Select().Select()
onNext(selector1(selector2(x)));

// Where().Where()
if(predicate1(x) && predicate2(x))
{
    onNext(x);
}

// Where().Select()
if(predicate(x))
{
    onNext(selector(x));
}

// Select().Where()
var v = selector(x);
if(predicate(v))
{
    onNext(v);
}

と、いうわけで、今回からそれらの結合を検出した場合に、内部的には自動的にデリゲートをまとめた一つのオペレータに変換して返すようになっています。

MessageBroker, AsyncMessageBroker

MessageBrokerはRxベースのインメモリPubSubです。AndroidでOttoからRxJavaへの移行ガイドのような記事があるように、PubSubをRxベースで作るのは珍しいことではなく、それのUniRx版となってます。

UniRxのMessageBrokerは「型」でグルーピングされて分配される仕組みにしています。

// こんな型があるとして
public class TestArgs
{
    public int Value { get; set; }
}

---

// Subscribe message on global-scope.
MessageBroker.Default.Receive<TestArgs>().Subscribe(x => UnityEngine.Debug.Log(x));

// Publish message
MessageBroker.Default.Publish(new TestArgs { Value = 1000 });

// AsyncMessageBroker is variation of MessageBroker, can await Publish call.

AsyncMessageBroker.Default.Subscribe<TestArgs>(x =>
{
    // show after 3 seconds.
    return Observable.Timer(TimeSpan.FromSeconds(3))
        .ForEachAsync(_ =>
        {
            UnityEngine.Debug.Log(x);
        });
});

AsyncMessageBroker.Default.PublishAsync(new TestArgs { Value = 3000 })
    .Subscribe(_ =>
    {
        UnityEngine.Debug.Log("called all subscriber completed");
    });

AsyncMessageBrokerはMessageBrokerの非同期のバリエーションで、Publish時に全てのSubscriberに届いて完了したことを待つことができます。例えばアニメーション発行をPublishで投げて、Subscribe側ではそれの完了を単一のObservableで返す、Publish側はObservableになっているので、全ての完了を待ってSubscribe可能。みたいな。文字だけだとちょっと分かりにくいですが、使ってみれば結構簡単です。

UniRx.Toolkit.ObjectPool/AsyncObjectPool

UniRx.Toolkit名前空間は、本体とはあんま関係ないけれど、Rx的にベンリな小物置き場という感じのイメージでたまに増やすかもしれません。こういうのはあまり本体に置くべき「ではない」とも思っているのですが、Rxの内部を考慮した最適化を施したコードを書くのはそこそこ難易度が高いので、実用的なサンプル、のような意味合いも込めて、名前空間を隔離したうえで用意していってもいいのかな、と思いました。

というわけで、最初の追加はObjectPoolです。ObjectPoolはどこまで機能を持たせ、どこまで汎用的で、どこまで特化させるべきかという範囲がかなり広くて、実装難易度が高いわけではないですが、好みのものに仕上げるのは難しいところです。なのでまぁプロジェクト毎に作りゃあいいじゃん、と思いつつもそれはそれで面倒だしねー、の微妙なラインなのでちょっと考えつくも入れてみました。

// こんなクラスがあるとして
public class Foobar : MonoBehaviour
{
    public IObservable<Unit> ActionAsync()
    {
        // heavy, heavy, action...
        return Observable.Timer(TimeSpan.FromSeconds(3)).AsUnitObservable();
    }
}

// それ専用のPoolを<T>で作る
public class FoobarPool : ObjectPool<Foobar>
{
    readonly Foobar prefab;
    readonly Transform hierarchyParent;

    public FoobarPool(Foobar prefab, Transform hierarchyParent)
    {
        this.prefab = prefab;
        this.hierarchyParent = hierarchyParent;
    }

    // 基本的にはこれだけオーバーロード。
    // 初回のインスタンス化の際の処理を書く(特定のtransformに下げたりとかその他色々あるでしょふ)
    protected override Foobar CreateInstance()
    {
        var foobar = GameObject.Instantiate<Foobar>(prefab);
        foobar.transform.SetParent(hierarchyParent);

        return foobar;
    }

    // 他カスタマイズする際はOnBeforeRent, OnBeforeReturn, OnClearをオーバーロードすればおk
    // デフォルトでは OnBeforeRent = SetActive(true), OnBeforeReturn = SetActive(false) が実行されます

    // protected override void OnBeforeRent(Foobar instance)
    // protected override void OnBeforeReturn(Foobar instance)
    // protected override void OnClear(Foobar instance)
}

public class Presenter : MonoBehaviour
{
    FoobarPool pool = null;

    public Foobar prefab;
    public Button rentButton;

    void Start()
    {
        pool = new FoobarPool(prefab, this.transform);

        rentButton.OnClickAsObservable().Subscribe(_ =>
        {
            // プールから借りて
            var foobar = pool.Rent();
            foobar.ActionAsync().Subscribe(__ =>
            {
                // 終わったらマニュアルで返す
                pool.Return(foobar);
            });
        });
    }
}

基本的に手動で返しますし、貸し借りの型には何の手も入ってません!Rent後のトラッキングは一切されてなくて、手でReturnしろ、と。まあ、9割のシチュエーションでそんなんでいいと思うんですよね。賢くやろうとすると基底クラスがばら撒かれることになって、あまり良い兆候とは言えません。パフォーマンス的にも複雑性が増す分、どんどん下がっていきますし。

どこがRxなのかというと、PreloadAsyncというメソッドが用意されていて、事前にプールを広げておくことができます。フリーズを避けるために毎フレームx個ずつ、みたいな指定が可能になっているので、その完了がRxで待機可能ってとこがRxなとこです。

それと同期版の他に非同期版も用意されていて、それは CreateInstance/Rent が非同期になってます。

MessageBrokerと同じくAsyncとそうでないのが分かれているのは、Asyncに統一すべき「ではない」から。統一自体は可能で、というのも同期はObservable.Returnでラップすることで非同期と同じインターフェイスで扱えるから。そのこと自体はいいんですが、パフォーマンス上のペナルティと、そもそもの扱いづらさ(さすがにTのほうがIObservable[T]より遙かに扱いやすい!)を抱えます。

sync over asyncは、UniRx的にはバッドプラクティスになるかなあ。なので、同期版と非同期版とは、あえて分けて用意する。使い分ける。使う場合は極力同期に寄せる。ほうがいいんじゃないかな、というのが最近の見解です。

なお、Rent, Returnというメソッド名はdotnet/corefxのSystem.Buffersから取っています。

AsyncReactiveCommand

というわけでこちらもsync/asyncの別分けパターンで非同期版のReactiveCommandです。ReactiveCommandは何がベンリなのか分からないって話なのですが、実はこっちのAsyncReactiveCommandはかなりベンリです!

public class Presenter : MonoBehaviour
{
    public UnityEngine.UI.Button button;

    void Start()
    {
        var command = new AsyncReactiveCommand();

        command.Subscribe(_ =>
        {
            // heavy, heavy, heavy method....
            return Observable.Timer(TimeSpan.FromSeconds(3)).AsUnitObservable();
        });

        // after clicked, button shows disable for 3 seconds
        command.BindTo(button);

        // Note:shortcut extension, bind aync onclick directly
        button.BindToOnClick(_ =>
        {
            return Observable.Timer(TimeSpan.FromSeconds(3)).AsUnitObservable();
        });
    }
}

interactableの状態をコード実行中、というかつまりIO<T>が返されるまでfalseにします。連打防止でThrottleFirstがよく使われますが、それをより正確にコントロールしたり、また、引数にIReactiveProperty[bool]を渡せて、それを複数のAsyncReactiveCommandで共有することで、特定のボタンを実行中は他のボタンも実行できない、のような実行可否のグルーピングが可能になります(例えばグローバルでUI用に一個持っておけば、ゲーム中でUIは単一の実行しか許可されない、的なことが可能になる)

PresenterBase再考

PresenterBase、Obsoleteはつけてないのですけれど、GitHub上のReadMeで非推奨の明言を入れました。賢い基底クラスは悪。なのです。POCO。それはUnityにおいても何事においても例外ではない。その原則からするとPresenterBaseは賢すぎたのでナシ of the Year。動きはする、動きはするんですが……。

Model-View-Presenterパターン自体の否定ではなくて(それ自体は機能するとは思っています、ただし関心がModelにばかり向きがちですが、Viewは何か、Presenterは何か、についてもきちんと向き合わないとPresenterが奇形化するかなー、というのは実感としてある。ViewであるものをPresenterとして表現してアレゲになる、とか)、PresenterBaseというフレームワークのミスかな、とは。です。

とりあえずいったん初期化順序が気になるシーンは手でInitializeメソッド立てて、それをAwake, Startの代わりにして、呼ばせる。いじょ。みたいな素朴な奴で十二分かなー、とオモッテマス。結局。メリットよりもデメリットのほうが大きすぎたかな。反省。

この辺りに関してはアイディアはあるので、形にするまで、むー、ちょっと味噌汁で顔洗って出直してきます。

まとめ

あんまり大きな機能追加はなく細々とした変化なんですが、着々と良くはなっているかな、と!

Rxに関してもバッドプラクティスを色々考えられるというか反省できる(おうふ……)ようになっては来たので、どっかでまとめておきたいですね。油断するとすぐリアクティブスパゲティ化するのはいくないところではある。強力なツールではあるんですが、やりすぎて自爆するというのは、どんなツールを使っても避けられないことではあるけれど、Rxがその傾向はかなり強くはある。

まぁ、sync over asyncはいくないです。ほんと(思うところいっぱいある)。

というわけかで繰り返しますが、9/13日にPhoton勉強会【Photon Server Deep Dive - PhotonWireの実装から見つめるPhoton Serverの基礎と応用、ほか】で登壇するので、よければそちらも是非是非です。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

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

Twitter:@neuecc GitHub:neuecc

Archive