Pages - Menu

2018年12月7日金曜日

[C#, Unity]Coroutineを逐次処理するCoroutine

やりたいこと

逐次実行する複数のCoroutineの中で、一連の処理を全部中断したい。

CoroutineRunner.cs
public IEnumerator HogeCoroutine()
{
    yield return CoroutineA();
    yield return CoroutineB();
    yield return CoroutineC();
}

private IEnumerable CoroutineA()
{
    ...
}
private IEnumerable CoroutineB()
{
    ...
    // ここで一連の処理を全て中断したい
}
private IEnumerable CoroutineC()
{
    ...
}

愚直にやる

キャンセルフラグを持っておいてキャンセルされたら外側のCoroutineでyield breakします。
単純だけどCoroutineが更にネストしたりするとめんどくさい。

CoroutineRunner.cs
private isCancelled = false;

public IEnumerator HogeCoroutine()
{
    IEnumerator[] coroutines = new IEnumerator[]
    {
        CoroutineA(), CoroutineB(), CoroutineC(),
    }
    foreach(IEnumerator e in coroutines)
    {
        yield return e;
        if(isCancelled) yield break;
    }
}

private IEnumerable CoroutineA()
{
    ...
}
private IEnumerable CoroutineB()
{
    ...
    // ここで一連の処理を全て中断したい
    isCancelled = true;
    yield break;
}
private IEnumerable CoroutineC()
{
    ...
}

Coroutineを分解する

各CoroutineのMoveNextの呼び出しをひとつのループで呼び出す。
ここまでやると後からCoroutineを追加したり、タイムアウトや割り込みを仕込んだりできるので応用が利きます。

CoroutineRunner.cs
public class CoroutineRunner{
    private Queue<IEnumerator> queue = new Queue<IEnumerator>();
    private bool isRequestedCancel = false;
    
    public Append(params IEnumerator[] coroutines)
    {
        foreach(var e in coroutines) queue.Enqueue(e);
    }
    
    public void Cancel()
    {
        isRequestedCancel = true;
    }
    
    public IEnumerator Coroutine()
    {
        while (true)
        {
            if (isRequestedCancel) break;
            if (queue.Count == 0) break;
    
            if (peek.MoveNext())
            {
                yield return peek.Current;
            }
            else
            {
                queue.Dequeue();
            }
        }
    }
}

private CoroutineRunner runner = new CoroutineRunner();

public IEnumerator HogeCoroutine()
{
    runner.Append(CoroutineA(), CoroutineB(), CoroutineC());
    yield return runner.Coroutine();
}

private IEnumerable CoroutineA()
{
    ...
}
private IEnumerable CoroutineB()
{
    ...
    // ここで一連の処理を全て中断したい
    runner.Cancel();
}
private IEnumerable CoroutineC()
{
    ...
}

ちなみに僕が普段使いしているものはこちらです。

[C#, Unity]Coroutineを自前で実行する

C#のCoroutineへの理解を深めましょう。
UnityでMonoBehaviourが無くてもCoroutineを実行できる1ようになったり、Coroutineの拡張をできるようになったりできます。

Coroutine is 何

C#のCoroutineは「処理を一旦止めて途中から処理を再開できる」ブロック2です。
C#ではyieldというキーワードを用いて、メソッドやプロパティをCoroutineにできます。
Coroutineブロックの戻り値はIEnumeratorまたはIEnumerableでなければなりません。
このCoroutineを1回呼び出すには、IEnumerator.MoveNext()を使います。

EnumeratorCoroutineSample.cs
//  IEnumeratorを返すコルーチンメソッド
public IEnumerator<int> SomeCoroutine()
{
    yield return 1;
    //  最初の呼び出しではここまで実行される

    yield return 2;
    //  次の呼び出しではここまで

    yield break;
    //  これ以降は処理しない

    yield return 3;
}

public void Hoge()
{
    IEnumerator<int> coroutine = SomeCoroutine();

    //  1回目の呼び出し
    coroutine.MoveNext();
    Assert.AreEqual(1, coroutine.Current);  //  pass

    //  2回目の呼び出し
    coroutine.MoveNext();
    Assert.AreEqual(2, coroutine.Current);  //  pass
}
EnumerableCoroutineSample.cs
//  IEnumerableを返すコルーチンメソッド
public IEnumerable<int> SomeCoroutine()
{
    yield return 1;
    //  最初の呼び出しではここまで実行される

    yield return 2;
    //  次の呼び出しではここまで

    yield break;
    //  これ以降は処理しない

    yield return 3;
}

public void Hoge()
{
    IEnumerator<int> coroutine = SomeCoroutine().GetEnumerator();

    //  1回目の呼び出し
    coroutine.MoveNext();
    Assert.AreEqual(1, coroutine.Current);  //  pass

    //  2回目の呼び出し
    coroutine.MoveNext();
    Assert.AreEqual(2, coroutine.Current);  //  pass
}

yieldキーワードを用いたブロックはIEnumeratorを実装したクラスにコンパイルされます。
yieldはIEnumeratorを簡単に実装するための構文というわけですね。

Coroutineを最後まで実行する

OnNextがfalseになるまで実行すれば良いです。

CoroutineSample.cs
//  IEnumeratorを返すコルーチンメソッド
public IEnumerator<int> SomeEnumeratorCoroutine()
{
    yield return 1;
    //  最初の呼び出しではここまで実行される

    yield return 2;
    //  次の呼び出しではここまで

    yield break;
    //  これ以降は処理しない

    yield return 3;
}
//  IEnumerableを返すコルーチンメソッド
public IEnumerable<int> SomeEnumerableCoroutine()
{
    yield return 1;
    //  最初の呼び出しではここまで実行される

    yield return 2;
    //  次の呼び出しではここまで

    yield break;
    //  これ以降は処理しない

    yield return 3;
}

public void Hoge()
{
    // Enumeratorを最後まで実行
    {
        int count = 0;
        IEnumerator<int> e = SomeEnumeratorCoroutine();
        while (e.MoveNext())
        {
            count++;
        }
        Assert.AreEqual(2, count);  //  pass
    }

    // Enumerableを最後まで実行
    {
        int count = 0;
        IEnumerator<int> e = SomeEnumerableCoroutine().GetEnumerator();
        while (e.MoveNext())
        {
            count++;
        }
        Assert.AreEqual(2, count);  //  pass
    }

    // Enumerableはこれでもいい(コンパイラが↑の形に展開する)
    {
        int count = 0;
        foreach(int _ in SomeEnumerableCoroutine())
        {
            count ++;
        }
        Assert.AreEqual(2, count);  //  pass
    }
}

UnityのCoroutine

UnityではCoroutineをフレームを跨いだ継続処理のために利用します。
UnityのComponentモデルはシングルスレッドなのでUI処理を止めずにフレームを跨いだ処理を実行するには
処理を途中で止める必要があり、これを実装するのはCoroutineを使うのが簡単なわけです。

UnityのMonoBehaviour.StartCoroutine()は毎フレームMoveNext()を呼び出す実装です。
また、コルーチンの中でIEnumeratorをreturnすると、コルーチンをスタックして中のIEnumeratorのMoveNext()を呼び出すような仕組みになっていることでしょう。
StartCoroutineを自前で簡単に実装するならこのような形になるのではないでしょうか。実際にはStopCoroutineやら例外やらAsyncOperationやら何やらがあるのでもっと複雑でしょうけどね。

MyStartCoroutine.cs
public void StartCoroutineSync(IEnumerator coroutine)
{
    Coroutine c = new Coroutine(coroutine);
    while (c.MoveNext()) { }
}


public class CoroutineRunner : MonoBehaviour {
    public static List<Coroutine> Coroutines { get; private set; } = new List<Coroutine>();

    public void Update()
    {
        Coroutines.ForEach(c => c.MoveNext());
        Coroutines.RemoveAll(c => !c.HasNext);
    }

    public void StartCoroutine(IEnumerator coroutine)
    {
        Coroutine c = new Coroutine(coroutine);
        Coroutines.Add(c);
    }
}

public class Coroutine : IEnumerator {

    private Stack<IEnumerator> coroutineStack = new Stack<IEnumerator>();

    public Coroutine(IEnumerator coroutine)
    {
        coroutineStack.Push(coroutine);
    }

    public object Current { get; private set; }

    public bool HasNext { get; private set; } = true;

    public bool MoveNext()
    {
        while (coroutineStack.Count > 0)
        {
            IEnumerator peek = coroutineStack.Peek();

            bool hasNext = peek.MoveNext();

            if (!hasNext)
            {
                coroutineStack.Pop();
                continue;
            }

            if (peek.Current is IEnumerator)
            {
                coroutineStack.Push((IEnumerator)peek.Current);
                continue;
            }
            else
            {
                Current = peek.Current;
                HasNext = true;
                return true;
            }
        }

        HasNext = false;
        return false;
    }

    public void Reset()
    {
        throw new NotImplementedException();
    }
}

これでMonoBehaviour.StartCoroutine()が無くてもコルーチンの実行ができますね。

  1. CoroutineのテストがPlayModeでなくてもできる!

  2. iterator blockと呼びます。