Unity のブリッジ
Unity の非同期の仕組み(UnityEvent、AsyncOperation、Addressables、Awaitable、Task)とフローをつなぐ方法を説明します。uGUI のボタンを待つヘルパーと、実行中のフローを見る Scope Tree ウィンドウも扱います。
UnityEvent を Signal にする
Section titled “UnityEvent を Signal にする”async FlowTask Shop(UnityEvent<int> onPurchased){ using var purchased = onPurchased.ToSignal(); // EventSignal<int>, owned by the current scope var itemId = await purchased.Next();}UnityEvent と UnityEvent<T> に ToSignal() があります。返る EventSignal<T> は、Next()、NextOrClosed()、Subscribe(policy) で待てます。
-
フローのコードの中で呼んでください。リスナーは現在のスコープが所有し、スコープの終わり(か、それより前の Dispose)で外され、EventSignal は閉じます。フローの外で呼んだときは、自分で Dispose します。
-
Invoke は受け手をその場では再開しません。次の Flush ポイントか Tick で再開します(セットアップ の「Tick と Flush」)。
-
引数が 2 つ以上のイベントは、
FlowBridge.FromCallbackにラムダを 1 つ書いてブリッジします。using var hits = FlowBridge.FromCallback<(GameObject, int)>(emit =>{UnityAction<GameObject, int> listener = (target, damage) => emit((target, damage));onHit.AddListener(listener);return () => onHit.RemoveListener(listener); // called when the scope ends});
Next() はエッジ
Section titled “Next() はエッジ”Next() は、待ち始めた後の Invoke だけを受け取ります。待っていない間の Invoke は捨てられます。ループの本体を実行している間の Invoke も取りこぼしたくないなら、ループの前で購読します。
using var purchased = onPurchased.ToSignal();using var queue = purchased.Subscribe(BufferPolicy.Latest); // keeps the latest one while the loop body runswhile (true){ var itemId = await queue.Next(); await Grant(itemId);}BufferPolicy の種類(Latest、Queue(容量, あふれ方))と、エッジと購読の違いは シグナル にあります。ループの中で Next() を待つ書き方には、アナライザが情報(FLOW006)を出します。
閉じた後に届くコールバック
Section titled “閉じた後に届くコールバック”EventSignal が閉じた後に Next() で待つと SignalClosedException になり、閉じた後に届いた値は黙って捨てられます。リワード広告の報酬や課金の結果のように、画面を閉じた後にも届きうるコールバックは、画面より長く生きるフロー(FlowTaskUnity.World.Run で始めたサービス)で受けてください。
AsyncOperation を待つ
Section titled “AsyncOperation を待つ”await SceneManager.LoadSceneAsync("Battle").AsFlow();var tex = (Texture2D)await Resources.LoadAsync<Texture2D>("icon").AsFlow(); // a ResourceRequest returns the assetAsyncOperation.AsFlow()はFlowTaskを、ResourceRequest.AsFlow()は読み込んだアセットを返すFlowTask<UnityEngine.Object>を返します。- 完了は
AsyncOperation.completedで受け、次の Flush ポイントか Tick で再開します。すでに完了している操作は、割り当てなしですぐに完了します。 - スコープが先にキャンセルされると、ハンドラを外して完了を無視します。
AsyncOperationには取り消す API がないので、ブリッジは待つのをやめるだけで、操作は走り続けます。- 止められる操作は、スコープの後始末(
finallyかFlow.AddCleanup)で止めます(UnityWebRequest.Abort()。下の「Addressables と UnityWebRequest」)。 - 待つのをやめた後に終わった読み込み(シーン、AssetBundle)は、フローのコードで片付けます。待つのをやめたとき(取り消されたとき)だけ、
operation.completedに片付けを登録します。終わった操作に登録しても呼ばれます。
- 止められる操作は、スコープの後始末(
- 進捗を表示するなら、読み込みと、
progressを毎フレーム読むループを Race します(サンプル の並列読み込み)。
注意:FlowTask メソッドの中で
AsyncOperationやAwaitableを.AsFlow()なしで直接 await しないでください。スコープの外の await なので、そこで止まった時点でスコープがFlowMisuseExceptionで終わります。アナライザは FLOW002 のエラーで報告します。
Addressables と UnityWebRequest
Section titled “Addressables と UnityWebRequest”Addressables の AsyncOperationHandle は AsyncOperation ではないので、.Task を FlowBridge.FromTask でブリッジします。
using var cancel = view.CancelButton.ClickedSignal();var r = await FlowTask.Race( cancel.Next(), FlowBridge.FromTask( _ => Addressables.LoadAssetAsync<GameObject>("Boss").Task, onDiscard: asset => Addressables.Release(asset)).ToFlowTask()); // a result nobody received is releasedif (!r.TryGet1(out var boss)) return; // canceled: onDiscard releases the asset when it arrives- 読み込みにはトークンを渡せないので、取り消されたブリッジは待つのをやめるだけです。後で届いた結果は
onDiscardが受け取ります(Task / ValueTask のブリッジ のonDiscard)。 - 結果のあるブリッジを Race などに渡すときは、
.ToFlowTask()を付けます。付けないと型引数を推論できません。 - 複数のアセットを進捗付きで読み込み、受け取られなかったものをまとめて解放する形は、サンプル の並列読み込みにあります。
UnityWebRequest の SendWebRequest() は AsyncOperation なので、.AsFlow() で待てます。取り消されたら finally で Abort() を呼んで通信を止めます。実時間の上限は timeout で付けます。FlowTask.WaitForSeconds はゲームの時間で進み、ポーズで止まるためです(合成 の「タイムアウト」)。
using var request = UnityWebRequest.Get(url);request.timeout = 10; // a real-time limit that a paused game doesn't stoptry{ await request.SendWebRequest().AsFlow();}finally{ if (!request.isDone) request.Abort(); // the scope gave up: stop the request}Awaitable を待つ
Section titled “Awaitable を待つ”await Awaitable.WaitForSecondsAsync(1f).AsFlow();int n = await ComputeAsync().AsFlow(); // Awaitable<T>Awaitable.AsFlow()はFlowBridge.FromTaskの上に作られていて、task.AsFlow()と同じくTaskBridge(Awaitable<T>ならTaskBridge<T>)を返します。await するほか、Race などに渡せます。TaskBridge<T>を渡すときは.ToFlowTask()を付けます。- どのスレッドで完了しても(
Awaitable.BackgroundThreadAsync()を含む)、フローは World のスレッドで再開します。 - スコープがキャンセルされると、
Awaitable.Cancel()を呼んで結果を無視します。 - フローの外で取り消された Awaitable は、await で
OperationCanceledExceptionを投げます。受けるならcatch (OperationCanceledException e) when (e is not FlowCanceledException)と書きます。スコープのキャンセルはこのcatchに来ず、そのまま巻き戻ります。
Unity では、async メソッドの Awaitable に Cancel() を呼んでも、その中で待っている Awaitable には伝わりません。中の待ちまで止めるには、メソッドに CancellationToken を受け取らせて中の待ちに渡し、FlowBridge.FromTask でスコープのトークンを渡します。
var map = await FlowBridge.FromTask(async ct => await BuildMapAsync(ct)); // the scope's token reaches the awaits inside
static async Awaitable<Map> BuildMapAsync(CancellationToken ct){ await Awaitable.NextFrameAsync(ct); // stops when the scope is canceled return Map.Generate();}Task と ValueTask のブリッジ全般(FromTask、onDiscard、外部のキャンセル)は Task / ValueTask のブリッジ にあります。
Task と UnitySynchronizationContext
Section titled “Task と UnitySynchronizationContext”Unity のメインスレッドには UnitySynchronizationContext があります。FlowBridge.FromTask のファクトリの中で ConfigureAwait(false) なしに await すると、その続きはこのコンテキストに Post され、Update フェーズ(ScriptRunDelayedTasks)で走ります。
var bytes = await FlowBridge.FromTask(async ct =>{ var data = await File.ReadAllBytesAsync(path, ct); // continues on the main thread in ScriptRunDelayedTasks return Decode(data);});ふだんのゲームではこれで問題ありません。ただし、メインスレッドを止めたまま Task の完了を待つコード(テストで Tick をループで回すなど)では、続きが走らずに終わらなくなります。そうしたコードでは、ファクトリの中の await に ConfigureAwait(false) を付けます。テストでの対処は テスト にあります。
重い計算は、FlowBridge.FromTask(ct => Task.Run(() => Work(), ct)) で別のスレッドに出します。Awaitable の BackgroundThreadAsync() で別のスレッドに移った処理も同じく、フローは World のスレッドで結果を受け取ります(スレッド)。
完了した Task がフローを再開する時機は次のとおりです。
- ブリッジは、Task を完了させたスレッドの上で同期に、完了を World に積みます。どのスレッドで完了しても、フローはすぐ後の Tick か Flush ポイントで再開します。
MonoBehaviour.Updateで完了させた Task は、同じフレームの Update の末尾の Flush で再開します。
逆向きに、フローを Task のコードから待つときは FlowHandle.AsTask() を使います。この Task は、フローが終わった Tick、Flush、Run、Dispose の直後に World のスレッドで完了します。メインスレッドでこの Task を .Result や Wait() で止まって待つと、Tick も止まるので終わりません(フローと World の「FlowHandle」)。
uGUI のボタン
Section titled “uGUI のボタン”FlowTask.Unity.UI アセンブリ(com.unity.ugui があるときだけコンパイルされる)に、ボタンのクリックを待つヘルパーがあります。
using var ok = view.OkButton.ClickedSignal(); // EventSignal<FlowUnit> over Button.onClickawait ok.Next();button.ClickedSignal()はEventSignal<FlowUnit>を返します。ほかのコントロールは UnityEvent をそのままブリッジします(toggle.onValueChanged.ToSignal())。どれも上のToSignal()と同じく、フローのコードの中で呼びます。- クリックは uGUI の EventSystem(Update フェーズ)で起きるので、待っているフローは同じフレームの Update の末尾の Flush で再開します。
演出中のクリックを拾う
Section titled “演出中のクリックを拾う”Next() はエッジなので、待ち始める前のクリック(画面を開く演出の間など)は届かず、警告も出ません(UniTask の button.OnClickAsync() と同じです)。拾いたいクリックは、演出の前にシグナルを作って購読します。シグナルを演出の前に作るだけでは足りません。
using var ok = view.OkButton.ClickedSignal();using var okClicks = ok.Subscribe(BufferPolicy.Latest); // before the animation: a click during it is keptawait FadeIn(view);await okClicks.Next();購入中の連打のように捨てたいクリックは、Next() のままにします(サンプル の購入の場面)。
UniTask と一緒に使う
Section titled “UniTask と一緒に使う”FlowTask と UniTask は同じ PlayerLoop で動きます。変換の API と移行の手引きは UniTask のブリッジと移行 にあり、ここでは Unity の PlayerLoop の上での順序だけを書きます。
- UniTask は、各フェーズの先頭と末尾(
PlayerLoopTiming.LastUpdateなど)にランナーを入れます。PlayerLoopTiming.Updateの続きは、FlowTask の Tick より前に走ります。その続きでTime.timeScaleを変えると、新しい値は次のフレームから効きます(セットアップ の「時間」)。 PlayerLoopTiming.LastUpdateの続きは、MonoBehaviour.Updateの後に走ります。そこで起きた Emit は、遅くとも PreLateUpdate の末尾の Flush で、同じフレームのうちに受け手を再開します。- UniTask の
DelayFrameで完了した待ちをブリッジしたフローは、そのフレームのうちに再開します。 ToUniTask()は、呼んだときの SynchronizationContext に完了を Post します。メインスレッドで呼んだものはScriptRunDelayedTasksで完了します。FlowTaskUnity.Configureで FlowTask のシステムを入れ直しても、UniTask のシステムは残ります。
Scope Tree ウィンドウ
Section titled “Scope Tree ウィンドウ”Window > FlowTask > Scope Tree で開きます。Play Mode の間、既定の World と、FlowWorldRegistry.Register(world) で登録した World のスコープの木を 0.25 秒ごとに更新して表示します。
- 各ノードの種類(scope、combinator、wait)、Clock、待っているもの、それを作った場所、待った時間(
waiting: Signal.Next at Enemy.cs:42 for 2.3sなど)、キャンセル中のスコープ(橙色、理由付き)を表示します。上部には、生きているスコープの数と、Clock ごとの時刻、Pause、倍率が出ます。 - ツールバーで、自動更新、テキスト表示(
FlowWorld.Dump()の出力)、scope だけの表示、名前のフィルタ、ダンプのコピーを切り替えます。World が複数あれば、表示する World を選べます。 - 行をダブルクリックすると、待ちを作った行をスクリプトで開きます(
FlowScopeInfo.WaitingFile、WaitingLine)。場所のない FlowTask メソッドのスコープの行(別の FlowTask メソッドの呼び出しを待つもの)は、そのメソッドを宣言したスクリプトを開きます。右クリックのメニューからは、どちらも開けます。 - ゲームが止まったまま動かないとき(Pause の外し忘れ)は、テキスト表示の先頭の
Paused clocks:を見ます。止まっている Clock ごとに Pause の数と持ち主が出ます(例:Game: paused x2 by Main > PauseMenu, <outside any flow>)。 - World がないとき(Play Mode の外、再生中の再コンパイルの後など)は、その理由を表示します。
自分で作った World を表示するには、作ったときに登録し、Dispose の前に外します(Dispose された World は自動で一覧から消えます)。
FlowWorldRegistry.Register(world); // shown in the Scope Tree window// ...FlowWorldRegistry.Unregister(world);world.Dispose();ダンプの読み方と診断の API は デバッグと診断 にあります。