【UniRx】UniRxのDelayFrameが初回だけ1フレーム余計に遅延する問題と解決方法
概要
UniRxでストリームを遅延させるDelayFrame
を使用した場合に初回だけOnNext
が1フレーム余計に遅延する現象がありました。
今回はその現象と解決方法を紹介したいと思います。
UniRxについて
Unityで使用可能なReactive Extensionsライブラリ。
Reactive Extensionsについては今回は詳しく触れませんが、
独特のクセがあり慣れない人には最初戸惑うと思いますが、使えるといろいろと捗ります!!!
DelayFrameの問題について
現象を詳しく説明すると
Subject.DelayFrame(N)
によりNフレーム遅延するSubject(Observable)を生成- 上記SubjectをSubscribeする
Subject.OnNext(...)
により値を流す- 初回の
OnNext
のみ「N+1」フレーム遅延して値が流れてくる- 2回目以降の
OnNext
は指定通り「N」フレーム遅延
- 2回目以降の
現象の確認方法
以下のコードを使用してSubscribe, OnNextを意図的に発生させて確認
DelayFrame(1)
で1フレーム遅延を指定しています。
上記コードをA -> Z -> Z と入力した時の結果です。
Z入力が1021、OnNextがコールされたのが1023 と2フレームの差がついています。
問題の原因
簡単に言うと
- UniRxは裏側でUniRx専用のDispatcher(スレッドの処理を管理するオブジェクト)を使用
- そのDispatcher生成は遅延処理などが必要になったときに実施
- Dispatcherの仕事は生成したフレームの次フレームから行われる
簡単に言えばこのような動きになるため、初回のみ1フレームずれます
詳細に言うと
- 初めて
DelayFrame
などの遅延処理が発生した時にMainThreadDispatcher
というオブジェクトが生成される- Hierarchyで確認可能(DontDestroyOnLoad以下に存在)
- アプリケーション終了まで存続
DelayFrame
などの遅延処理が初めて発生した時にMainThreadDispatcher.StartCoroutine
がコールされる- UniRxのDispatcherは
MonoBehaviour
のコルーチンを使用して実現している
- UniRxのDispatcherは
- コルーチンは
while(true)
とyield return null
でRun()
のループ処理を行うRun()
内で遅延処理、および遅延後のOnNext
をコールするyield return null
->Run()
の順番でループする(コードは こちら)
StartCoroutine
した直後にyield return null
があり、
次のフレームから遅延処理が始まるため、
初回のみ1フレーム遅れる現象が起きます。
バグではないの?
バグではあると思います。
ですが、個人的には
「制限事項 」としてとらえたほうが良いと思います。
制限事項とする理由
これはUniRxなりの気遣いの結果、生まれてしまった問題だと思ったからです。
コードを読むと「遅延処理を必要としないならDispatcherは生成しない」という設計が見て取れます。
また、「初回だけなら気にしない」もしくは「1フレームぐらいずれてもいい」という場合にも問題にはなりません。
そして、後述しますが問題の回避が簡単です。
余談:修正できないの?
難しいと思います。
というのもUniRxという使われる側からすると初回のDelayFrame
などが「どこからコールされるかわからない」からです。
コルーチンを使っている関係で、FixedUpdate
, Update
, LateUPdate
など、
どこから呼ばれるかによって初回の処理のみ差異が生まれてしまいます。
だったら初回のみ特別扱いせず、1フレーム後からRun()
を開始したほうがよほど合理的だと思います。
問題の回避
この問題、結構簡単に解決できます。
Dispatcherの生成を真っ先に行えば良いだけです。
以下に紹介するMonoBehaviour
をオブジェクトにアタッチして初期シーンに配置すれば問題を回避できます。
方法1. MainThreadDispatcher.Initialize()
をコール
見た目の通りMainThreadのDispatcherを初期化します。
コール時にStartCoroutine
も実施されるため、DelayFrame
する前に初期化を完了できます。
using UnityEngine; using UniRx; public class UniRxThreadStarter : MonoBehaviour { void Start() { MainThreadDispatcher.Initialize(); } }
方法2. Scheduler.SetDefaultForUnity()
をコール
※個人的には非推奨
これも結果としてMainThreadのDispatcherを初期化することになります。
こちらはもっと広い範囲でスレッド周りの初期化を行います。
主にUnitTestで使われているようです。
ただ、注意点としてWebGLだとおかしくなるかもしれません。
こちらのような記述があるのですが、上記メソッドだとそれに反するスレッドが使用されてしまいます。
using UnityEngine; using UniRx; public class UniRxThreadStarter : MonoBehaviour { void Start() { Scheduler.SetDefaultForUnity(); } }
方法3. Scheduler, Dispatcherが初期化されるPropertyを呼び出し
以下のPropertyは初回呼び出し時にScheduler, Dispatcherが初期化されるようになっています。
予めいろいろな初期化を済ませておきたい人にはオススメのやり方かなと思います。
using UnityEngine; using UniRx; public class UniRxThreadStarter : MonoBehaviour { void Start() { // Propertyの初期呼び出しで初期化が実施される var immediate = Scheduler.Immediate; var currentThread = Scheduler.CurrentThread; var mainThread = Scheduler.MainThread; var threadPool = Scheduler.ThreadPool; } }
回避策の結果
初回のOnNext
も2回目以降と同様に指定通りの遅延になりました。
余談2:起動したコルーチンはどうなるの?
少なくともUniRx v7.1.0 時点ではアプリケーションが終了するまで残り続けます。
StopCoroutine
などコルーチンを止める処理も見当たりませんでした。
再起動がないなら割り切ってさっさと起動させてしまうのも手かなと思います。
雑感
今回はUniRxを取り上げてみました。
個人的には厳密なフレーム単位の遅延を考えるのが好きだったりするので、今回の現象は興味深くもありすごく気になる部分でもありました。
初期化処理はなんだかんだ特殊な処理が生まれるので、
UniRxにも諸々の初期化を実施してくれるメソッドがあってもいいかなと思ったりしました。
何にしろこれからもお世話になるライブラリかなと思います!(最近更新されてないのが気になりますが)
それでは~