【Unity】UniTaskに善玉例外を投げよう!CancellationToken.ThrowIfCancellationRequested()
全てのUniTaskにCancellationTokenを渡して、
1行目にCancellationToken.ThrowIfCancellationRequested()を叩こう!
public async UniTask PlayAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
...
}
CancellationToken.ThrowIfCancellationRequested()って?
CancellationToken.ThrowIfCancellationRequested()とは、
CancellationTokenがCancel状態なら、
OperationCanceledException例外をthrowするメソッドです。
OperationCanceledException例外とは、
UniTaskシステムが管理(try-catch)している「UniTaskを中断させる」例外です。
すべては理想のCancellation(中断)のために
理想の中断(Cancellation)とは、
「次のUniTaskが実行されない」ことだと思われます。
それを実現するのに2つのアプローチがあります:
・return
・throw
returnで中断する
CancellationToken.IsCancellationRequested()が成立ならreturnする。
public async UniTask PlayAsync(CancellationToken cancellationToken)
{
// この時点で cancel されていたら終了
if (cancellationToken.IsCancellationRequested())
{
return;
}
await HogeAsync(cancellationToken);
// この時点で cancel されていたら終了
if (cancellationToken.IsCancellationRequested())
{
return;
}
await WaraAsync(cancellationToken);
// この時点で cancel されていたら終了
if (cancellationToken.IsCancellationRequested())
{
return;
}
await FooAsync(cancellationToken);
}
「全てのUniTask」が上記サンプルコードのように、
「子UniTask直前」、或は「子UniTask直後」でチェックを入れれば、
理想の中断が実現できます。
しかし、超絶面倒くさい!
throwで中断する
UniTaskの起点でtry-catchを仕掛けて、
「全てのUniTaskの1行目」に
CancellationToken.IsCancellationRequested()を確認して成立ならthrowする。
そもそもthrowを使ったことがない
Q:throwって、exceptionを投げるのアレだよね?エラーにならないの?
A:try-catchで例外を受け取ればエラーを避けられます。
private async UniTask PlayAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested())
{
// 無視したい自作例外を throw
throw new MyException();
}
...
}
// Cancel しても Unity エラーが起こらない
private void Start()
{
// gameObject が破棄された時に cancel する CancellationToken
var cancellationToken = gameObject.GetCancellationTokenOnDestroy();
try
{
UniTask.Void(async () =>
{
await PlayAsync(cancellationToken);
})
}
catch (MyException myException)
{
// 無視したい自作例外なので無視する (何もしない)
}
}
// Cancel したら Unity エラーが起こる
private void Start()
{
// gameObject が破棄された時に cancel する CancellationToken
var cancellationToken = gameObject.GetCancellationTokenOnDestroy();
UniTask.Void(async () =>
{
await PlayAsync(cancellationToken);
})
}
try-catchはUniTaskの起点だけでいいのは何故?
こう書くんじゃないの?
private async UniTask FadeAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested())
{
// 無視したい自作例外を throw
throw new MyException();
}
...
}
private async UniTask PlayAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested())
{
// 無視したい自作例外を throw
throw new MyException();
}
try
{
await FadeAsync(cancellationToken);
}
catch (MyException myException)
{
// 無視したい自作例外なので無視する (何もしない)
}
}
private void Start()
{
// gameObject が破棄された時に cancel する CancellationToken
var cancellationToken = gameObject.GetCancellationTokenOnDestroy();
try
{
UniTask.Void(async () =>
{
await PlayAsync(cancellationToken);
})
}
catch (MyException myException)
{
// 無視したい自作例外なので無視する (何もしない)
}
}
実は、throwは最も近いcatchに直接に飛びます!つまり、
private async UniTask FadeAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested())
{
// 無視したい自作例外を throw
// PlayAsync() を経由せず, Start() の catch に直接飛びます!
throw new MyException();
}
...
}
private async UniTask PlayAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested())
{
// 無視したい自作例外を throw
throw new MyException();
}
await FadeAsync(cancellationToken);
}
private void Start()
{
// gameObject が破棄された時に cancel する CancellationToken
var cancellationToken = gameObject.GetCancellationTokenOnDestroy();
try
{
UniTask.Void(async () =>
{
await PlayAsync(cancellationToken);
})
}
catch (MyException myException)
{
// 無視したい自作例外なので無視する (何もしない)
}
}
大変便利!
だからCancellation.ThrowIfCancellationRequested()
鋭い方々はすでに気付いてると思います。
if (cancellationToken.IsCancellationRequested())
{
// MyException => OperationCanceledException
throw new OperationCanceledException();
}
上記サンプルコードはまさにCancellationToken.ThrowIfCancellationRequested()そのもの。
そして、UniTaskのForget()などの中にはtry-catchが含まれている。
ですので、上記サンプルコードは更に簡潔に書き換えられます:
private async UniTask FadeAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
...
}
private async UniTask PlayAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
await FadeAsync(cancellationToken);
}
private void Start()
{
// gameObject が破棄された時に cancel する CancellationToken
var cancellationToken = gameObject.GetCancellationTokenOnDestroy();
// Forget は try-catch を含んでいます
// catch する対象は cancellationToken.ThrowIfCancellationRequested() が throw する OperationCanceledException
PlayAsync(cancellationToken).Forget();
}
returnよりは遥かにシンプルではないでしょうか?
1行目だけで本当に大丈夫?
public async UniTask PlayAsync(CancellationToken cancellationToken)
{
await FooAsync(cancellationToken);
await BarAsync(cancellationToken); // <-- ここで Cancellation 発生
await HogeAsync(cancellationToken);
await WaraAsync(cancellationToken);
}
まず、上記スコープだけを見てみましょう。
BarAsync()の実行途中でCancellation発生とします。
理想では、後続のHogeAsync()とWaraAsync()をスキップしますよね?
そこで、HogeAsync()とWaraAsync()の実行を阻止する方法は2つ:
1、UniTaskの間にCancellationToken.ThrowIfCancellationRequested()を挟む
2、UniTaskの頭にCancellationToken.ThrowIfCancellationRequested()を書く
UniTaskの間にCancellationToken.ThrowIfCancellationRequested()を挟む
public async UniTask PlayAsync(CancellationToken cancellationToken)
{
await FooAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了
await BarAsync(cancellationToken); // <-- ここで Cancellation 発生
cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了
await HogeAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了
await WaraAsync(cancellationToken);
}
考え方自体は問題ないですが、最大の懸念点は「書き漏れ」です。
UniTaskを使う度にCancellationToken.ThrowIfCancellationRequested()を書く義務が付随されます。
例えばPlayAsync()以外の所もBarAsync()を呼ぶと…
public async UniTask StopAsync(CancellationToken cancellationToken)
{
await BarAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了
await FooAsync(cancellationToken);
}
このように、BarAsync()が100回呼び出される場合は、
CancellationToken.ThrowIfCancellationRequested()も100回書かなければなりません。
UniTaskの頭にCancellationToken.ThrowIfCancellationRequested()を書く
private async UniTask BarAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了
...
}
public async UniTask PlayAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了
await FooAsync(cancellationToken); // <- ここで throw して終了
await BarAsync(cancellationToken); // <- ここで throw して終了
await HogeAsync(cancellationToken); // <- ここで throw して終了
await WaraAsync(cancellationToken); // <- ここで throw して終了
}
public async UniTask StopAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了
await BarAsync(cancellationToken); // <- ここで throw して終了
await FooAsync(cancellationToken); // <- ここで throw して終了
}
このアプローチでは、無駄を見事に避けられましたね!
簡単明瞭ですので、コーディング規約にしやすいではないでしょうか。
「全てのUniTask」がこのルールを守っていれば、
「次のUniTaskの1行目で終了する=次のUniTaskが実行されない」ことが保証できます。
と、いうワケで
「全てのUniTask」の1行目にCancellationToken.ThrowIfCancellationRequested()を叩きましょう!
そのために、「全てのUniTask」にCancellationToken引数を渡しましょう!
おまけ:CancellationTokenこそUniTaskの主役
CancellationTokenはUniTaskを中断するのに唯一の方法だと思われます。
その重要性はUniRxにおけるIDisposableと同じくらいでしょう。
参画しているプロジェクトはCancellationTokenを強調するため、
UniTaskの「1番目の引数」として渡す規約まで定めています。
この記事が気に入ったらサポートをしてみませんか?