【UniTask】コールバックを待機できるように変換する

はじめに

処理の終了をコールバックで受け取る形式の非同期処理は UniTask の TrySetResult で async/await で待機可能に変換できます。

TrySetResultを使った変換は以下のように実装できます。

// 完了をコールバックで通知するメソッド
public void Play(Action onCompleted) { /*...*/ }

// TrySetResultを使って待機する
public async UniTask PlayAsync()
{
    var source = new UniTaskCompletionSource();
    Play(() => { source.TrySetResult(); }); // これで完了するまで待機する
    await source.Task;
}

具体的に旧来の実装と使い方と比較してみます。

// ★★今まで:コールバック形式の呼び出し方法
public void Sample()
{
    Play(OnCompleted); // コールバックを指定する
}
public void OnCompleted() // ← 終了したら呼び出される処理
{
    Debug.Log("処理完了", this);
    image.color = Color.red; // 例えば終わったら色を赤に変える
}

// - - - - - - - - - - - - - 

// ★★TrySetResultを使用すると終了をawaitで待てる
public async UniTask()
{
    await PlayAsync(); // ★こんな風に終了を待てる

    Debug.Log("処理完了", this);
    image.color = Color.red;
}

実際の実装では、キャンセル処理は別口で実装 + オブジェクトが破棄された時の対応は別途必要ですが概ねこのように実装できます。

Unity は Animation や Timeline の終了をコールバックで受け取れますが呼び出し元は await で待機できたらなという局面があり、毎回 UniTaskCompletionSource を作って待機用に処理を書くのがやや手間な場合があります。

このため今回は、このような定型的な実装を簡単に書けるようにするための実装を紹介したいと思います。

確認環境

この記事は以下環境で確認しています。

  • Unity 2021.3.25f1
  • UniTask 2.3.3
  • Windows11 + VisualStudio2022
  • Editor上で動作を確認

実装コード

使い方

先に使用方法の紹介です。

下のように終了をコールバックで受け取る処理があった場合を想定しています。

// ★(1) 完了をコールバックで受け取る
public void Play(Action callback);

// ★(2) メソッドに引数がある
public void Play(string a, string b, int a, Action callback);

// ★(3) コールバックに引数(戻り値)がある
public void Play(Action<int> callback);

上記メソッドはそれぞれ以下のように待機可能です。

public async UniTask PlayAsync()
{
    // ★(1)
    await this.ToAsync(Play);

    // ★(2)
    await this.ToAsync(Play, "a", "b", 10);

    // ★(3)
    int result = await this.ToAsync<int>(Play);
}

UniTaskTrySetAsyncExtensionsクラス

実装コードです。

using System;
using Cysharp.Threading.Tasks;

public delegate void MyAction(Action callback);
public delegate void MyAction<T1>(T1 arg1, Action callback);
public delegate void MyAction<T1, T2>(T1 arg1, T2 arg2, Action callback);
public delegate void MyAction<T1, T2, T3>(T1 arg1, T2 arg2, T3 arg3, Action callback);

public delegate void MyFunc<TResult>(Action<TResult> callback);
public delegate void MyFunc<T1, TResult>(T1 arg1, Action<TResult> callback);
public delegate void MyFunc<T1, T2, TResult>(T1 arg1, T2 arg2, Action<TResult> callback);
public delegate void MyFunc<T1, T2, T3, TResult>(T1 arg1, T2 arg2, T3 arg3, Action<TResult> callback);

/// <summary>
/// コールバックでの終了待ちをawaitできるようにする拡張機能を定義します。
/// </summary>
public static class UniTaskTrySetAsyncExtensions
{
    // -------------------------------------------------------------
    // 戻り値なし
    // -------------------------------------------------------------
    // (1) こんな感じのコールバックで通知があるメソッドを...
    // public void ShowDialog(Action onClose)
    // {
    //     onClose?.Invoke(); // 終わったらコールバックで終了が通知される
    // }
    // public void ShowDialog1(string arg1, Action onClose)
    // {
    //     onClose?.Invoke();
    // }
    //
    // (2) 以下のようにawaitで終了を待機できるように変換します
    // public async void Foo()
    // {
    //     await this.ToAsync(_p1.ShowDialog);
    //     await this.ToAsync(_p1.ShowDialog1, "hoge");
    // }
    //
    // キャンセルしたいときは以下のように記述すると
    // この待機はOperationCanceledExceptionを投げて終了する
    // ** ただし待機させてるメソッドの動作は停止しないので別途停止する必要がある。
    // await this.ToAsync(_currentCircle.Play).AttachExternalCancellation(this.GetCancellationTokenOnDestroy());
    //
    //
    // -------------------------------------------------------------
    public static UniTask ToAsync(MyAction method)
    {
        var source = new UniTaskCompletionSource();
        method(() => { source.TrySetResult(); });
        return source.Task;
    }
    public static UniTask ToAsync<T1>(MyAction<T1> method, T1 arg1)
    {
        var source = new UniTaskCompletionSource();
        method(arg1, () => { source.TrySetResult(); });
        return source.Task;
    }
    public static UniTask ToAsync<T1, T2>(MyAction<T1, T2> method, T1 arg1, T2 arg2)
    {
        var source = new UniTaskCompletionSource();
        method(arg1, arg2, () => { source.TrySetResult(); });
        return source.Task;
    }
    public static UniTask ToAsync<T1, T2, T3>(MyAction<T1, T2, T3> method, T1 arg1, T2 arg2, T3 arg3)
    {
        var source = new UniTaskCompletionSource();
        method(arg1, arg2, arg3, () => { source.TrySetResult(); });
        return source.Task;
    }

    public static UniTask ToAsync(this UnityEngine.Object _, MyAction method) => ToAsync(method);
    public static UniTask ToAsync<T1>(this UnityEngine.Object _, MyAction<T1> method, T1 arg1) => ToAsync(method, arg1);
    public static UniTask ToAsync<T1, T2>(this UnityEngine.Object _, MyAction<T1, T2> method, T1 arg1, T2 arg2) => ToAsync(method, arg1, arg2);
    public static UniTask ToAsync<T1, T2, T3>(this UnityEngine.Object _, MyAction<T1, T2, T3> method, T1 arg1, T2 arg2, T3 arg3) => ToAsync(method, arg1, arg2, arg3);

    public static UniTask ToAsync(this IUtilityContext _, MyAction method) => ToAsync(method);
    public static UniTask ToAsync<T1>(this IUtilityContext _, MyAction<T1> method, T1 arg1) => ToAsync(method, arg1);
    public static UniTask ToAsync<T1, T2>(this IUtilityContext _, MyAction<T1, T2> method, T1 arg1, T2 arg2) => ToAsync(method, arg1, arg2);
    public static UniTask ToAsync<T1, T2, T3>(this IUtilityContext _, MyAction<T1, T2, T3> method, T1 arg1, T2 arg2, T3 arg3) => ToAsync(method, arg1, arg2, arg3);

    // -------------------------------------------------------------
    // 戻り値あり
    // -------------------------------------------------------------
    // (1) こんな感じのコールバックで実行結果の値を受け取るメソッドを...
    // public void LoadItems(Action<string> completed)
    // {
    //     completed?.Invoke("hoge"); // 終了したら実行結果の値をコールバックで受け取る
    // }
    //
    // public void LoadItems1(string arg1, Action<string> completed)
    // {
    //     completed?.Invoke("hoge1");
    // }
    //
    // (2) 以下のようにawaitで終了を待機できるように変換します
    // public async void Foo()
    // {
    //     string ret1 = await this.ToAsync<string>(_p1.LoadItems);
    //     string ret2 = await this.ToAsync<string, string>(_p1.LoadItems1, "hoge");
    // }
    // -------------------------------------------------------------
    public static UniTask<TResult> ToAsync<TResult>(MyFunc<TResult> method)
    {
        var source = new UniTaskCompletionSource<TResult>();
        method(ret => { source.TrySetResult(ret); });
        return source.Task;
    }
    public static UniTask<TResult> ToAsync<TResult, T1>(MyFunc<T1, TResult> method, T1 arg1)
    {
        var source = new UniTaskCompletionSource<TResult>();
        method(arg1, ret => { source.TrySetResult(ret); });
        return source.Task;
    }
    public static UniTask<TResult> ToAsync<TResult, T1, T2>(MyFunc<T1, T2, TResult> method, T1 arg1, T2 arg2)
    {
        var source = new UniTaskCompletionSource<TResult>();
        method(arg1, arg2, ret => { source.TrySetResult(ret); });
        return source.Task;
    }
    public static UniTask<TResult> ToAsync<TResult, T1, T2, T3>(MyFunc<T1, T2, T3, TResult> method, T1 arg1, T2 arg2, T3 arg3)
    {
        var source = new UniTaskCompletionSource<TResult>();
        method(arg1, arg2, arg3, ret => { source.TrySetResult(ret); });
        return source.Task;
    }

    public static UniTask<TResult> ToAsync<TResult>(this UnityEngine.Object _, MyFunc<TResult> method) => ToAsync(method);
    public static UniTask<TResult> ToAsync<TResult, T1>(this UnityEngine.Object _, MyFunc<T1, TResult> method, T1 arg1) => ToAsync(method, arg1);
    public static UniTask<TResult> ToAsync<TResult, T1, T2>(this UnityEngine.Object _, MyFunc<T1, T2, TResult> method, T1 arg1, T2 arg2) => ToAsync(method, arg1, arg2);
    public static UniTask<TResult> ToAsync<TResult, T1, T2, T3>(this UnityEngine.Object _, MyFunc<T1, T2, T3, TResult> method, T1 arg1, T2 arg2, T3 arg3) => ToAsync(method, arg1, arg2, arg3);

    public static UniTask<TResult> ToAsync<TResult>(this IUtilityContext _, MyFunc<TResult> method) => ToAsync(method);
    public static UniTask<TResult> ToAsync<TResult, T1>(this IUtilityContext _, MyFunc<T1, TResult> method, T1 arg1) => ToAsync(method, arg1);
    public static UniTask<TResult> ToAsync<TResult, T1, T2>(this IUtilityContext _, MyFunc<T1, T2, TResult> method, T1 arg1, T2 arg2) => ToAsync(method, arg1, arg2);
    public static UniTask<TResult> ToAsync<TResult, T1, T2, T3>(this IUtilityContext _, MyFunc<T1, T2, T3, TResult> method, T1 arg1, T2 arg2, T3 arg3) => ToAsync(method, arg1, arg2, arg3);
}

/// <summary>
/// ユーティリティ機能をオブジェクトに追加するためのマーカーインターフェース
/// </summary>
public interface IUtilityContext{ }

Unity の Monobehaviour(正確には UnityEngine.Object)上では上記処理が使用可能になります。

MonoBehaviour 以外はクラスに IUtilityContext を継承することで拡張メソッドが見えるようになり変換が可能です。常時見えてる必要がないので使用するときにマーカーインターフェースを付与して機能を使用できるようにしています。

この動作が好みでない場合、以下の通り各自カスタマイズが可能です。

  • IUtilityContext を System.Object に変更することで常にメソッドが使用可能にする
  • 名前空間を使用してその名前空間を using した時だけ使用可能にする