はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Unity】Unity 2023.1より登場したAwaitableの使い方まとめ(Unity公式版UniTask??)

はじめに

ついにUnity2023.1よりUnity公式版UniTaskが出ました。(結構語弊がありそうだか...)

github.com

ただ現段階ではUniTaskと同等・もしくはそれ以上な機能を持っているわけではなく、軽く触った限りはまだまだAPIが足りず発展途上かなといった感じです。

しかし段々と使いやすくなるのは間違いないでしょうし、外部ライブラリをなるべく使いたくない方にとって魅力的なことも確かです。今後利用されるケースも増えるかと思いますので、今のうちから触っておくのも悪くないかと思います。

docs.unity3d.com

ちなみに現段階ではしっかりとしたドキュメントはなさそうです...。

環境

Unity2023.1.0

ライフサイクルに紐づいたCancellationToken

MonoBehaviorのライフサイクルと紐づいたCancellationTokenがMonoBehaviorのプロパティとして実装されました。

その名もMonoBehaviour.destroyCancellationTokenです。
docs.unity3d.com

// 重要な箇所以外省略
namespace UnityEngine
{
  public class MonoBehaviour : Behaviour
  {
    private CancellationTokenSource m_CancellationTokenSource;

    /// <summary>
    ///   <para>Cancellation token raised when the MonoBehaviour is destroyed (Read Only).</para>
    /// </summary>
    public CancellationToken destroyCancellationToken
    {
      get
      {
        if (this.m_CancellationTokenSource == null)
        {
          this.m_CancellationTokenSource = new CancellationTokenSource();
          this.OnCancellationTokenCreated();
        }
        return this.m_CancellationTokenSource.Token;
      }
    }
  }
}

github.com

MonoBehaviorのDestroyのタイミングでCancelされるので、コルーチンのようにGameObjectが破棄されたタイミングでも処理が止まってくれるようになります。(ちゃんとCancellationTokenを扱っていればだが)

public class Sample : MonoBehaviour
{
    private async Awaitable Start()
    {
        // 1秒待つ
        await Awaitable.WaitForSecondsAsync(1, destroyCancellationToken);
    }
}

またPlayMode終了時(Editor上での話)もしくはアプリケーション終了時にキャンセルを発行するApplication.exitCancellationTokenなるものも用意されています。

CancellationToken token = Application.exitCancellationToken;

Unity - Scripting API: Application.exitCancellationToken

使い方

現在実装されているAwaitableのstaticメソッドについて列挙します。

private async Awaitable Start()
{
    // 1秒待つ
    // Task.Delayと異なり、Time.timeScaleの影響を受ける
    await Awaitable.WaitForSecondsAsync(1f, destroyCancellationToken);
        
    // 現在のフレームの最後まで待つ
    await Awaitable.EndOfFrameAsync(destroyCancellationToken);

    // 次のFixedUpdateまで待つ
    await Awaitable.FixedUpdateAsync(destroyCancellationToken);

    // 次のフレームまで待つ
    await Awaitable.NextFrameAsync(destroyCancellationToken);
        
    // 別スレッドに移動
    await Awaitable.BackgroundThreadAsync();

    // メインスレッドに移動
    await Awaitable.MainThreadAsync();
}


Awaitable.WaitForSecondsAsyncはちゃんとTime.timeScaleの影響を受けてくれるみたいですね。

PlayerLoopの場所について

ここはマニアックなので読み飛ばしてもらって大丈夫です。

PlayerLoopの細かいイベントたちはUniTaskの作者さんがGitHubに載せてくれているので載せておきます。(一部UniTaskのイベントが入っていますが今回は無視してください)

Unityマニアな人は実際PlayerLoopのどこの箇所で実行されるか気になると思うので、それぞれの処理の前後にDebug.Logを仕組むように改造してみました。

public struct CustomEvent { }

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Init()
{
    var playerLoop = PlayerLoop.GetCurrentPlayerLoop();

    Set(ref playerLoop);

    PlayerLoop.SetPlayerLoop(playerLoop);
}

private static void Set(ref PlayerLoopSystem playerLoop)
{
    playerLoop.subSystemList ??= Array.Empty<PlayerLoopSystem>();

    var type = playerLoop.type;

    playerLoop = new PlayerLoopSystem
    {
        type = playerLoop.type,
        updateDelegate = playerLoop.updateDelegate,
        subSystemList =
            playerLoop.subSystemList
                .Prepend(new PlayerLoopSystem
                {
                    type = typeof(CustomEvent),
                    updateDelegate = () => Debug.Log($"{type} : Start"),
                })
                .Append(new PlayerLoopSystem
                {
                    type = typeof(CustomEvent),
                    updateDelegate = () => Debug.Log($"{type} : End"),
                })
                .ToArray(),
        updateFunction = playerLoop.updateFunction,
        loopConditionFunction = playerLoop.loopConditionFunction,
    };

    if (playerLoop.subSystemList.Length == 2) return;
    for(var i = 0; i < playerLoop.subSystemList.Length; i++)
    {
        Set(ref playerLoop.subSystemList[i]);
    }
}

PlayerLoopSystemが構造体なので少し手間取りましたが、ちゃんと計測できていそうです。

結果としては以下のようになりました。

関数名 await後が実行されるタイミング
WaitForSecondsAsync ScriptRunBehaviourUpdateとScriptRunDelayedDynamicFrameRateの間。
EndOfFrameAsync PostLateUpdateの後。つまり本当に最後。
FixedUpdateAsync DirectorFixedUpdatePostPhysicsとScriptRunDelayedFixedFrameRateの間。
NextFrameAsync ScriptRunBehaviourUpdateとScriptRunDelayedDynamicFrameRateの間。

返り値を持つ場合

Awaitable<T>がちゃんと存在します。

private async Awaitable Start()
{
    var value = await HogeAsync(destroyCancellationToken);
        
    // 1
    Debug.Log(value);
}

private static async Awaitable<int> HogeAsync(CancellationToken token)
{
    return 1;
}

エラーの面白い性質

C#ではOperationCanceledExceptionというエラーを特殊に扱っています。

具体的には呼び出し元のメソッドがTaskかAwaitableを返す場合はエラーが出力されるわけではありません。

private async Awaitable Start()
{
    // ログ出力もされず、アプリが止まるわけでもない
    throw new OperationCanceledException();
}
    
private async Task Start2()
{
    // ログ出力もされず、アプリが止まるわけでもない
    throw new OperationCanceledException();
}
    
private async void Start3()
{
    // エラーが出力される
    throw new OperationCanceledException();
}
    
private void Start4()
{
    // エラーが出力される
    throw new OperationCanceledException();
}