はじめに
UnityWebRequestをAwaitableにしたい!
なんかTaskCompletionSourceってのを使うと楽に実装できるらしい。
でもなんかTaskは色々無駄な機能が入ってて重いらしい。(多分エクストリームC#erなお方が言ってるから正しいんだろうけど、実際どれくらいパフォーマンス差があるのか、可能ならば調べたい)
ぶっちゃけUniTask使えば済む話なんだけど、どうしても既存ライブラリを使えないというケースは存在しなくもない。(例えばライブラリを作るのに他のライブラリとの依存関係を持ちたくないなど)
という訳で実装してみた、みたいな話。
要件
1. SendWebRequestをawaitできる
大前提としてこれをやりたい。
var request = UnityWebRequest.Get("http://example.com/hoge");
await request.SendWebRequest();
Debug.Log(request.downloadHandler.text);
2. 進捗を通知することができる
これもUniTaskにある機能で、あるとめちゃくちゃ便利なので実装したい。
var request = UnityWebRequest.Get("http://example.com/hoge");
await request.SendWebRequest().ConfigureAwait(new Progress<float>(x => Debug.Log(x)));
Debug.Log(request.downloadHandler.text);
実装方法
実際にAwaitableにするのにはどうしたらいいの?というのは以下の記事がわかりやすくまとまっています。
実装
1. UnityWebRequestAsyncOperationにGetAwaiterを実装する
SendWebRequestの返り値はUnityWebRequestAsyncOperation。Awaitableにするには、これにAwaiterを取得するGetAwaiterメソッドを実装する必要がある。
拡張メソッドでこれを実装する。
using UnityEngine.Networking;
public static class UnityWebRequestAsyncOperationExtension
{
public static UnityWebRequestAsyncOperationAwaiter GetAwaiter(this UnityWebRequestAsyncOperation asyncOperation)
{
return new UnityWebRequestAsyncOperationAwaiter(asyncOperation);
}
}
2. UnityWebRequestAsyncOperationAwaiterを実装する
もちろん上で返しているUnityWebRequestAsyncOperationAwaiterというのは存在しないので、自前で実装する。
using System;
using System.Runtime.CompilerServices;
using UnityEngine.Networking;
public class UnityWebRequestAsyncOperationAwaiter : INotifyCompletion
{
UnityWebRequestAsyncOperation _asyncOperation;
public bool IsCompleted
{
get { return _asyncOperation.isDone; }
}
public UnityWebRequestAsyncOperationAwaiter(UnityWebRequestAsyncOperation asyncOperation)
{
_asyncOperation = asyncOperation;
}
public void GetResult()
{
// NOTE: 結果はUnityWebRequestからアクセスできるので、ここで返す必要性は無い
}
public void OnCompleted(Action continuation)
{
_asyncOperation.completed += _ => { continuation(); };
}
}
個人的にこのOnCompletedが理解し辛かった。実装のための理解ポイントとしては以下。
- 引数で与えられるcontinuationは、awaitの後の処理がActionとしてパックされたようなもの
- OnCompletedという名前とは裏腹に、awaitされた直後に呼び出される
- 要は「非同期処理が完了したらcontinuationを発火させる」という実装を行えば良い
以下の解説が非常に参考になる。
非同期メソッドの内部実装 - 状態機械生成
3. 進捗通知を実装する
進捗通知をするには、まずは毎フレーム進捗を監視しながら通知を行うための機能が必要。
UniRxではPlayerLoopを使っていますが、とりあえずMonobehaviourで実装してみる。
using UnityEngine;
using UnityEngine.Networking;
using System;
using System.Collections.Generic;
public class WebRequestProgressNotifier
{
UnityWebRequestAsyncOperation _asyncOp;
IProgress<float> _progress;
public WebRequestProgressNotifier(UnityWebRequestAsyncOperation asyncOp, IProgress<float> progress)
{
_asyncOp = asyncOp;
_progress = progress;
}
public bool NotifyProgress()
{
_progress.Report(_asyncOp.progress);
return _request.isDone;
}
}
public class ProgressUpdater : MonoBehaviour
{
static ProgressUpdater instance;
List<WebRequestProgressNotifier> items = new List<WebRequestProgressNotifier>();
public static ProgressUpdater Instance
{
get
{
if (instance == null)
{
instance = new GameObject("ProgressUpdater").AddComponent<ProgressUpdater>();
}
return instance;
}
}
public void AddItem(WebRequestProgressNotifier item)
{
if (!item.NotifyProgress())
{
items.Add(item);
}
}
void Update()
{
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
if (item.NotifyProgress())
{
items[i] = null;
}
}
// パフォーマンス的にあまりよろしくない実装なのでどうにかしたい感
items.RemoveAll(item => item == null);
}
}
そしてUnityWebRequestAsyncOperationExtensionにConfigureAwaitを追加。
進捗通知の登録をしつつ、Awaiterを返す。
public static class UnityWebRequestAsyncOperationExtension
{
public static UnityWebRequestAsyncOperationAwaiter ConfigureAwait(this UnityWebRequestAsyncOperation asyncOperation, IProgress<float> progress)
{
var progressNotifier = new WebRequestProgressNotifier(asyncOperation, progress);
ProgressUpdater.Instance.AddItem(progressNotifier);
return new UnityWebRequestAsyncOperationAwaiter(asyncOperation);
}
}
このままだとawait request.SendRequest().ConfigureAwait()
みたいに書けないので、UnityWebRequestAsyncOperationAwaiterにGetAwaiterを実装する。
public class UnityWebRequestAsyncOperationAwaiter : INotifyCompletion
{
public UnityWebRequestAsyncOperationAwaiter GetAwaiter()
{
return this;
}
}
動作確認
適当な接続先に対して試してみる。
progressはレスポンスヘッダにContent-Lengthが無いと表示されないため、ちゃんと返してくれる接続先にする。
using UnityEngine;
using UnityEngine.Networking;
using System.Threading.Tasks;
using System;
public class Test : MonoBehaviour
{
void Start()
{
Send();
}
public async Task Send()
{
var request = UnityWebRequest.Get("http://example.com/hoge");
await request.SendWebRequest().ConfigureAwait(new Progress<float>(p => { Debug.Log(p); }));
Debug.Log(request.downloadHandler.data.Length);
}
}
問題なく動いてそうな感じですね。
最後1が出力される前にawait後の処理に移っているのが若干気になりますが、この辺は実行順の問題でPlayerLoopで前の方に挟むとかで解決したりするのかな…?
一番確実なのはAwaiter自身にProgressを持たせてしまって、_asyncOp.completed時に1をReportしてProgressUpdaterから削除する実装が良さそう。
さいごに
実際自分で実装してみると大したことないけど、実際書いてみないとよくわからん!みたいな所はあるので、書いてみてよかった。エラーハンドリング周りは全然書いてないので書く必要がありそう。
TaskCompletionSource使った場合と比較してどれくらい軽いのかは暇があったら見てみたい。
参考資料
非同期メソッドの内部実装 - C# によるプログラミング入門 | ++C++; // 未確認飛行 C
Unityのコルーチンをasync/awaitで待機できるように変換してみる - Qiita
neue cc - UniTask - Unity + async/awaitの完全でハイパフォーマンスな統合