🦔

UnityとUniTaskを使いこなしてちゃんと中断できるようにする

2024/05/13に公開

はじめに

UniTask は非常に強力なライブラリです。Unity で async/await を使うときに必須と言えるライブラリでしょう。Unity公式のAwaitable はまだまだ発展途上です。

さて、そんなUniTaskですが中断をちゃんとサポートしていないコードを見かけます。皆様公式ドキュメントのキャンセルと例外のハンドリングの項を読みましょう。
https://github.com/Cysharp/UniTask?tab=readme-ov-file#cancellation-and-exception-handling

本稿ではちゃんと中断できる書き方のコツを記載します。
これで、アクションゲームでモーションキャンセルを実装したり、実行中の大きなタスクにキャンセルボタンを用意することができるでしょう。

何を中断したいかを知る

ゲーム開発で中断したいこと第1位はキャラクタが死亡・被ダメージしたときのアクション中断ではないでしょうか?ゲームオーバーになったときも色々キャンセルしたいですね。
 他にもTimelineやParcicleSystemやVisualEffectコンポやAudioSourceをすぐさま止めて、死亡時専用処理に変更したいですね。

  • アニメーション
  • Audio
  • VisualEffect
  • ゲーム遷移
  • ユーザー入力
  • カメラワーク

基本的にコルーチンやasyncメソッドで数フレームにわたって行う仕事をすべてキャンセルしたいです。これらは適切にキャンセルできないと前のtaskが流れっぱなしのバグが発生します。

処理を中断したいのか処理の待ち受けを中断したいのか

より詳しくAnimationの中断を考えます。例として、タックル攻撃モーションのキャンセルを挙げてみましょう。今回タックル攻撃はRootMotionを使っているため、移動処理を適切に切り替える必要があります。

// これは何をするタスク?何をすべきタスク?
public async UniTask PlayAnimAsync(string clipPath, CancellationToken token)
{
    AnimationClip clip = await MyAssetLoader.LoasAsync(clipPath);
    animator.Play(clip, ......);
}

// ユーザーコード
public async void BadCodeAsync(CancellationToken token)
{
    // タックル攻撃中はナビメッシュの移動を切って、RootMotionで動かす
    navMesh.updatePosition = false;
    animator.applyRootMotion = true; 
    await PlayAnimAsync("タックルattack", token);

    // タックル攻撃モーションが完了したのでナビメッシュを戻してRootMotionをオフにする
    navMesh.updatePosition = true;
    animator.applyRootMotion = false; 
}

さて、上記コードばバグっています。ユーザーコードはモーション再生の完了を待っていますが、PlayAnimAsyncはロードを待って再生処理をしているだけです。実際に必要なのは再生するタスクではなく再生完了を待つタスクだったのです。

さらにバグは悪化します。タックル攻撃中にダメージを受けたため、タックル攻撃をキャンセルして被ダメモーションを再生したいとします。PlayAnimAsyncはロードをキャンセルするだけなので、実際にはタックル攻撃モーションをキャンセルすることはできません。
 何をawait していて、何をキャンセルしたいのかをちゃんと考える必要があるのです。

基本的な中断要求に対する考え方

本稿では次の3原則を基本的な中断の考え方として解説します。

とある処理を行うときは

  1. 開始時に中断要求されていたら何もしない
  2. 実行中に中断要求があればなるはやで受け入れる
  3. 完了後に中断要求があれば完了してよい(完了したので中断できない)

3番は完了にするのではなく、元に戻すという考え方もあります。これは強い例外保証と似た考え方です。例えば、データのダウンロード処理をキャンセルしたいとします。ユーザーがダウンロードを開始したところあまりにも時間がかかり過ぎたため、キャンセルボタンを押しました。このとき、アプリはどのように振る舞うべきでしょうか?ダウンロード処理はすぐさま止めるとしても、ダウンロード済みのキャッシュデータは削除すべきでしょうか?ちょうどダウンロード完了したときにボタンが押されたときはすべてのDL済みデータを削除すべきでしょうか?
これは仕様や設計思想によりますので一概には言えません。

本稿では完了後に中断要求をチェックしません。なぜなら下流の処理の冒頭でチェックすべきだから。またUniTaskがそんな感じの設計になっているから。

https://qiita.com/Kokudori/items/987073d59529b6c9a37c

具体的なノウハウ

中断に対する解像度が上がったところで具体的なコーディングノウハウを記します。なお、個人の感想です。

CancellationToken を渡せ

何も考えずにasync UniTaskなメソッドには CancellationToken を引数で付与しましょう。

public async UniTask RunAsync(CancellationToken cancellationToken)
{
    // 略
}

// これは好きじゃない
// public async UniTask RunAsync(CancellationToken cancellationToken = default)

このとき、デフォルトパラメータ =defaultは付けません。呼び出し側がキャンセルするつもりがないならdefault を明示的に渡すべきと考えるからです。加えて、呼び出し側もまた非同期関数になることがほとんどであるからです。CancellationToken を更に親からもらっているはずなので、cancellationToken をそのまま下流に流すだけになるはずです。

CancellationToken を使え

引数でもらったからには使いましょう。cancellationToken は呼び出し側からの中断指示です。自タスクは可能な限り処理を中断させばなりません。

public async UniTask RunAsync(CancellationToken cancellationToken)
{
    // すでにキャンセルされているなら例外を投げる
    cancellationToken.ThrowIfCancellationRequested();

    // まだキャンセルされていないので処理にとりかかる
    なんか前処理();
    await なんか非同期処理(cancellationToken); //ctをそのまま渡す
    なんか後処理();
}

特に重要なのは 関数冒頭の cancellationToken.ThrowIfCancellationRequested();です。原則1の「開始時に中断要求されていたら何もしない」を遵守しましょう。これだけで8割方の中断処理に対応できます。

ポーリングするならCancelチェックする

次のタスクはリークしています。

public async UniTask 無限RunAsync(CancellationToken cancellationToken)
{
    while(true)
    {
        Debug.Log("だれもcancelチェックしてないから終わらないよ");
        await UniTask.Yield(cancellationToken:default);
    }

    // ここには来ない
}

無限ループでポーリングするのはいいのですが、ちゃんと中断要求をチェックして中断しましょう。
UniTask.YieldなどのUniTask系ファクトリータスクは内部でCancelチェックしてくれているので、cancellationToken を渡す限りちゃんと動きます。

正しくは次の通りです。

public async UniTask 中断できるRunAsync(CancellationToken cancellationToken)
{
    // すでにキャンセルされているなら例外を投げる
    cancellationToken.ThrowIfCancellationRequested();

    while(true)
    {
        await UniTask.Yield(cancellationToken:cancellationToken);
    }
}

原則2の「実行中に中断要求があればなるはやで受け入れる」の部分はUniTask.Yieldがチェックしてくれています。ありがとうUniTask。

CancellationToken を渡せないなら自分でCancelチェックする

何らかの理由によりCancellationTokenを渡せないやつがいます。
意図されたものなら良いのですが、レガシーコードやasync/awaitよくわからない人が書いたコードには多い印象。

public async UniTask RunAsync(CancellationToken cancellationToken)
{
    // すでにキャンセルされているなら例外を投げる
    cancellationToken.ThrowIfCancellationRequested();
    await 何かデータ作成する();

    // cancellationTokenを渡せないTask1
    cancellationToken.ThrowIfCancellationRequested();
    await 何か圧縮する();

    // cancellationTokenを渡せないTask2
    cancellationToken.ThrowIfCancellationRequested();
    await 何か保存する();

    // ここまで来たら完了しているのでもう中断しないことにする
    // cancellationToken.ThrowIfCancellationRequested();
}

原則2の「実行中に中断要求があればなるはやで受け入れる」を実現するため、await直前にも実施するべきです。本来なら下流のタスクがその冒頭でチェックしてキャンセルしてくれるはずなのですが、引数でcancellationTokenを渡せないので自前でチェックするしかありません。

中断したくないならそもそもキャンセルを受け付けない

仕様上、キャンセル・中断という概念が存在しない方がいい場合もあります。
 その最たる例は決済ではないでしょうか?世の中のECサイトで購入確認後の購入ボタンはキャンセルできません。キャンセルできたらやばいからです。クレジット決済システムや電子マネーシステムなど色々絡むので中断がめちゃむずになります。(というかできない)
 実際のシステムでは、購入処理のキャンセルではなく、キャンセル処理の発注という概念で解決しています。アマゾンや楽天市場などでは、注文の取り消し機能がありますね?このように +1する処理を中断するのではなく、+1を処理したあとに-1を処理することでなかったことにできます。

Unityで中断できないやつ

そもそもUnityEngine側で中断がサポートされていない機能がいくつか存在します。

  1. AssetBundle.LoadAsync()
  2. SceneManager.LoadSceneAsync()

この辺はゲーム実行時には別にキャンセルしなくてもいいんですが、UnityEditor実行時には開くレベル間違えちゃった取り消したーい、ということがあるのでキャンセルに対応してほしいところです。バトルに行こうとしたら間違えてロビーに入ったとかありませんか?なぜかロードが重くて取り消しに待たされてイライラしません?
Awaitableで改善されるといいなぁ。

Discussion