徳島ゲーム開発ごっこ 技術ブログ

ゲームを作るために役に立ったり立たなかったりする技術を学んでいきます!

【Unity】カスタムコルーチンを使おうとしたけど、使いませんでした

 僕はStartCoroutine()をよく使います。
 コルーチンは便利なものです。
 ただ処理を記述する際に、もう少し何とかならないかと思う箇所もあります。
 その部分を解決するために、今回はCustomYieldInstructionを使ってカスタムコルーチンの作成にチャレンジしてみました。

 さあ、僕はうまく扱うことができるのでしょうか。


この記事にはUnity2017.1.0f3を使用しています。

IEnumeratorで返す関数の結果が欲しくて……

 コルーチンで処理中の関数内で、IEnumeratorを返す関数をyield returnで使えばその関数の終わりまで処理を待ってくれて便利です。
 でもIEnumeratorを返している以上、この関数に成否があった場合に取得が困難です。
 僕は引数にコールバック関数を渡して判断する方法を主に用いています。

using UnityEngine;
using System.Collections;

public class TestRoutine : MonoBehaviour
{

    private IEnumerator Start()
    {
        Debug.LogFormat( "Start Time={0} Frame={1}", Time.time, Time.frameCount );

        bool result = false;
        System.Action<bool> onFinished  = i_result => result = i_result;
        yield return WaitProcess( onFinished );

        Debug.LogFormat( "Finish Result={0} Time={1} Frame={2}", result, Time.time, Time.frameCount );

    }

    IEnumerator WaitProcess( System.Action<bool> i_onFinished )
    {
        yield return new WaitForSeconds( 3.0f );

        // 何かこの処理に対して"結果"というものがあるとして….
        bool result = Random.Range( 0, 2 ) == 0 ? true : false;

        i_onFinished( result );
    }

} // class TestRoutine

 ただ、ラムダ式でコールバック関数を用意するなど、関数を呼ぶための前準備が多く必要なので行数もかさむし、そもそも面倒です

 他にもそのクラスのメンバ変数に値を入れて参照する方式もありますが……。

using UnityEngine;
using System.Collections;

public class TestRoutine : MonoBehaviour
{
    private bool    m_result    = false;

    private IEnumerator Start()
    {
        Debug.LogFormat( "Start Time={0} Frame={1}", Time.time, Time.frameCount );

        yield return WaitProcess();

        Debug.LogFormat( "Finish Result={0} Time={1} Frame={2}", m_result, Time.time, Time.frameCount );
    }

    IEnumerator WaitProcess()
    {
        yield return new WaitForSeconds( 3.0f );

        // 何かこの処理に対して"結果"というものがあるとして….
        bool result = Random.Range( 0, 2 ) == 0 ? true : false;

        m_result    = result;
    }

} // class TestRoutine

 ただ個人的にはちょっと……。
 この関数が複数同時に呼ばれた場合には対応できないですし、クラス内のすべての関数でアクセス出来るため、他の関数で間違えて書き換えてしまったり、そもそもこの変数の結果が残りっぱなしになってしまいます。

 ただ近所の噂では、CustomYieldInstructionという凄いやつがいるという話を聞きました。
 これを使えば、この問題が解決されるのではないでしょうか。

CustomYieldInstructionを使ってみたけど……

docs.unity3d.com

 スクリプトリファレンスを見たけど、いまいち使い方がわかりません。
 どう使えばよいのでしょうか。
 ここは先人の方々の知恵をお借りしましょう。
 CustomYieldInstructionはUnity 5.3から追加された機能。
 これならば誰かがいい感じの使っているはずです。

toriden.hatenablog.com

 こちらの方の記事を参考にして使ってみましょう。

using UnityEngine;
using System.Collections;

public class TestRoutine : MonoBehaviour
{
    private IEnumerator Start()
    {
        Debug.LogFormat( "Start Time={0} Frame={1}", Time.time, Time.frameCount );

        var process = new WaitProcess();
        yield return process;

        Debug.LogFormat( "Finish Result={0} Time={1} Frame={2}", process.Result, Time.time, Time.frameCount );
    }

} // class TestRoutine

public class WaitProcess : CustomYieldInstruction
{
    public WaitProcess()
    {
        m_routine   = Routine();
    }

    private IEnumerator m_routine   = null;

    public bool Result
    {
        get;
        private set;
    }
    
    public override bool keepWaiting
    {
        get
        {
            return m_routine.MoveNext();
        }
    }

    private IEnumerator Routine()
    {
        yield return new WaitForSeconds( 3.0f );

        // 何かこの処理に対して"結果"というものがあるとして….
        Result  = Random.Range( 0, 2 ) == 0 ? true : false;
    }
}

f:id:urahimono:20170813060343p:plain

Start Time=0 Frame=1
Finish Result=True Time=0.02 Frame=2

f:id:urahimono:20170813060405p:plain

 yield return new WaitForSeconds()の処理が正しく機能していないような気が……。
 確かに参考にした記事ではyield return nullを使っていたなぁ。
 機能していないというより、1フレームしか待ってないね。
 どうしてこんなことになるんでしょうか。

yieldを本当はよく分かっていなくて……

 うーん、これはyieldについて、僕がよく理解していないのが問題のようだなぁ。
 C#を知っていてUnityを使っているんじゃなくて、Unityを使いたいからC#を学んだような感じだからなぁ。
 そのためC#そのものには疎いんですよね。
 yieldについて少し勉強してみましょう。

yield キーワード

 こちらのサイトにこんなことが書かれていました。

yield return でオブジェクトを返すメソッドが呼び出されると、メソッドは何も実行せずにオブジェクトだけを返します。 このとき、オブジェクトの MoveNext() メソッドに対して yield return の内容が割り当てられます。 そして、MoveNext() が実行されるたびに、前回の yield return 文からブロックが再開されるという仕組みになっています。

 なるほど、MoveNext()を呼ぶと、次のyield returnまで処理を進めるのですね。

 今回作成したクラスではkeepWaitingプロパティにて、MoveNext()を呼んでいます。
 そのため、このクラスを呼び出し先のコルーチンにてkeepWaitingにアクセスするたびに先に進んでしまいます。
 yield return nullならよいのですが、WaitForSeconds()など一定の処理を待とうとする場合は、正しく動きません。
 困ったな。どうしましょう。
 これじゃ使えない……。

でも頑張れば何とかなるんじゃ……

 でもでも、これならやり方を変えれば何とかなるんじゃないかな。
 例えばさっきのWaitForSeconds()の場合は、while()yield return nullを組み合わせれば使えるようになるはず。

WaitProcessクラス

private IEnumerator Routine()
{
    float startTime = Time.time;
    while( startTime + 3.0f > Time.time )
    {
        yield return null;
    }

    // 何かこの処理に対して"結果"というものがあるとして….
    Result  = Random.Range( 0, 2 ) == 0 ? true : false;
}

f:id:urahimono:20170813060427p:plain

 ほらほら、何とかなったよ!
 WWWクラスの処理を待ったりする場合も、こういう風にwhile()yield return null組み合わせれば何とかなるはずだ。

 じゃあ、これからはこのCustomYieldInstructionを使って処理を書いていこう!
 ……という風にはなりませんよね。

 そもそも、引数にコールバックを仕込むのが面倒くさい、ということから今回CustomYieldInstructionの使用を検討しているのに、より面倒くさいことになっているではないか!
 yield returnの使い方には気を使わなくてはいけませんし、一つの非同期処理を作るのに一つのCustomYieldInstructionを継承したクラスを作らなくてはいけない。
 め、めんどうくせー……。

 というわけで今回のCustomYieldInstructionの検討結果としては、使わない、という結論に達するしかありません。
 もっと上手に使う方法があるのかもしれませんが、僕の頭ではアイディアが出てきそうにありません。

 うーん、残念。

そもそも普通のクラスで解決出来たじゃないか……

 そもそも今回の問題、普通のクラスを使うことで解決できないだろうか。
 一つずつ確かめていこう。

 まず、MonoBehaviourを継承していないクラスの非同期処理を、MonoBehaviourを継承したクラスで実行することは可能です。

using UnityEngine;
using System.Collections;

public class TestRoutine : MonoBehaviour
{
    private IEnumerator Start()
    {
        Debug.LogFormat( "Start Time={0} Frame={1}", Time.time, Time.frameCount );

        var externalProcess = new ExternalProcess();
        yield return externalProcess.Update();

        Debug.LogFormat( "Finish Result={0} Time={1} Frame={2}", externalProcess.Result, Time.time, Time.frameCount );
    }

} // class TestRoutine

public class ExternalProcess
{
    public bool Result
    {
        get;
        private set;
    }

    public IEnumerator Update()
    {
        yield return new WaitForSeconds( 3.0f );

        // 何かこの処理に対して"結果"というものがあるとして….
        Result  = Random.Range( 0, 2 ) == 0 ? true : false;
    }
}

 この書き方なら正しく動作します。
 今回は非同期処理内のIEnumerator Start()で処理を記述しているので直接呼んでいますが、別にStartCoroutine()で呼ぶことだって出来ますよね。

using UnityEngine;
using System.Collections;

public class TestRoutine : MonoBehaviour
{
    private void Start()
    {
        var externalProcess = new ExternalProcess();
        StartCoroutine( externalProcess.Update() );
    }

} // class TestRoutine

public class ExternalProcess
{
    public bool Result
    {
        get;
        private set;
    }

    public IEnumerator Update()
    {
        Debug.LogFormat( "Start Time={0} Frame={1}", Time.time, Time.frameCount );

        yield return new WaitForSeconds( 3.0f );

        // 何かこの処理に対して"結果"というものがあるとして….
        Result  = Random.Range( 0, 2 ) == 0 ? true : false;

        Debug.LogFormat( "Finish Result={0} Time={1} Frame={2}", Result, Time.time, Time.frameCount );
    }
}

 そして別のクラスから渡した非同期処理をこのクラスに渡して、同じように非同期処理を実行することも出来ちゃいます。

using UnityEngine;
using System.Collections;

public class TestRoutine : MonoBehaviour
{
    private IEnumerator Start()
    {
        Debug.LogFormat( "Start Time={0} Frame={1}", Time.time, Time.frameCount );

        var externalProcess = new ExternalProcess( WaitProcess() );
        yield return externalProcess.Update();

        Debug.LogFormat( "Finish Result={0} Time={1} Frame={2}", externalProcess.Result, Time.time, Time.frameCount );
    }

    private IEnumerator WaitProcess()
    {
        yield return new WaitForSeconds( 3.0f );
    }

} // class TestRoutine

public class ExternalProcess
{
    public ExternalProcess( IEnumerator i_process )
    {
        m_process   = i_process;
    }

    private IEnumerator m_process   = null;

    public bool Result
    {
        get;
        private set;
    }

    public IEnumerator Update()
    {
        yield return m_process;

        // 何かこの処理に対して"結果"というものがあるとして….
        Result  = Random.Range( 0, 2 ) == 0 ? true : false;
    }
}

 あれっ、これなら記述の仕方を工夫すれば問題解決できないかな。

using UnityEngine;
using System.Collections;

public class TestRoutine : MonoBehaviour
{
    private IEnumerator Start()
    {
        Debug.LogFormat( "Start Time={0} Frame={1}", Time.time, Time.frameCount );

        var process = new CoroutineHelper( WaitProcess );
        yield return process.Update();

        Debug.LogFormat( "Finish Result={0} Time={1} Frame={2}", process.Result, Time.time, Time.frameCount );
    }

    private IEnumerator WaitProcess( CoroutineHelper.OnFinishAction i_onFinished )
    {
        yield return new WaitForSeconds( 3.0f );

        // 何かこの処理に対して"結果"というものがあるとして….
        bool result = Random.Range( 0, 2 ) == 0 ? true : false;

        i_onFinished( result );
    }

} // class TestRoutine

public class CoroutineHelper
{
    public CoroutineHelper( ProcessFunc i_function )
    {
        m_function  = i_function;
    }

    public delegate void OnFinishAction( bool i_result );
    public delegate IEnumerator ProcessFunc( OnFinishAction i_onFinished );

    private ProcessFunc m_function;

    public bool Result
    {
        get;
        private set;
    }

    public IEnumerator Update()
    {
        if( m_function == null )
        {
            yield break;
        }

        OnFinishAction  onFinished  = i_result => Result = i_result;
        yield return m_function( onFinished );
    } 
}

 この書き方ならいけるはずだ。
 最初に話したコールバック方式を使って結果を判断する処理を、CoroutineHelper内の関数で記述しているので、呼出し元の関数はすごくスッキリします。
 今回は、結果の型をbool型で固定させていますが、ジェネリック型にすることで、どんな型でも対応できるようにできます。

using UnityEngine;
using System.Collections;

public class TestRoutine : MonoBehaviour
{
    private IEnumerator Start()
    {
        Debug.LogFormat( "StartA Time={0} Frame={1}", Time.time, Time.frameCount );

        var process = new CoroutineHelper<bool>( WaitProcess );
        yield return process.Update();

        Debug.LogFormat( "FinishA Result={0} Time={1} Frame={2}", process.Result, Time.time, Time.frameCount );

        Debug.LogFormat( "StartB Time={0} Frame={1}", Time.time, Time.frameCount );

        var processB = new CoroutineHelper<int>( WaitProcessB );
        yield return processB.Update();

        Debug.LogFormat( "FinishB Result={0} Time={1} Frame={2}", processB.Result, Time.time, Time.frameCount );
    }

    private IEnumerator WaitProcess( CoroutineHelper<bool>.OnFinishAction i_onFinished )
    {
        yield return new WaitForSeconds( 3.0f );

        // 何かこの処理に対して"結果"というものがあるとして….
        bool result = Random.Range( 0, 2 ) == 0 ? true : false;

        i_onFinished( result );
    }

    private IEnumerator WaitProcessB( CoroutineHelper<int>.OnFinishAction i_onFinished )
    {
        yield return new WaitForSeconds( 3.0f );

        // 何かこの処理に対して"結果"というものがあるとして….
        int result = Random.Range( 0, 10 );

        i_onFinished( result );
    }

} // class TestRoutine

public class CoroutineHelper<TResult>
{
    public CoroutineHelper( ProcessFunc i_function )
    {
        m_function  = i_function;
    }

    public delegate void OnFinishAction( TResult i_result );
    public delegate IEnumerator ProcessFunc( OnFinishAction i_onFinished );

    private ProcessFunc m_function;

    public TResult Result
    {
        get;
        private set;
    }

    public IEnumerator Update()
    {
        if( m_function == null )
        {
            yield break;
        }

        OnFinishAction  onFinished  = i_result => Result = i_result;
        yield return m_function( onFinished );
    } 
}

f:id:urahimono:20170813060444p:plain

StartA Time=0 Frame=1
FinishA Result=False Time=3.001926 Frame=172
StartB Time=3.001926 Frame=172
FinishB Result=6 Time=6.016861 Frame=354

 あらあら、いいじゃありませんか。

 このやり方なら、返り値数を増やすのだって簡単にできちゃいます。

using UnityEngine;
using System.Collections;

public class TestRoutine : MonoBehaviour
{
    private IEnumerator Start()
    {
        Debug.LogFormat( "StartA Time={0} Frame={1}", Time.time, Time.frameCount );

        var process = new CoroutineHelper<bool, string>( WaitProcess );
        yield return process.Update();

        Debug.LogFormat( "FinishA Result={0} Detail={1} Time={2} Frame={3}", process.Result, process.Detail, Time.time, Time.frameCount );
    }

    private IEnumerator WaitProcess( CoroutineHelper<bool,string>.OnFinishAction i_onFinished )
    {
        yield return new WaitForSeconds( 3.0f );

        // 何かこの処理に対して"結果"というものがあるとして….
        bool    result  = Random.Range( 0, 2 ) == 0 ? true : false;
        string  detail  = "I have a bad feeling about this.";

        i_onFinished( result, detail );
    }

} // class TestRoutine

public class CoroutineHelper<TResult, TDetail>
{
    public CoroutineHelper( ProcessFunc i_function )
    {
        m_function  = i_function;
    }

    public delegate void OnFinishAction( TResult i_result, TDetail i_detail );
    public delegate IEnumerator ProcessFunc( OnFinishAction i_onFinished );

    private ProcessFunc m_function;

    public TResult Result
    {
        get;
        private set;
    }

    public TDetail Detail
    {
        get;
        private set;
    }

    public IEnumerator Update()
    {
        if( m_function == null )
        {
            yield break;
        }

        OnFinishAction  onFinished  = ( i_result, i_detail ) =>
        {
            Result  = i_result; 
            Detail  = i_detail;
        };

        yield return m_function( onFinished );
    } 
}

f:id:urahimono:20170813060459p:plain

StartA Time=0 Frame=1
FinishA Result=True Detail=I have a bad feeling about this. Time=3.010901 Frame=172

 やった! 問題解決だ!
 引数の型の数は同名のクラス名でオーバーロード出来るため、引数一つ版と引数二つ版のクラスは共存できる点も〇です。

 そうか……、既に知っている技術で何とかできる問題だったのか。
 新しい技術を調べる前に、今ある技術で何とかする方法を模索することも大事ですね。

参考リンク

スクリプトリファレンス
Unity - Scripting API: CustomYieldInstruction

Unity公式ブログ
カスタムコルーチン | Unity Blog

さけのさかなのブログ
【Unity】 カスタムコルーチンでこのコールバックからの卒業 - さけのさかなのブログ

G-MODE Engineers' Blog
G-MODE Engineers' Blog — StartCoroutineは何をしているのか?作って学ぶコルーチンの仕組み(前編)

C#入門
yield キーワード