Unity3DとuGUIとUniRxのコラボでサーバ連携への第二歩

今日で一番やりたかった事をやってみる

機能のUnity3DとuGUIとUniRxのコラボでサーバ連携への第二歩の続きいきます。
スクリーンショットとりながら記事書くだけで結構時間がかかったので昨日はボタンをクリックするだけで終わってしまいました。

今回は、ボタンをクリックすると入力エリアに入力された値をサーバへ送信してサーバで計算された結果をテキストエリアに表示するところまでやってみます。

まずは前回のServerCall.csを修正

using UnityEngine;
using UnityEngine.UI;           // InputField や Text などの UI で必要
using UnityEngine.EventSystems; // uGUI のクリックイベントの処理で必要
using Unity.Linq;               // Linq で gameObject にアクセスする
using System.Collections;
using System.Collections.Generic; // Dictionary 使う時等
using System.Linq; // iOS で使うと動かない可能性あり
using UniRx;       // クリックイベントを Observable に変換する

public class ServerCall : ObservableMonoBehaviour {

    void Start() {

        // 入力フィールド1のコンポーネントを取得
        InputField txtPar1 = null;
        gameObject.Descendants()                       // Panel の子供を列挙する
            .Where(x => x.name == "txtPar1")           // txtPar1 の名前だけ抽出
            .Select(x => x.GetComponent<InputField>()) // InputField コンポーネントを取得
            .Where(x => x != null)                     // InputField コンポーネント取得結果、null でなければ続ける
            .ToObservable()                            // InputField コンポーネントを通知する
            .Subscribe(x => txtPar1 = x);              // 受け取った InputField コンポーネント通知を txtPar1 に格納する

        // 入力フィールド2のコンポーネントを取得
        InputField txtPar2 = null;
        gameObject.Descendants()
            .Where(x => x.name == "txtPar2")
            .Select(x => x.GetComponent<InputField>())
            .Where(x => x != null)
            .ToObservable()
            .Subscribe(x => txtPar2 = x);

        // テキストフィールドコンポーネントを取得
        Text txtResult = null;
        gameObject.Descendants()
            .Where(x => x.name == "txtResult")
            .Select(x => x.GetComponent<Text>())
            .Where(x => x != null)
            .ToObservable()
            .Subscribe(x => txtResult = x);

        // 上記コンポーネントが全部取れてるのが条件
        if (txtPar1 != null && txtPar2 != null && txtResult != null) {
            // クリックイベントを Observable に変換(つまり、クリックする度にメソッドチェインに値が流れていく)
            OnTriggerEventAsObservable()
                .Where(x => x.selectedObject.name == "btnSubmit") // 通知元の gameObject 名が "btnSubmit" が条件
                // ここで OnTriggerEventAsObservable() から ObservableWWW に変換
                .SelectMany(x => 
                    // WWW リクエストを発行し、結果テキストを通知させる
                    ObservableWWW.Post(
                        "http://megamin.jp/apps/20150206_api_sample/index.php", // アクセス先APIのURL
                        GetForm(new Dictionary<string, string>() { // POST用パラメータを生成するための補助メソッド
                            {"par1", txtPar1.text}, // POST値その1
                            {"par2", txtPar2.text}  // POST値その2
                        })
                    )
                )
                .Catch((WWWErrorException ex) => Observable.Return(ex.RawErrorMessage)) // WWW エラー時はメッセージに変換して通知
                .Subscribe(
                x => txtResult.text = x, // WWW アクセスに成功するとここの処理が実行される
                ex => txtResult.text = ex.Message, // 例外が発生するとこの処理が実行される
                () => txtResult.text += "\nCompleted."); // 全部完了するとこの処理が実行される
        }
    }

    /// <summary>
    /// フォーム生成ヘルパーメソッド
    /// </summary>
    /// <param name="dcPost">ポスト情報の Dictionary</param>
    /// <returns>WWWFormデータ</returns>
    public static WWWForm GetForm(Dictionary<string, string> dcPost) {
        WWWForm oForm = new WWWForm();
        foreach (var item in dcPost) {
            oForm.AddField(item.Key, item.Value);
        }
        return oForm;
    }

    Subject<BaseEventData> onTriggerEvent; // ボタンクリック Subject (通知する側)

    // クリックされると呼ばれるメソッド
    public void OnTriggerEvent(BaseEventData ev) {
        if (onTriggerEvent != null) onTriggerEvent.OnNext(ev); // オブザーバに通知するだけ
    }

    // Start メソッドで使用する、クリックイベントを Observable に変換するメソッド
    public IObservable<BaseEventData> OnTriggerEventAsObservable() {
        return onTriggerEvent ?? (onTriggerEvent = new Subject<BaseEventData>());
    }

}

44行目から61行目までの OnTriggerEventAsObservable() で始まる処理がボタンクリック通知を受け取ってからサーバーAPIをコールし、結果を受け取って画面に表示するまでの処理になっています。

実際に動かすとこんな感じです。

ソースコード解説

下記コードがイベント処理になります。
メソッドチェインで受け取ったイベントを選別して、変換して、例外処理して、結果表示してます。

OnTriggerEventAsObservable()
.Where(x => x.selectedObject.name == "btnSubmit")
.SelectMany(x => 
    ObservableWWW.Post(
        "http://megamin.jp/apps/20150206_api_sample/index.php",
        GetForm(new Dictionary<string, string>() {
            {"par1", txtPar1.text},
            {"par2", txtPar2.text}
        })
    )
)
.Catch((WWWErrorException ex) => Observable.Return(ex.RawErrorMessage))
.Subscribe(
    x => txtResult.text = x,
    ex => txtResult.text = ex.Message,
    () => txtResult.text += "\nCompleted."
);

すべての始まりはここから

OnTriggerEventAsObservable()

これは、uGUI のイベントシステムのクリックイベントを Observable に変換するメソッドとなります。
クリックイベントが発生する度にこのメソッドの後ろに連ねたメソッドチェインの中の処理に値が渡されます。

定義部分はこのような感じです。

Subject<BaseEventData> onTriggerEvent; // ボタンクリック Subject (通知する側)

// クリックされると呼ばれるメソッド
public void OnTriggerEvent(BaseEventData ev) {
    if (onTriggerEvent != null) onTriggerEvent.OnNext(ev); // オブザーバに通知するだけ
}

// Start メソッドで使用する、クリックイベントを Observable に変換するメソッド
public IObservable<BaseEventData> OnTriggerEventAsObservable() {
    return onTriggerEvent ?? (onTriggerEvent = new Subject<BaseEventData>());
}

OnTriggerEventAsObservable() は、Subject を返します。
これは監視される側のクラスになります。監視する対象は EventTrigger のイベント発生です。

昨日の記事では、btnSubmit と名づけられた Button コンポーネントのクリックイベントに対して OnTriggerEvent() を指定しました。

OnTriggerEvent() メソッドの中では OnTriggerAsObservable で返された Subject のインスタンスの通知メソッドを呼んでいます。

// クリックされると呼ばれるメソッド
public void OnTriggerEvent(BaseEventData ev) {
    if (onTriggerEvent != null) onTriggerEvent.OnNext(ev); // オブザーバに通知するだけ
}

上記の OnNext(ev) という部分が通知している処理となります。
この通知した ev という値がどこから出てくるかというと、

OnTriggerEventAsObservable()
.Where(x => x.selectedObject.name == "btnSubmit") // x が OnNext で通知された ev なのだ

ということになります。
BaseEventData の中には通知元 GUI コンポーネントオブジェクトの情報が入ってますので、オブジェクトの名前でどのボタンがクリックされたのか判定する事ができます。(ただし、名前がユニークでないとダメ)

上記 Where メソッドはこのようにも書けます。

OnTriggerEventAsObservable()
.Where(x => { return (x.selectedObject.name == "btnSubmit"); })

return される値が true のものだけ次のメソッドに値が渡されます。
次のメソッドには ev がそのまま無加工で通知されます。

BaseEventData通知をWWW通知に変換する

Where メソッドを通過すると、下記処理が実行されます。

.SelectMany(x => 
    ObservableWWW.Post(
        "http://megamin.jp/apps/20150206_api_sample/index.php",
        GetForm(new Dictionary<string, string>() {
            {"par1", txtPar1.text},
            {"par2", txtPar2.text}
        })
    )
)

SelectMany は別な Observable を実行し、次メソッドに値を渡します。
ObservableWWW は Unity3D の WWW コルーチンを実行し、結果を取得するとその結果を次メソッドに通知します。
ObservableWWW.Post() メソッドは、POST 処理をウェブサーバに実行し、結果テキストをメソッドに通知しているわけです。
ObservableWWW クラスは他にも GET したり色々な便利メソッドがあります。

“http://megamin.jp/apps/20150206_api_sample/index.php” は今回のサーバ API 的なサンプルスクリプトの配置先 URL です。
実際にソシャゲのサーバとかやる場合は色々きちんとしたところにきちんとしたプログラムを配置するので今回のはあくまでも簡易的なサンプルになります。

GetForm() メソッドは WWWForm オブジェクトを生成するための補助メソッドです。入力された Dictionary を AddField してるだけの簡単処理です。

{“par1”, txtPar1.text} と {“par2”, txtPar2.text} の部分で、InputField の入力された値を POST 値に設定しています。
本来ならきちんとバリデーションすべきかと思いますがその辺はサンプルなので省略してます。

この後、WWW の処理結果を次のメソッドチェインの処理で受け取るわけですが、通常の Unity3D プログラムだとコルーチンとして別メソッドに処理を記述する必要があります。 プログラムによっては、switch を使って状態を変化させながら制御しないといけないので超面倒です。

下記プログラムをご覧ください。

void Update() {
    switch (phase) {
        case Phase.WWWRequest:
            // WWWコルーチン実効的な何か
            phase = Phase.WWWRequestWait;
            break;
        case Phase.WWWRequestWait:
            if (ステータス監視的な何か) {
                値を受け取って加工して判定的な何か
            }
            break;
    }
}

IEnumerator WWWRequest() {
    WWW w = new WWW(うらる);
    WWW実効的な何か、エラー処理的な何か
    結果をどこかに格納して受け渡しする的な略....
}

段々高度な処理が必要になると継承に継承を重ねて多機能・高機能になり、次第に WWW 実行する部分が見えなくなり、値の受け渡しにそのうち色々なグローバル変数的なクラスを経由したりどこかのワークに退避したりとカオスな事になります。

ていうか、てめぇら、リファクタリングしやがれ!

閑話休題…例外をキャッチする

.Catch((WWWErrorException ex) => Observable.Return(ex.RawErrorMessage))

上記処理は ObservableWWW で例外が発生した場合、catch して次メソッドにエラーメッセージのテキストを通知します。
ObservableWWW.Post は正常終了するとテキストを通知するので、この場合は例外を正常終了した時と同じ通知に変換している事になります。
(Subscribe の説明でわかる事ですが、どっちも結果は画面に表示してるので別にやらんでも結果かわらないんですけど、例外処理のサンプルとしてとりあえず入れた感じです)

ちなみに、WWWErrorException 以外だとスルーして、Subscribe の ex => {} の処理が実行されます。

通知受け取り処理

.Subscribe(
    x => txtResult.text = x,
    ex => txtResult.text = ex.Message,
    () => txtResult.text += "\nCompleted."
);

上から順に解説します。

    x => txtResult.text = x,

x => の部分は、WWW 処理結果のテキストが変数 x に入って受け渡されるという事になります。(説明があまりうまくないので厳密にどういう意味かは C# のラムダ式を調べてみてください)

txtResult.text = x の部分が実際に受け取った x を使って何するかの処理を記述しています。
txtResult は uGUI の Text コンポーネントのテキスト部分になるので、WWW 結果を GUI に表示させる処理となるわけです。

x を使って何かすると書きましたが、受け取った x を使わずに処理を記述しても OK ですし、こういう書き方もできます。

    x => {
        int count = 0;
        if (int.TryParse(x, out count)) {
            for (int ii=0; ii<count; ii++) {
                txtResult.text = "結果を書き込んでみるぜ" + ii + "\n";
            }
        }
    }

次いきます。

    ex => txtResult.text = ex.Message,

上記処理は Observable の一連の処理の中で例外が発生したらスパっとここの処理に飛んできます。
この処理が実行されるとこの後は必ず

    () => txtResult.text += "\nCompleted."

この処理が呼ばれます。
例外が発生すると、

    x => txtResult.text = x,

この処理は呼ばれません。

実は困った事が

WWW 処理でサーバーに異常があったりすると例外が返ってくるのですが、そうなると

    () => txtResult.text += "\nCompleted."

が呼ばれてしまいます。
これが呼ばれるともうボタンをクリックしてもボタンクリック通知が走らなくなります。
再度ボタンクリックを監視したければ再び Subscribe させる必要があるのかなと思うのですが、この辺は割り切ってボタンクリックイベントと、WWWイベントを別にわけるのもありなのかなと思います。

シンプルにわかりやすく記述できるのがメリットだと思うので、やたら長いメソッドチェインで最終的に複雑な処理を記述するのもそれはそれで違うかも。 と思ったり。

あれから色々手を入れてみてこうなった

using UnityEngine;
using UnityEngine.UI;           // InputField や Text などの UI で必要
using UnityEngine.EventSystems; // uGUI のクリックイベントの処理で必要
using Unity.Linq;               // Linq で gameObject にアクセスする
using System.Collections;
using System.Collections.Generic; // Dictionary 使う時等
using System.Linq; // iOS で使うと動かない可能性あり
using UniRx;       // クリックイベントを Observable に変換する

public class ServerCall2 : ObservableMonoBehaviour {

    public InputField txtPar1 = null;
    public InputField txtPar2 = null;
    public Button btnSubmit = null;
    public Text txtResult = null;

    /// <summary>
    /// フェイズ定義
    /// </summary>
    enum Phase {
        None,
        Initialize, // コンポーネント初期化
        ButtonWait, // ボタンクリック待機
        WWWWait,    // WWW実行
        Error,      // エラー
        TimeWait,   // 一定時間待機
        MAX
    }

    /// <summary>
    /// フェイズ管理
    /// </summary>
    Phase phase = Phase.Initialize;
    Phase old = Phase.None;

    /// <summary>
    /// Observable 破棄用
    /// </summary>
    CompositeDisposable d = new CompositeDisposable();

    /// <summary>
    /// フェイズ切り替え通知用
    /// </summary>
    Subject<Phase> onChangePhase = new Subject<Phase>();

    // コンポーネントを取得
    void Start() {
        gameObject.Descendants().Where(x => x.name == "txtPar1").Select(x => x.GetComponent<InputField>()).Where(x => x != null).ToObservable().Subscribe(x => txtPar1 = x);
        gameObject.Descendants().Where(x => x.name == "txtPar2").Select(x => x.GetComponent<InputField>()).Where(x => x != null).ToObservable().Subscribe(x => txtPar2 = x);
        gameObject.Descendants().Where(x => x.name == "btnSubmit").Select(x => x.GetComponent<Button>()).Where(x => x != null).ToObservable().Subscribe(x => btnSubmit = x);
        gameObject.Descendants().Where(x => x.name == "txtResult").Select(x => x.GetComponent<Text>()).Where(x => x != null).ToObservable().Subscribe(x => txtResult = x);

        var white = new ColorBlock();
        var gray = new ColorBlock();
        white.normalColor = Color.white;
        white.highlightedColor = Color.blue;
        white.pressedColor = Color.red;
        white.disabledColor = Color.gray;
        white.colorMultiplier = 1;
        white.fadeDuration = 0.1f;
        gray.normalColor = Color.gray;
        gray.highlightedColor = Color.gray;
        gray.pressedColor = Color.gray;
        gray.disabledColor = Color.gray;
        gray.colorMultiplier = 1;
        white.fadeDuration = 0.1f;

        // GUI Enable判定
        onChangePhase
            .Where(x => x == Phase.ButtonWait)
            .Subscribe(x => {
                txtPar1.enabled = true;
                txtPar2.enabled = true;
                btnSubmit.enabled = true;
                txtPar1.GetComponent<InputField>().colors = white;
                txtPar2.GetComponent<InputField>().colors = white;
                btnSubmit.GetComponent<Button>().colors = white;
            });

        // GUI Disable判定
        onChangePhase
            .Where(x => x != Phase.ButtonWait)
            .Subscribe(x => {
                txtPar1.enabled = false;
                txtPar2.enabled = false;
                btnSubmit.enabled = false;
                txtPar1.GetComponent<InputField>().colors = gray;
                txtPar2.GetComponent<InputField>().colors = gray;
                btnSubmit.GetComponent<Button>().colors = gray;
            });

    }

    /// <summary>
    /// Observable 制御処理
    /// </summary>
    void Update() {

        // フェイズの変化を監視
        if (old != phase) {
            old = phase;
            onChangePhase.OnNext(phase); // フェイズの変化を通知
            // Observable 破棄
            if (d.Count > 0) {
                d.Dispose();
                d = null;
                d = new CompositeDisposable();
            }
        }

        // フェイズ管理
        switch (phase) {
            case Phase.Initialize: // コンポーネントが取得できてれば次フェイズへ
                if (txtPar1 != null && txtPar2 != null && btnSubmit != null && txtResult != null) {
                    phase = Phase.ButtonWait;
                }
                break;

            case Phase.ButtonWait:
                if (d.Count == 0) {
                    OnTriggerEventAsObservable()
                        .Where(x => x.selectedObject.name == "btnSubmit")
                        .Subscribe(
                        x => phase = Phase.WWWWait,
                        ex => {
                            txtResult.text = ex.Message;
                            phase = Phase.Error;
                        })
                        .AddTo(d);
                }
                break;

            case Phase.WWWWait:
                if (d.Count == 0) {
                    ObservableWWW.Post(
                        "http://megamin.jp/apps/20150206_api_sample/index.php",
                        GetForm(new Dictionary<string, string>() {
                            {"par1", txtPar1.text},
                            {"par2", txtPar2.text}
                        }))
                        .Catch((WWWErrorException ex) => Observable.Return(ex.RawErrorMessage))
                        .Subscribe(
                        x => {
                            txtResult.text = x;
                        },
                        ex => {
                            txtResult.text = ex.Message;
                            phase = Phase.Error;
                        },
                        () => phase = Phase.ButtonWait)
                        .AddTo(d);
                }
                break;

            case Phase.Error:
                txtResult.text = "\nError";
                phase = Phase.None;
                break;

            case Phase.TimeWait:
                break;

        }
    }

    /// <summary>
    /// フォーム生成ヘルパーメソッド
    /// </summary>
    /// <param name="dcPost">ポスト情報の Dictionary</param>
    /// <returns>WWWFormデータ</returns>
    public static WWWForm GetForm(Dictionary<string, string> dcPost) {
        WWWForm oForm = new WWWForm();
        foreach (var item in dcPost) {
            oForm.AddField(item.Key, item.Value);
        }
        return oForm;
    }

    Subject<BaseEventData> onTriggerEvent; // ボタンクリック Subject (通知する側)

    // クリックされると呼ばれるメソッド
    public void OnTriggerEvent(BaseEventData ev) {
        if (onTriggerEvent != null) onTriggerEvent.OnNext(ev); // オブザーバに通知するだけ
    }

    // Start メソッドで使用する、クリックイベントを Observable に変換するメソッド
    public IObservable<BaseEventData> OnTriggerEventAsObservable() {
        return onTriggerEvent ?? (onTriggerEvent = new Subject<BaseEventData>());
    }

}

こっちのスタイルは、ボタン監視とWWW処理を switch で分けてます。
Observable を実行すると、Disposable を返すのですが、これを CompositeDisposable にリストとして追加しておき、後でまとめて削除する事ができます。

ついでに、Phase の変化も監視対象にして、入力可能フェイズ以外は GUI を Disable 状態にしています。
なぜか、Disabled のカラーに変化しなかったので強引に色を設定しちゃってますけどとりあえず、スルー。

ああ、さっきまで「もう6時か」と思ってたらもう7時じゃないですか。

とりあえずまた明日。おやすみ。おやすみ。

ツイートツイート