Edited at

[UniRx.Async] UnityWebRequestAsyncOperationConfiguredAwaiter周辺で困った話


CancelしてもUnityWebRequestはAbortされない

UniTaskにはUnityWebRequestをawaitできる機能が付いていて、さらにそれをキャンセルできるインターフェースが用意されている。

こんな感じ。

CancellationTokenSource cts;

public async void SendRequest()
{
cts = new CancellationTokenSource();
var req = UnityWebRequest.Get("http://example.com");

await req.SendWebRequest().CongigureAwait(cts.Token);
Debug.Log("Completed: " + req.downloadHandler.text);
}

public void Cancel()
{
if (cts != null) cts.Cancel();
}

が、これでキャンセルしてもUnityWebRequestはAbortされない

AwaiterのGetResultでOperationCanceledExceptionがthrowされるので、await以降の処理は実行されない。(UnityAsyncExtensions.cs#L640-L642

が、UnityWebRequestはAbortされていないので、例えば以下の場合に普通に通信完了時にレスポンステキストがログに出力される。(await使うのにわざわざこう書くことは無いと思われるが)

CancellationTokenSource cts;

public async void SendRequest()
{
cts = new CancellationTokenSource();
var req = UnityWebRequest.Get("http://example.com");

var asyncOp = req.SendWebRequest();
asyncOp.completed += _ => {
Debug.Log("UnityWebRequest completed");
Debug.Log("Completed: " + req.downloadHandler.text);
};
await asyncOp.CongigureAwait(cts.Token);
Debug.Log("After await");
}

public void Cancel()
{
if (cts != null) cts.Cancel();
}

なので、通信も終了させたい場合はcts.Cancel()と同時にUnityWebRequestもAbortするように気を付けておかないといけない。

わざわざIssueも投げてみたけど、よくよく考えてみればあくまで「UnityWebRequestAsyncOperation」をAwaitしてる訳で、UnityWebRequestをAwaitしてる訳ではないので、Abortされないのはむしろ自然な挙動だったと気付いた。(「通信処理の完了を待機する」のをキャンセルするので「通信処理」自体はキャンセルされないという感じ)

…という事で、仕様でいいんですよね?


Progress.ReportがContinuationの後に実行される

どういうことかというと、以下のコードはNullReferenceExceptionを発生させる。

[SerializeField] Text _text;

public async void StartRequest()
{
var req = UnityWebRequest.Get("http://example.com");
await req.SendWebRequest().ConfigureAwait(new Progress<float>(x => _text.text = x.ToString()));
Debug.Log("StartRequest Completed");
GameObject.Destroy(_text);
_text = null;
}

何故かというと、await後の処理(Continuation)が行われた後にProgressのReportが実行されている

上記コードではawait後に_textをDestroyしている為、その後にProgressのReportをしようとして_textを参照してNullReferenceExceptionになるという流れ。

UniTaskの内部ではこの辺でProgressのReportとcontinuationのinvokeを行っている

UnityAsyncExtensions.cs#L656-L666

if (progress != null)

{
progress.Report(asyncOperation.progress);
}

if (asyncOperation.isDone)
{
this.result = asyncOperation.webRequest;
InvokeContinuation(AwaiterStatus.Succeeded);
return false;
}

一見するとIProgress.ReportのあとにcontinuationがInvokeされているので問題無いように見える。が、この問題の本質はProgressクラスそのものにある。

Progressクラスは簡単に言うと、コンストラクタでSyncronizationContextを取得し、Report時にはSyncronizationContext.Postでその処理を投げるという仕組みになっている。

SyncronizationContextって何?という方はこちら

【Unity開発者向け】「SynchronizationContext」と「Taskのawait」 - Qiita

なので、Progress.Reportを呼んだ段階ではSyncronizationContext内のキューに積まれるだけで、実際に実行されるタイミングは分からないという話。

Unityの実装がどうなってるかいまいちわからないけど、多分1フレームごとのライフサイクルのどっかでDequeueされる。

参考:UnitySynchronizationContext.cs#L41-L61

なので、1フレーム遅らせると解決したりする。

var req = UnityWebRequest.Get("http://example.com");

await req.SendWebRequest().ConfigureAwait(new Progress<float>(x => _text.text = x.ToString()));
await UniTask.DelayFrame(1);
Debug.Log("StartRequest Completed");
GameObject.Destroy(_text);
_text = null;

が、そもそもProgressでSynchronizationContextが使われているのが問題なので、標準のProgressを使わなければ良い。(ConfigureAwaitで求められるのはIProgressなので、必ずしもProgressでなくて良い)

で、SynchronizationContextを使わないProgressが既にUniRx.Asyncの中に組み込まれている。

Progress.cs

var req = UnityWebRequest.Get("http://example.com");

await req.SendWebRequest().ConfigureAwait(Progress.Create<float>(x => _text.text = x.ToString()));
Debug.Log("StartRequest Completed");
GameObject.Destroy(_text);
_text = null;

これでNullReferenceExceptionは発生しなくなる。めでたしめでたし。


まとめ


  • UnityWebRequestは必要なら手動でAbortしよう

  • 標準のProgressは使わず、UniRx.AsyncのProgress.Createを使おう