自分用UniTask辞書
この記事はUniTask v2.5.11を前提にしている。
基本形
using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
public sealed class SampleLoader : MonoBehaviour
{
private async UniTaskVoid Start()
{
await LoadAsync(destroyCancellationToken);
}
private async UniTask LoadAsync(CancellationToken cancellationToken)
{
await UniTask.DelayFrame(1, cancellationToken: cancellationToken);
var asset = await Resources
.LoadAsync<TextAsset>("Data")
.WithCancellation(cancellationToken);
Debug.Log(asset.name);
}
}
非同期処理 → await → 結果を使う、という順序で読むと理解しやすい。
UniTaskは、Unityでasync / awaitを使いやすくするためのライブラリだ。標準のTaskに似た書き味を保ちながら、UnityのPlayerLoop、AsyncOperation、Coroutine的な待機、CancellationToken、uGUIイベント、AsyncEnumerableなどをUnity向けに扱える。
ただし、「UniTaskにすれば非同期処理がすべて安全になる」という意味ではない。キャンセルを渡さなければGameObjectが破棄されたあとも処理が続く可能性があるし、Forget()した処理で例外を握りつぶす設計も危険だ。
UniTaskが扱いやすくしてくれるのは、Unityでの待機、キャンセル、非同期イベント、並列待機の表現だ。ライフサイクルの設計まで自動で正しくなるわけではない。
索引
基本
| 項目 | 説明 |
|---|---|
UniTask / UniTask<T> |
Unity向けの軽量な非同期戻り値。 |
async UniTask |
値を返さない非同期メソッドを書く。 |
await AsyncOperation |
Unityのロード、通信、シーン遷移などを待つ。 |
UniTask.Delay / DelayFrame |
時間またはフレーム数で待つ。 |
UniTask.Yield / NextFrame |
PlayerLoop上で処理を次のタイミングへ送る。 |
WaitUntil / WaitWhile |
条件が変わるまで待つ。 |
Forget |
戻り値を待たない非同期処理を開始する。 |
UniTaskVoid |
fire-and-forget用の戻り値。 |
キャンセル・例外
| 項目 | 説明 |
|---|---|
CancellationToken |
非同期処理を中断可能にする。 |
destroyCancellationToken |
MonoBehaviourの破棄に連動するToken。 |
WithCancellation |
AsyncOperationなどにキャンセルを付ける。 |
Timeout |
タイムアウトをCancellationTokenで表現する。 |
| 例外処理 |
await時の例外とForget時の例外を区別する。 |
組み合わせる系
| 項目 | 説明 |
|---|---|
WhenAll |
複数の処理を並行して待ち、すべて終わったら進む。 |
WhenAny |
複数の処理のうち、最初に終わったものを受け取る。 |
WhenEach |
終わった順に結果を処理する。 |
UniTaskCompletionSource |
任意のタイミングで完了するUniTaskを作る。 |
Progress.Create |
進捗通知を軽量に扱う。 |
Unity拡張
| 項目 | 説明 |
|---|---|
OnClickAsync |
uGUIのクリックを一回待つ。 |
OnClickAsAsyncEnumerable |
UIイベントを非同期ストリームとして扱う。 |
UniTaskAsyncEnumerable.EveryUpdate |
Update相当の処理をAsyncEnumerableで書く。 |
AsyncReactiveProperty<T> |
値を保持しつつ、非同期ストリームとして変更を流す。 |
| DOTween / Addressables連携 | 外部アセットの非同期操作をawaitする。 |
注意点
| 項目 | 説明 |
|---|---|
| Coroutineとの違い | 書き味は似ているが、所有者とキャンセルの考え方が違う。 |
Taskとの違い |
UnityのPlayerLoop上で動く前提を理解する。 |
async voidを避ける |
例外とライフサイクル管理が壊れやすい。 |
| キャンセルを渡し忘れない | 破棄後に処理が続く事故を防ぐ。 |
| 毎フレーム非同期化しすぎない | 何でもUniTask化すれば読みやすくなるわけではない。 |
導入
UnityではPackage ManagerからGit URLで導入できる。#2.5.11を付けると、そのタグに固定できる。?path=...形式はUnity 2019.3.4f1 / 2020.1a21以降が前提になる。
https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask#2.5.11
コード側では名前空間を追加する。
using Cysharp.Threading.Tasks;
Async LINQを使う場合は追加で次の名前空間を使う。
using Cysharp.Threading.Tasks.Linq;
MonoBehaviourのイベントをAsyncEnumerableとして扱う場合は、Triggers用の名前空間も使う。
using Cysharp.Threading.Tasks.Triggers;
まず覚えるべきなのは、using Cysharp.Threading.Tasks;、async UniTask、await UniTask.Delay(...)、WithCancellation(...)、destroyCancellationTokenの組み合わせだ。
UniTask / UniTask<T>
UniTaskは値を返さない非同期処理、UniTask<T>は値を返す非同期処理を表す。
async UniTask LoadAsync()
{
await UniTask.Delay(TimeSpan.FromSeconds(1));
}
async UniTask<string> LoadTextAsync()
{
var asset = await Resources.LoadAsync<TextAsset>("Data");
return ((TextAsset)asset).text;
}
標準のTaskやTask<T>に近いが、UnityのPlayerLoopやAsyncOperationと組み合わせる前提で作られている。
string text = await LoadTextAsync();
Debug.Log(text);
「非同期処理の結果をあとで使う」ならUniTask<T>、「終わるのを待つだけ」ならUniTaskと考えるとよい。
ただし、UniTaskは何度もawaitするための値ではない。結果を複数箇所で待ち回したい場合は、設計を見直すか、必要に応じて別の仕組みを使う。
async UniTask
値を返さない非同期メソッドを書く。
private async UniTask InitializeAsync(CancellationToken cancellationToken)
{
await UniTask.DelayFrame(1, cancellationToken: cancellationToken);
Debug.Log("Initialized");
}
呼び出し側はawaitする。
await InitializeAsync(destroyCancellationToken);
MonoBehaviourのStartなど、Unityから直接呼ばれるメソッドではasync UniTaskVoidを使うことがある。
private async UniTaskVoid Start()
{
await InitializeAsync(destroyCancellationToken);
}
ただし、通常の自作メソッドはasync UniTaskを基本にしたほうがよい。呼び出し側が待てるし、例外も伝播できる。
UniTaskVoidは最後の出口に近い。何でもUniTaskVoidにすると、処理の完了を待てず、失敗も扱いにくくなる。
await AsyncOperation
Unityの非同期操作をそのままawaitできる。
var asset = await Resources.LoadAsync<TextAsset>("MasterData");
UnityWebRequestも待てる。
using UnityEngine.Networking;
async UniTask<string> GetTextAsync(string url, CancellationToken cancellationToken)
{
using var request = UnityWebRequest.Get(url);
var operation = await request
.SendWebRequest()
.WithCancellation(cancellationToken);
return operation.downloadHandler.text;
}
シーンロードも同じ発想で書ける。
await SceneManager.LoadSceneAsync("Battle");
キャンセルを待機側に付けるなら次のようにも書ける。
await SceneManager
.LoadSceneAsync("Battle")
.WithCancellation(cancellationToken);
ただし、LoadSceneAsyncでは直接awaitした場合と、WithCancellation / ToUniTaskを使った場合で、継続が戻るPlayerLoopタイミングが変わる。公式READMEでも、LoadSceneAsync.ToUniTaskはロード後のStartとの順序を変え得るため推奨されていない。ロード直後の初期化順序が重要なら、安易にToUniTaskへ置き換えないこと。キャンセルが必要な場合も、「待機を中断する」のか「ロード自体を止めたい」のかを分けて考える。
Coroutineならyield return operation;と書いていた場所を、UniTaskではawait operation;として読める。
ただし、awaitできることと、キャンセルできることは同じではない。ロードや通信をGameObjectの寿命と結びたいなら、WithCancellationを付けるべきだ。
UniTask.Delay / DelayFrame
時間またはフレーム数だけ待つ。
await UniTask.Delay(TimeSpan.FromSeconds(1));
フレーム数で待つ場合はDelayFrameを使う。
await UniTask.DelayFrame(3);
Coroutineで書くなら次のような処理に近い。
yield return new WaitForSeconds(1);
for (int i = 0; i < 3; i++)
{
yield return null;
}
DelayFrame(3)は「次の1フレーム」ではなく、3フレーム待つ。yield return null一回と混同しないこと。
UniTaskではキャンセルを渡せる。
await UniTask.Delay(
TimeSpan.FromSeconds(1),
cancellationToken: cancellationToken);
毎回キャンセルを渡すのは少し面倒に見える。しかし、Unityではオブジェクト破棄後の処理継続が事故になりやすい。面倒だから省く、という判断はかなり危ない。
UniTask.Yield / NextFrame
Yieldは指定したPlayerLoopのタイミングまで処理を戻す。
await UniTask.Yield();
NextFrameは次のフレームまで待つ。
await UniTask.NextFrame();
ここは混同しやすい。UniTask.Yield()はCoroutineのyield return nullと完全には同じではない。呼び出したPlayerLoopの位置によっては同じフレーム内で続きが走ることがある。次フレームを保証したいならUniTask.NextFrame()を使う。
タイミングを指定することもできる。
await UniTask.Yield(PlayerLoopTiming.PreLateUpdate);
Yieldは「いったん処理を分ける」ために使う。重い処理を完全に別スレッドへ逃がす命令ではない。
for (int i = 0; i < 10000; i++)
{
BuildOne(i);
if (i % 100 == 0)
{
await UniTask.Yield(cancellationToken);
}
}
こうすると長い処理を複数フレームに分けられる。ただし、分けただけで総処理量は減らない。フレーム落ちを避けたいなら、分割、キャッシュ、処理量そのものの削減をまとめて考える必要がある。
WaitUntil / WaitWhile
条件が満たされるまで待つ。
await UniTask.WaitUntil(
() => isLoaded,
cancellationToken: cancellationToken);
条件が続いている間待つならWaitWhileを使う。
await UniTask.WaitWhile(
() => isPlayingAnimation,
cancellationToken: cancellationToken);
値の変化を待つ場合はWaitUntilValueChangedも使える。
await UniTask.WaitUntilValueChanged(
target,
x => x.isActiveAndEnabled,
cancellationToken: cancellationToken);
CoroutineのWaitUntilに近い書き味だが、キャンセルを渡せる点が重要だ。
条件待ちは便利だが、条件が永遠に変わらなければ永遠に待つ。キャンセルなしで書くと、破棄済みオブジェクトを参照し続ける原因になる。
Forget
Forget は UniTask を開始するためのものではなく、戻り値を await しないことを明示するためのものだ。
呼び出し元では catch できないため、失敗をUIやログに反映したい処理では、非同期メソッド内部で try / catch する。
未処理例外は UniTaskScheduler.UnobservedTaskException へ流れるが、OperationCanceledException は通常のキャンセルとして扱われる。
LoadAsync(destroyCancellationToken).Forget();
イベントから非同期処理を呼び出すときによく使う。
button.onClick.AddListener(() =>
{
OnClickAsync(destroyCancellationToken).Forget();
});
ただし、Forget()は「待たない」と明示するためのものだ。失敗しても呼び出し元でcatchできない。
例外を扱いたいなら、処理の中でtry / catchする。
private async UniTask OnClickAsync(CancellationToken cancellationToken)
{
try
{
await LoadAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// キャンセルは通常の終了として扱う
}
catch (Exception ex)
{
Debug.LogException(ex);
}
}
Forget()は便利だが、乱用すると「どこで始まって、どこで終わるのか分からない処理」が増える。UIイベントや一方向の通知など、完了を待つ必要がない場所に限定したほうがよい。
UniTaskVoid
fire-and-forget用の戻り値。
private async UniTaskVoid Start()
{
await InitializeAsync(destroyCancellationToken);
}
async voidの代わりに使える。
private async UniTaskVoid FireAndForgetAsync()
{
await UniTask.Yield();
Debug.Log("Done");
}
このメソッドを自分で呼び出す場合は、警告を消すだけでなく未観測例外の扱いを明示するために.Forget()を呼ぶ。
FireAndForgetAsync().Forget();
ただし、通常のメソッドではUniTaskを返すほうが扱いやすい。
private async UniTask LoadAsync(CancellationToken cancellationToken)
{
await UniTask.DelayFrame(1, cancellationToken: cancellationToken);
}
UniTaskVoidは呼び出し側がawaitできない。テストもしにくくなる。
「Unityイベントから直接呼ぶ」「MonoBehaviourのStartで非同期初期化を始める」などの出口で使い、それ以外はUniTaskを返す、と決めておくと破綻しにくい。
CancellationToken
非同期処理を中断可能にするための値。
private async UniTask LoadAsync(CancellationToken cancellationToken)
{
await UniTask.Delay(
TimeSpan.FromSeconds(3),
cancellationToken: cancellationToken);
}
呼び出し側からキャンセルできる。
using var cts = new CancellationTokenSource();
var task = LoadAsync(cts.Token);
cts.Cancel();
try
{
await task;
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
// キャンセルとして扱う
}
Unityでは、多くの場合、GameObjectやComponentの寿命に連動させる。
await LoadAsync(destroyCancellationToken);
または古いUnityバージョンでは次のようにする。
await LoadAsync(this.GetCancellationTokenOnDestroy());
CancellationTokenは「任意で付ける飾り」ではない。シーン遷移、画面閉じ、GameObject破棄、通信中断などを正しく扱うための入口だ。
destroyCancellationToken
Unity 2022.2以降では、MonoBehaviourからdestroyCancellationTokenを使える。
private async UniTaskVoid Start()
{
await LoadAsync(destroyCancellationToken);
}
このTokenは、対象のMonoBehaviourが破棄されるとキャンセルされる。
private async UniTask LoadAsync(CancellationToken cancellationToken)
{
await UniTask.Delay(
TimeSpan.FromSeconds(5),
cancellationToken: cancellationToken);
Debug.Log("Loaded");
}
破棄後にDebug.LogやUI更新が走るのを防げる。
Unity 2022.2より前では、UniTaskのGetCancellationTokenOnDestroy()を使う。
await LoadAsync(this.GetCancellationTokenOnDestroy());
どちらを使うにしても、メソッドの引数としてCancellationTokenを受け取り、下位の非同期処理へ渡す形にしておくとよい。
WithCancellation
AsyncOperationなどにキャンセルを付ける。
var asset = await Resources
.LoadAsync<TextAsset>("Data")
.WithCancellation(cancellationToken);
UnityWebRequestにも使える。
using var request = UnityWebRequest.Get(url);
var response = await request
.SendWebRequest()
.WithCancellation(cancellationToken);
await operation;だけなら、処理は完了まで待つ。WithCancellationを付けると、Tokenがキャンセルされたときに待機側を中断できる。
await SceneManager
.LoadSceneAsync("NextScene")
.WithCancellation(cancellationToken);
ただし、シーンロードでは直接awaitした場合とWithCancellation / ToUniTaskを使った場合で継続タイミングが変わり得る。ロード直後の初期化順序が重要なら、前述の注意を守る。
注意点として、キャンセルは「待つ側をやめる」意味を持つ。内部のUnity操作そのものが完全に取り消されるかは、対象APIの性質による。
ロードや通信の中断を厳密に扱うなら、対象APIがどこまで停止できるかも確認する必要がある。
Timeout
UniTaskには.Timeout(...)もあるが、基本的には最初に覚える形としては勧めにくい。.Timeout(...)は外側から結果を無視する形になり、タイムアウトした対象処理そのものを止められるとは限らないからだ。
通信や外部待ちでは、タイムアウトもCancellationTokenとして表現し、処理の内側へ渡すほうが安全に読みやすい。Unityでは標準のCancellationTokenSource.CancelAfterはスレッドタイマーに依存するため、UniTaskのCancelAfterSlimを使うほうがよい。
async UniTask<string> RequestAsync(
string url,
CancellationToken destroyToken)
{
using var timeoutCts = new CancellationTokenSource();
timeoutCts.CancelAfterSlim(TimeSpan.FromSeconds(10));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
destroyToken,
timeoutCts.Token);
try
{
using var request = UnityWebRequest.Get(url);
var response = await request
.SendWebRequest()
.WithCancellation(linkedCts.Token);
return response.downloadHandler.text;
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
throw new TimeoutException();
}
}
ユーザーが画面を閉じたときの中断と、通信が遅すぎるときの中断は別の理由だ。実装では、破棄用のCancellationTokenとタイムアウトを混ぜて考えず、どちらで止まったのかをログやUIに出せる形にしておくとよい。
例外処理
awaitしたUniTaskで例外が起きると、呼び出し元へ伝播する。
try
{
await LoadAsync(cancellationToken);
}
catch (OperationCanceledException)
{
Debug.Log("キャンセル");
}
catch (Exception ex)
{
Debug.LogException(ex);
}
キャンセルはOperationCanceledExceptionとして扱うのが基本だ。
catch (OperationCanceledException)
{
return;
}
キャンセルをエラー扱いにするか、通常の終了扱いにするかは処理の意味で決める。たとえば画面を閉じたことによるキャンセルは、多くの場合エラーではない。
一方、Forget()した処理は呼び出し元でcatchできない。
LoadAsync(cancellationToken).Forget();
その場合は内部で例外を処理する。
private async UniTask LoadAsync(CancellationToken cancellationToken)
{
try
{
await UniTask.DelayFrame(1, cancellationToken: cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
Debug.LogException(ex);
}
}
非同期処理では、成功時のコードだけ書いて満足してはいけない。キャンセル、失敗、破棄後の呼び戻しまで考える必要がある。
WhenAll
複数の処理を並行して待つ。
var playerTask = LoadPlayerAsync(cancellationToken);
var enemyTask = LoadEnemiesAsync(cancellationToken);
var stageTask = LoadStageAsync(cancellationToken);
await UniTask.WhenAll(playerTask, enemyTask, stageTask);
戻り値がある場合は、タプルとして受け取れる。
var playerTask = LoadPlayerAsync(cancellationToken);
var configTask = LoadConfigAsync(cancellationToken);
var (player, config) = await UniTask.WhenAll(
playerTask,
configTask);
個別に順番待ちすると、処理が直列になる。
var player = await LoadPlayerAsync(cancellationToken);
var config = await LoadConfigAsync(cancellationToken);
これで問題ない場合もあるが、互いに依存しないロードならWhenAllのほうが自然だ。
ただし、並行化してよい処理かは確認する必要がある。同じリソースを取り合う、順序に意味がある、負荷が一気に増える、といった場合は直列のほうが安全なこともある。
WhenAny
複数の処理のうち、最初に完了したものを受け取る。
var (winArgumentIndex, result) = await UniTask.WhenAny(
LoadFromCacheAsync(cancellationToken),
LoadFromNetworkAsync(cancellationToken));
if (winArgumentIndex == 0)
{
Debug.Log("cache");
}
else
{
Debug.Log("network");
}
入力待ちとタイムアウトの競争にも使える。
var clickTask = WaitClickAsync(cancellationToken);
var timeoutTask = UniTask.Delay(
TimeSpan.FromSeconds(5),
cancellationToken: cancellationToken);
var winArgumentIndex = await UniTask.WhenAny(clickTask, timeoutTask);
if (winArgumentIndex == 0)
{
Debug.Log("クリックされた");
}
else
{
Debug.Log("時間切れ");
}
WhenAnyで注意すべきなのは、負けた側の処理だ。最初の処理が終わったあと、残りを放置してよいとは限らない。
必要ならCancellationTokenSourceを用意し、勝敗が決まった時点で残りをキャンセルする。
WhenEach
複数の処理を、完了した順に処理する。
await foreach (var result in UniTask.WhenEach(tasks))
{
if (result.IsCompletedSuccessfully)
{
var texture = result.GetResult();
AddThumbnail(texture);
}
else if (result.IsFaulted)
{
Debug.LogException(result.Exception);
}
}
WhenAllはすべて完了するまで待つ。WhenEachは終わったものから順に扱える。
大量のサムネイルを読み込み、終わった順に画面へ出すような処理に向いている。
await foreach (var result in UniTask.WhenEach(tasks))
{
var texture = result.GetResult();
AddThumbnail(texture);
}
ただし、完了順で処理するということは、元の順序とは異なる可能性がある。UIの並び順を固定したいなら、インデックスを一緒に持たせるなどの工夫が必要だ。
UniTaskCompletionSource
任意のタイミングで完了するUniTaskを作る。
private UniTaskCompletionSource<bool> source;
public UniTask<bool> WaitDecisionAsync()
{
source = new UniTaskCompletionSource<bool>();
return source.Task;
}
public void DecideOk()
{
source.TrySetResult(true);
}
public void DecideCancel()
{
source.TrySetResult(false);
}
コールバック形式のAPIをUniTaskへ変換するときに使える。
public async UniTask WaitAnimationEndAsync(
Animation animation,
CancellationToken cancellationToken)
{
var source = new UniTaskCompletionSource();
void OnAnimationFinished()
{
source.TrySetResult();
}
using (cancellationToken.Register(() =>
{
source.TrySetCanceled(cancellationToken);
}))
{
// 実際には AnimationEvent や独自コールバックで
// OnAnimationFinished を呼ぶように接続する
animation.Play();
await source.Task;
}
}
便利だが、完了しないUniTaskCompletionSourceは待ち続ける。成功、失敗、キャンセルのどれで閉じるのかを必ず設計すること。
ボタン待ちやダイアログ待ちでは、画面破棄時にTrySetCanceledする処理も必要になる。
Progress.Create
進捗通知を扱う。
var progress = Progress.Create<float>(value =>
{
progressBar.value = value;
});
ToUniTaskと組み合わせる。
await Resources
.LoadAsync<TextAsset>("Data")
.ToUniTask(
progress: Progress.Create<float>(x =>
{
progressBar.value = x;
}),
cancellationToken: cancellationToken);
ロード画面ではよく使う。
private async UniTask LoadSceneAsync(
string sceneName,
Slider slider,
CancellationToken cancellationToken)
{
await SceneManager
.LoadSceneAsync(sceneName)
.ToUniTask(
progress: Progress.Create<float>(x => slider.value = x),
cancellationToken: cancellationToken);
}
進捗表示はユーザーに安心感を与えるが、Unity側の進捗値が必ず体感時間と一致するわけではない。最後の数パーセントで止まったように見えるAPIもあるため、表示ロジックは実際の挙動を確認して調整したほうがよい。
OnClickAsync
uGUIのクリックを一回待つ。
await startButton.OnClickAsync(cancellationToken);
StartGame();
三回クリックを待つ処理も自然に書ける。
await button.OnClickAsync(cancellationToken);
await button.OnClickAsync(cancellationToken);
await button.OnClickAsync(cancellationToken);
Debug.Log("3回クリックされた");
「ボタンが押されるまで待つ」という処理では、Observableよりも直感的に読める場合がある。
ただし、連続してOnClickAsyncを書くと、その順番でしか処理されない。複数種類の入力を同時に待つなら、WhenAnyやAsyncEnumerableを検討したほうがよい。
OnClickAsAsyncEnumerable
uGUIイベントを非同期ストリームとして扱う。
await foreach (var _ in button
.OnClickAsAsyncEnumerable()
.WithCancellation(cancellationToken))
{
Debug.Log("Clicked");
}
Async LINQも使える。
await button
.OnClickAsAsyncEnumerable()
.Take(3)
.ForEachAsync(_ =>
{
Debug.Log("Clicked");
}, cancellationToken);
購読型に近い書き方もできる。
button
.OnClickAsAsyncEnumerable()
.Subscribe(_ =>
{
Debug.Log("Clicked");
});
R3やUniRxのObservableと似て見えるが、UniTaskAsyncEnumerableは基本的にpull型の非同期ストリームだ。イベントが大量に発生する場面では、処理中のイベントをどう扱うかが問題になる。
必要ならQueue()で処理中のイベントを貯める。
await button
.OnClickAsAsyncEnumerable()
.Queue()
.ForEachAwaitAsync(async _ =>
{
await UniTask.Delay(TimeSpan.FromSeconds(1));
}, cancellationToken);
UIイベントをすべてAsyncEnumerableにすればよい、という話ではない。単発で待つならOnClickAsync、状態を継続監視するならR3、非同期処理と順序制御を絡めたいならUniTaskAsyncEnumerable、と用途で分けるほうが整理しやすい。
UniTaskAsyncEnumerable.EveryUpdate
Update相当の処理をAsyncEnumerableで書く。
await foreach (var _ in UniTaskAsyncEnumerable
.EveryUpdate()
.WithCancellation(cancellationToken))
{
transform.Rotate(0f, 90f * Time.deltaTime, 0f);
}
C# 7.3環境ではForEachAsyncを使える。
await UniTaskAsyncEnumerable
.EveryUpdate()
.ForEachAsync(_ =>
{
transform.Rotate(0f, 90f * Time.deltaTime, 0f);
}, cancellationToken);
WhereやTakeUntilなどと組み合わせられる。
await UniTaskAsyncEnumerable
.EveryUpdate()
.Where(_ => Input.GetKeyDown(KeyCode.Space))
.Take(1)
.ForEachAsync(_ =>
{
Jump();
}, cancellationToken);
ただし、普通のUpdate()で十分な処理までAsyncEnumerableにすると、かえって追跡しにくくなる。
EveryUpdateが向いているのは、「条件を満たすまで待つ」「一定回数だけ受け取る」「非同期処理と組み合わせる」ような場面だ。
AsyncReactiveProperty<T>
値を保持しつつ、変更を非同期ストリームとして流す。
private readonly AsyncReactiveProperty<int> hp = new(100);
private void Start()
{
ObserveHpAsync(destroyCancellationToken).Forget();
}
private async UniTask ObserveHpAsync(CancellationToken cancellationToken)
{
await hp.ForEachAsync(value =>
{
Debug.Log($"HP: {value}");
}, cancellationToken);
}
ForEachAsyncは購読が続く間は完了しない。Start内で直接awaitすると、Startの後続処理がそこで止まる。購読として使うなら、上のように専用メソッドへ分けてForget()し、キャンセルを渡すほうが意図が明確だ。
値を変更すると通知される。
hp.Value -= 10;
現在値を無視して変更だけ受けたい場合はWithoutCurrent()を使う。
hp
.WithoutCurrent()
.ForEachAsync(value =>
{
Debug.Log($"HP changed: {value}");
}, cancellationToken)
.Forget();
UIへのバインドもできる。
hp
.Select(value => $"HP: {value}")
.BindTo(hpText);
R3のReactiveProperty<T>に似ているが、UniTask側の非同期ストリームとして扱う点が違う。
状態管理全体をR3で組んでいるなら、無理にAsyncReactiveProperty<T>へ置き換える必要はない。UniTask中心で非同期フローを書いていて、値変更も同じ流れに乗せたいときに選ぶとよい。
DOTween / Addressables連携
UniTaskは外部アセットの非同期操作とも組み合わせられる。
DOTween連携では Tween を await できる。ただし既定の await / WithCancellation / ToUniTask は「Tween が kill されるまで」を待つ。
通常のAutoKill設定なら完了時にkillされるため完了待ちのように見えるが、SetAutoKill(false) のTweenでは意図とズレることがある。
完了、停止、巻き戻しなど特定のタイミングを待ちたい場合は AwaitForComplete、AwaitForPause、AwaitForRewind などを使う。
await transform.DOMoveX(5f, 1f);
並行実行も書ける。
await UniTask.WhenAll(
transform.DOMoveX(5f, 1f).WithCancellation(cancellationToken),
transform.DOScale(2f, 1f).WithCancellation(cancellationToken));
Addressablesでもロード待ちに使える。
var handle = Addressables.LoadAssetAsync<GameObject>("Enemy");
try
{
var prefab = await handle.WithCancellation(cancellationToken);
// prefabを使う
}
finally
{
Addressables.Release(handle);
}
外部アセット連携は便利だが、対象パッケージごとの有効化条件を確認する必要がある。TextMeshProとAddressables連携は対応パッケージを導入していればasmdefのVersion Definesで有効化される。DOTween連携だけは、DOTween導入後にUNITASK_DOTWEEN_SUPPORTをScripting Define Symbolsへ追加する必要がある。
また、Tweenの場合は「完了を待つ」のか「Killを待つ」のか、「再利用するTweenなのか」で挙動が変わる。アニメーション制御の意図を曖昧にしたままawaitすると、停止タイミングで混乱しやすい。
Addressablesの場合は、キャンセル時のハンドル解放も設計に含める。上のようにfinallyで手動Releaseするなら、キャンセル時にもReleaseされる。WithCancellation / ToUniTaskのautoReleaseWhenCanceledを使う設計にする場合は、手動Releaseと重ねて二重Releaseしないようにする。
Coroutineとの違い
UniTaskはCoroutineの代わりに書ける場面が多い。
Coroutineの例。
private IEnumerator Start()
{
yield return new WaitForSeconds(1);
Debug.Log("Done");
}
UniTaskの例。
private async UniTaskVoid Start()
{
await UniTask.Delay(TimeSpan.FromSeconds(1));
Debug.Log("Done");
}
yield returnの代わりにawaitで読む。
await UniTask.DelayFrame(1);
await UniTask.WaitUntil(() => isReady);
await SceneManager.LoadSceneAsync("Next");
CoroutineはStopCoroutineやMonoBehaviourの状態と関係する。UniTaskではCancellationTokenで中断を表現する。
つまり、CoroutineからUniTaskへ置き換えるなら、単に文法を書き換えるだけでは足りない。どのTokenで止めるのか、破棄時に何をキャンセルするのかを明示する必要がある。
Taskとの違い
標準のTaskは.NETの汎用的な非同期処理だ。一方、UniTaskはUnityのPlayerLoopと組み合わせる前提が強い。
await Task.Delay(1000);
Unityでは、こちらより次のほうが意図を表しやすい。
await UniTask.Delay(TimeSpan.FromSeconds(1));
ThreadPoolで処理したい場合は明示的に切り替える。ただし、ThreadPoolを使うコードはWebGLなどでは動かない。UniTask.Runは非推奨なので、必要ならUniTask.RunOnThreadPoolかSwitchToThreadPoolを使う。
await UniTask.SwitchToThreadPool();
// 重い計算など
await UniTask.SwitchToMainThread();
// Unity APIを触る
Unity APIの多くはメインスレッドで扱う必要がある。ThreadPoolへ逃がしたあと、そのままtransformやGameObjectを触るのは危険だ。
UniTaskは「スレッドを増やして速くする道具」ではない。Unityのメインスレッド上で非同期待機を読みやすく扱い、必要なときだけ明示的にスレッドを切り替えるための道具だ。
async voidを避ける
async voidは基本的に避ける。
// 避けたい
private async void Start()
{
await InitializeAsync(destroyCancellationToken);
}
UniTaskでは次のように書ける。
private async UniTaskVoid Start()
{
await InitializeAsync(destroyCancellationToken);
}
イベントに非同期ラムダを登録する場合も、async voidになりやすい。
// 避けたい
button.onClick.AddListener(async () =>
{
await SubmitAsync(destroyCancellationToken);
});
代わりにUniTask.UnityActionを使える。
button.onClick.AddListener(
UniTask.UnityAction(async () =>
{
await SubmitAsync(destroyCancellationToken);
}));
または明示的にForget()する。
button.onClick.AddListener(() =>
{
SubmitAsync(destroyCancellationToken).Forget();
});
重要なのは、「待つのか、待たないのか」を曖昧にしないことだ。async voidはその境界を壊しやすい。
キャンセルを渡し忘れない
UniTaskで一番事故りやすいのは、キャンセルの渡し忘れだ。
private async UniTaskVoid Start()
{
await UniTask.Delay(TimeSpan.FromSeconds(5));
hpText.text = "Loaded";
}
このコードでは、5秒経つ前にGameObjectが破棄されても、後続処理が走る可能性がある。
private async UniTaskVoid Start()
{
await UniTask.Delay(
TimeSpan.FromSeconds(5),
cancellationToken: destroyCancellationToken);
hpText.text = "Loaded";
}
さらに、下位メソッドへも渡す。
private async UniTask LoadAsync(CancellationToken cancellationToken)
{
await LoadMasterAsync(cancellationToken);
await LoadUserAsync(cancellationToken);
await LoadSceneAsync(cancellationToken);
}
Tokenを受け取っただけで満足してはいけない。呼び出している非同期処理へ渡して初めて意味がある。
「この処理は何に連動して止まるべきか」を先に決めること。画面、シーン、GameObject、ユーザー操作、タイムアウト。ここが曖昧なUniTaskコードは、あとで必ず読みにくくなる。
毎フレーム非同期化しすぎない
UniTaskを覚えると、何でもasyncで書きたくなる。
private async UniTaskVoid Start()
{
await foreach (var _ in UniTaskAsyncEnumerable
.EveryUpdate()
.WithCancellation(destroyCancellationToken))
{
Move();
}
}
これは書ける。しかし、単純な移動なら普通のUpdate()で十分なことも多い。
private void Update()
{
Move();
}
非同期化する価値があるのは、待機、キャンセル、順序制御、並列待機、イベント待ちと組み合わせたい場合だ。
たとえば「ボタンを押したら演出を再生し、終わるまで次の入力を無視する」はUniTaskに向いている。
private bool isPlaying;
private async UniTask PlayAsync(CancellationToken cancellationToken)
{
if (isPlaying) return;
isPlaying = true;
try
{
await animation.PlayAsync(cancellationToken);
}
finally
{
isPlaying = false;
}
}
一方、毎フレーム座標を足すだけの処理を無理にUniTask化する必要はない。
道具を増やすことと、コードが単純になることは別問題だ。
実戦的な組み合わせ
ボタンを押したら通信し、結果を表示し、画面が閉じられたら中断する例。
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
using TMPro;
public sealed class UserProfileView : MonoBehaviour
{
[SerializeField] private Button reloadButton;
[SerializeField] private TMP_Text statusText;
private void Start()
{
reloadButton.onClick.AddListener(() =>
{
ReloadAsync(destroyCancellationToken).Forget();
});
}
private async UniTask ReloadAsync(CancellationToken cancellationToken)
{
reloadButton.interactable = false;
statusText.text = "Loading...";
try
{
string json = await GetTextAsync(
"https://example.com/profile",
cancellationToken);
statusText.text = json;
}
catch (OperationCanceledException)
{
statusText.text = "Canceled";
}
catch (Exception ex)
{
Debug.LogException(ex);
statusText.text = "Error";
}
finally
{
if (!cancellationToken.IsCancellationRequested)
{
reloadButton.interactable = true;
}
}
}
private static async UniTask<string> GetTextAsync(
string url,
CancellationToken cancellationToken)
{
using var request = UnityWebRequest.Get(url);
var response = await request
.SendWebRequest()
.WithCancellation(cancellationToken);
return response.downloadHandler.text;
}
}
このコードでは、onClickが処理を開始し、Forgetで待たないことを明示し、destroyCancellationTokenで画面破棄時に止め、WithCancellationで通信待機にキャンセルを付けている。try / catch / finallyで、成功、キャンセル、失敗、UI復帰も分けている。
単にawaitで通信するだけなら簡単だ。しかし実戦では、ボタン連打、画面破棄、通信失敗、UI復帰が同時に問題になる。UniTaskを使うなら、こういう周辺処理まで一つの流れとして書ける点を活かしたい。
最初に覚えるもの
最初は、async UniTask、UniTask<T>、UniTask.Delay、UniTask.DelayFrame、UniTask.Yield、WithCancellation、destroyCancellationToken、Forget、WhenAllだけで十分だ。
UIを扱うなら、OnClickAsyncとOnClickAsAsyncEnumerableを追加する。毎フレーム処理を非同期ストリームとして扱いたいなら、UniTaskAsyncEnumerable.EveryUpdateを覚える。
ただし、UniTaskの中心は「Coroutineより新しい書き方」ではない。Unityの非同期処理を、キャンセル可能で、例外処理できて、順序が読みやすい形にすることだ。
最初からAsync LINQ、Channel、AsyncReactiveProperty、ThreadPool切り替えまで広げる必要はない。まずはロード、通信、UIボタン、演出待ちのような場所で、CancellationTokenを正しく渡す練習をしたほうがよい。
UniTaskの価値は、非同期処理を短く書けることだけではない。待つ処理、止める処理、失敗した処理を、Unityのライフサイクルに合わせて見える形にできる点にある。
参考資料
この記事は、Cysharp/UniTaskの公式README、公式リリース情報、APIリファレンスを基に、自分用の実装メモとして整理した。