Help us understand the problem. What is going on with this article?

Unityのコルーチンをasync/awaitで待機できるように変換してみる

はじめに

Unity 2018からついに.NET 4.6がstableになり、async/awaitを使いこなせるようになっておく必要がでてきました。
今回は個人的な練習として、Unityのコルーチンをasync/awaitで待機できるようにしてみました。

そのまえに:UniRxを導入すれば全部できるよ

実はこれから紹介するような実装をわざわざする必要はありません。
UniRxを導入すればいい感じにコルーチンやAsyncOperationawaitできるようになります。

参考:.NET 4.6時代のUnityでUniRx

UniRxを使ったAsyncOperationの待機
using System;
using System.Collections;
using UnityEngine;
using UniRx;

class LoadTextureTest : MonoBehaviour
{
    async void Start()
    {
        var request = Resources.LoadAsync<Texture>("texture/player");

        await request; //テクスチャの読み込み待機

        GetComponent<Renderer>().material.mainTexture = request.asset as Texture;
    }
}

UniRxを使ったコルーチンの待機
using System;
using System.Collections;
using UnityEngine;
using UniRx;

class CoroutineAwaitTest: MonoBehaviour
{
    async void Start()
    {
        await MoveCoroutine(Vector3.forward, 1.0f, 1.0f);
        await MoveCoroutine(Vector3.right, 1.5f, 2.0f);
        await MoveCoroutine(Vector3.back, 2.0f, 5.0f);
    }

    /// <summary>
    /// 指定した方向に指定秒数移動する
    /// </summary>
    IEnumerator MoveCoroutine(Vector3 direction, float speed, float seconds)
    {
        var start = Time.time;
        while (Time.time - start <= seconds)
        {
            transform.position += direction * speed * Time.deltaTime;
            yield return null;
        }
    }
}

今回の記事は「自分でawaitableなオブジェクトを作るにはどうしたらいいのか」を紹介するのが目的のため、UniRxは使いません。

前提「 await を使えるようにするためには」

あるオブジェクトをawaitできるようにするためには、次の2つを実装する必要があります。

  • INotifyCompletionの実装およびIsCompletedGetResultを定義した「待機用オブジェクト
  • 待機用オブジェクトを返すGetAwaiter()メソッド

逆に言えば、この2つさえ実装してあればどんなものもawaitすることができるようになります。

コルーチンの終了をawaitできるようにしてみる

まずはシンプルな例として、「結果を返さないコルーチン」の終了をawaitで待機できるようにしてみます。

1. コルーチンを実行するGameObjectを用意する

コルーチンを処理するためにはなにかしらアクティブなGameObjectがシーン上に必要になります。
今回は予めシーンにGameObjectを用意しておくことにします。

名前は「MainThreadDispatcher」という名前にしておきます。

image.png
(中身は空でいいのでとりあえずGameObjectだけ用意しておく)

2. コルーチンを実際に処理するComponentを定義する

コルーチンを実際に処理して、その終了を通知することができるComponentを作ります。
名前はGameObject名に合わせて「MainThreadDispatcher」にします。

using System;
using System.Collections;
using UnityEngine;

public class MainThreadDispatcher : MonoBehaviour
{
    private static MainThreadDispatcher instance;

    public static MainThreadDispatcher Instance
    {
        get
        {
            if (instance == null)
            {
                //とりあえず雑に
                instance = GameObject.FindObjectOfType<MainThreadDispatcher>();
            }
            return instance;
        }
    }

    /// <summary>
    /// 登録されたコルーチンを実行する
    /// </summary>
    /// <param name="coroutine">対象のコルーチン</param>
    /// <param name="callback">終了時のコールバック</param>
    public void RegisterCoroutione(IEnumerator coroutine, Action callback)
    {
        StartCoroutine(WorkCoroutine(coroutine, callback));
    }

    /// <summary>
    /// コルーチンを実行し、終了時にコールバック関数を実行する
    /// </summary>
    private IEnumerator WorkCoroutine(IEnumerator target, Action callback)
    {
        yield return target;
        callback();
    }
}

そしてこれを先程のGameObjectに貼り付けておきます。

image.png

3. INotifyCompletionの実装およびIsCompletedGetResultを定義した「待機用オブジェクト」を定義する

await時に呼び出される待機用のオブジェクトを定義します。

内容としてはコルーチンをコンストラクタで受け取り、INotifyCompletion.OnCompleted内でごねごねする形になります。

/// <summary>
/// コルーチンの待機を補助するオブジェクト
/// </summary>
public class AwaitableEnumerator : INotifyCompletion
{
    /// <summary>
    /// 対象となるコルーチン
    /// </summary>
    private readonly IEnumerator target;

    /// <summary>
    /// 非同期処理が完了しているか
    /// </summary>
    public bool IsCompleted { get; private set; }

    /// <summary>
    /// 結果のオブジェクトを取得する場合に利用する
    /// 今回は結果の値がないのでvoid
    /// </summary>
    public void GetResult()
    {
    }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public AwaitableEnumerator(IEnumerator coroutine)
    {
        target = coroutine;
    }

    /// <summary>
    /// await開始時に実行される関数
    /// </summary>
    /// <param name="continuation">「await以降の処理」を扱うAction</param>
    public void OnCompleted(Action continuation)
    {
        // awaitが開始されたらコルーチンを実行する
        MainThreadDispatcher.Instance.RegisterCoroutione(target, () =>
        {
            // コルーチンが終了したらフラグを立てて、処理の継続を行う
            IsCompleted = true;
            continuation();
        });
    }
}

OnCompleted(Action continuation)が最も重要なメソッドです。
awaitが実行されたときにこのメソッドが呼び出されるため、ここに「awaitを終了する条件および継続処理の呼び出し」を記述します。
Action continuationは「await以降の処理」を指し示すActionです。そのため、非同期処理が終わったあとに処理を継続する場合は明示的にこのActionを実行する必要があります。

今回はこのOnCompleted()内でコルーチンを起動し、そのコールバックをもってawaitを終了するという形にします。

4. IEnumeratorからAwaitableEnumeratorに変換するGetAwaiter()を定義する。

コルーチン(IEnumrator)をawaitするためには、IEnumratorGetAwaiter()メソッドが定義されている必要があります。
今回は拡張メソッドでこれを追加します。

public static class IEnumeratorExtension
{
    public static AwaitableEnumerator GetAwaiter(this IEnumerator coroutine)
    {
        return new AwaitableEnumerator(coroutine);
    }
}

4.実際にコルーチンをawaitで待機する

これで下準備は完了したため、実際に使ってみましょう。

次のようなスクリプトをオブジェクトに貼り付けて実行してみます。

class AwaitCoroutineSample : MonoBehaviour
{

    async void Start()
    {
        // 前へ2秒進む
        await MoveCoroutine(Vector3.forward, 2.0f);

        // 右へ1秒進む
        await MoveCoroutine(Vector3.right, 1.0f);

        // 後ろへ3秒進む
        await MoveCoroutine(Vector3.back, 3.0f);
    }

    /// <summary>
    /// 指定した方向へ、指定した時間[秒]、移動する
    /// </summary>
    IEnumerator MoveCoroutine(Vector3 direction, float seconds)
    {
        var start = Time.time;
        while (Time.time - start <= seconds)
        {
            transform.position += direction * Time.deltaTime;
            yield return null;
        }
    }
}

1.gif

このように、コルーチンをasync/awaitで待ち受けることができるようになりました!

もっと簡単に定義するために「TaskCompletionSource」を使う

今回は説明のためにAwaitableパターンを自前ですべて実装しました。
ですがC#には非同期処理をTaskにラップしてしまう機能が用意されており、そちらを使うとAwaitableEnumeratorの定義すら不要になります。

というわけで、先程のGetAwaiterメソッドをTaskCompletionSourceを使って書き換えると次のようになります。

public static class IEnumeratorExtension
{

    public static TaskAwaiter<bool> GetAwaiter(this IEnumerator coroutine)
    {
        // TaskCompletionSourceを生成
        // 非ジェネリック版のTaskCompletionSourceは存在しないので、便宜上boolで代用
        var tcs = new TaskCompletionSource<bool>();

        MainThreadDispatcher.Instance.RegisterCoroutione(coroutine, () =>
        {
            // コルーチン終了時に値を設定
            tcs.TrySetResult(true);
        });

        return tcs.Task.GetAwaiter();
    }
}

このように、TaskCompletionSourceを使えば簡単にコルーチンの処理をTaskに置き換えてawaitできるようになります。

コルーチンの実行結果を取り出せるようにする

では続いて、awaitでコルーチンの実行結果を取得できるようにしてみましょう。

1. MainThreadDispatcherにメソッドを追加する

MainThreadDispatcherに次のメソッドを追加します。
単にコルーチンを実行するだけのメソッドです。

/// <summary>
/// 登録されたコルーチンを実行する
/// </summary>
/// <param name="coroutine">対象のコルーチン</param>
public void RegisterCoroutione(IEnumerator coroutine)
{
    StartCoroutine(coroutine);
}

2. statiメソッド経由でコルーチンからTaskに変換できるようにする

コルーチンから結果を取り出そうとした場合、コルーチン内で明示的にコールバックを呼び出す必要があります。
今回はコルーチンに直接TaskCompletionSourceを渡し、内部で結果を詰めてもらう仕組みにします。

そこで、そのようなコルーチンを簡単に生成できるようにするためのstaticメソッドを用意しておきます。

public static class Awaitable
{
    public static Task<T> Create<T>(Func<TaskCompletionSource<T>, IEnumerator> creation)
    {
        var tcs = new TaskCompletionSource<T>();

        // コルーチンを生成
        var coroutine = creation(tcs);

        MainThreadDispatcher.Instance.RegisterCoroutione(coroutine);

        return tcs.Task;
    }
}

Func<TaskCompletionSource<T>, IEnumerator>は「TaskCompletionSource<T>を引数にとり、IEnumeratorを戻り値とする関数」を意味します。

3. 実際にコルーチンを生成して利用する

以上で準備が整ったので、コルーチンをawaitしてみます。

さきほどのAwaitable.Create<string>()を用いて、結果を返すコルーチンを生成し、それをTask<string>に変換します。

using System;
using System.Collections;
using System.Threading.Tasks;
using Assets;
using UnityEngine;

public class AwaitCoroutineSample2 : MonoBehaviour
{
    async void Start()
    {
        var url = "https://unity3d.com/jp/";

        // コルーチンをawaitableにする
        var awaitableCoroutine = Awaitable.Create<String>(tcs => GetCoroutine(url, tcs));

        // await
        var result = await awaitableCoroutine;

        Debug.Log(result);
    }

    /// <summary>
    /// WWWを使って通信して結果をStringで通知するコルーチン
    /// </summary>
    IEnumerator GetCoroutine(string url, TaskCompletionSource<string> taskCompletionSource)
    {
        var www = new WWW(url);

        yield return www;

        if (!string.IsNullOrEmpty(www.error))
        {
            taskCompletionSource.TrySetException(new Exception(www.error));
        }
        else
        {
            taskCompletionSource.TrySetResult(www.text);
        }
    }
}


(こんなことしなくても)

UniRxを導入すれば全部できます。
使い方もだいたい同じです。

参考:UniRx入門 その5 -コルーチンとの併用-

using System;
using System.Collections;
using UniRx;
using UnityEngine;

public class AwaitCoroutineSample3 : MonoBehaviour
{

    async void Start()
    {
        var url = "https://unity3d.com/jp/";

        // await
        var result = await Observable.FromCoroutine<string>(observer => GetCoroutine(url, observer));

        // 結果がとれる
        Debug.Log(result);
    }

    /// WWWを使って通信して結果をStringで通知するコルーチン
    /// </summary>
    IEnumerator GetCoroutine(string url, IObserver<string> observer)
    {
        var www = new WWW(url);

        yield return www;

        if (!string.IsNullOrEmpty(www.error))
        {
            //失敗
            observer.OnError(new Exception(www.error));
        }
        else
        {
            // 正常系
            observer.OnNext(www.text);
            observer.OnCompleted();
        }
    }
}

最後に

自前でAwaitableパターンのインタフェースを満たしてしまえば、どんなものでもawaitできるようになります。
とくにTaskCompletionSourceを使うと非同期処理を簡単にTaskに変換してawaitableにできるのでおすすめです。

参考にしたサイト

非同期メソッド入門 (9) - Awaitableパターンの自前実装

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away