対戦カードゲーム開発ブログ 【2019年秋リリース(ios,android)】

【UniRx】瀕死状態のユニティちゃんをUniRxでリファクタリングしてみる

こんにちは、たかせです。カードゲーム作ってます。

twitter.com

前回の記事では、驚異的な速度で歩くユニティちゃんを使ってTaskを0から説明してみました。

今回はこのユニティちゃんに機能追加を行いつつ、UniRxについて紹介したいと思います。

UniRxはUnityにおけるリアクティブプログラミングをサポートしてくれるライブラリです。UniRxに関する説明は素晴らしい記事がすでにたくさんあるので、そちらを参考にしてください。記事の最後にいくつかリンクを貼っておきます。

今回はUniRxについての0からの説明はせず、UniRxを使う前と後でどう変わるのか?を見てもらいたいと思っています。

利用例は引き続きユニティちゃん(スクリプト)です。ユニティちゃんには申し訳ないですが、説明のため一度見るも無残な姿(スクリプト)になってもらい、UniRxによる改造を経て新生ユニティちゃん(スクリプト)となってもらう予定です。

 

 

※UniRxの利用には次の2つが必要です。

  1. UniRx Assetのインポート
  2. Unity 2018.3以上、もしくは Unity 2018.1~2 + Incremental Compiler

目次

  • 前回までのユニティちゃん
  • 急な修正依頼に雑な設計で対応してみる
  • リファクタリングの方針について
  • RxRxしていく前に、UniRxについて少しだけ
  • UniRxでユニティちゃんを新生せてみる

前回までのユニティちゃん

1フレームごとに一歩進むユニティちゃんを作成しました。また、Taskを使って、100歩ごとに進捗を報告するようにしました。

using UniRx.Async;
using UnityEngine;
public class ユニティちゃんだよ : MonoBehaviour {
  private int 歩数;

  private async void Update() {
    一歩進む();
    歩数++;
    
    if (歩数 % 100 == 0) {
      var result = await 歩数を報告する();
      //resultがtrueなら報告成功
    }
  }

  private UniTask<bool> 歩数を報告する() {
    var task = UniTask.Run(arg => {
      var 報告用歩数 = (int) arg;
      return 歩数をサーバに送信して成功したらtrueをもらう(報告用歩数);
    }, 歩数);

    return task;
  }
  ...

(UniTaskに移行していたり引数を渡していたり、前回とは比べるとしれっと変わっている部分がありますがお許しを🙇‍♂️)

UniTaskについて気になる方は、この辺りの記事がおすすめです。

neue cc - UniTask - Unity + async/awaitの完全でハイパフォーマンスな統合

UniRx.Async(UniTask)機能紹介 - Qiita

次からはUniRxのお話です。下準備として、各種方面から飛んでくる修正依頼にくそ修正で対応し、ユニティちゃんを瀕死に追い込みます。

急な修正依頼に雑な設計で対応してみる

一人目のPMさん「サボってるのかバグってるのか分からないからさ、100歩ごとじゃなくて10秒ごとに報告するよう修正してよ」

僕「ちょっと雑だけどこんな感じでいいかな?」

Carbon

二人目のPMさん「こっちから指示したら走れるようにしといて。そんなの必要ないだろうって? すまんな上からの要望なんだ」

僕「もっと早く言ってよ...。通常の5倍歩けば走ったことになるしょ」

Carbon

サーバエンジニアさん「ユーザ行動を把握したいのでFirebaseにも報告よろしく。いやぁサーバから送るののは大変でさ。あ、こっちには"前回の報告から何歩歩いたか"でよろしくね」

僕「変数をもう一つ増やして...Taskを連結させて...」

Carbon

外野さん1「ランダムに立ち止まるようにしてみようよ! そんな時間ないって? 前のエンジニアさんは楽しそうに対応してくれたのになぁ...」

外野さん2「ムーンウォークできるようにします!!!これは決定事項です!!!」

外野さん3「まだリリースしないの?」

 

 

僕「」

ユニティちゃん「」

Carbon

リファクタリングの方針について

各種方面からの依頼を請け負い、合計5つの機能追加が追加されました。 もともとあった歩くという機能も含めると、今のユニティちゃんは次の6つの機能があります。

  • 歩く
  • 歩数をサーバに送信する
  • 指示があったら走る
    • 走る = 通常の5倍の速度で歩くこととします
  • 前回の報告から何歩歩いたかをFirebaseに報告する
  • ランダムに立ち止まる
  • ボタンクリックでムーンウォークに切り替える

それでは、改めてユニティちゃんを見てみましょう。

using System;
using UniRx.Async;
using UnityEngine;
using UnityEngine.UI;
using Random = System.Random;
public class 汚れてしまったユニティちゃん : MonoBehaviour {
  private Random _random;
  private int 歩数;
  private int 最後に報告した歩数;
  private DateTime 最後に報告した時刻;
  private bool 走れの指示がある;
  private bool ムーンウォークの指示がある;
  [SerializeField]
  public Button ムーンウォークボタン;

  private void Awake() {
    _random = new Random();
    ムーンウォークボタン.onClick.AddListener(() => {
    ムーンウォークの指示がある = !ムーンウォークの指示がある;
    });
  }

  private async void Update() {
    var 止まれ = _random.Next(2) < 1;
    var 走れ = 走れの指示がある;
    var ムーンウォークせよ = ムーンウォークの指示がある;

    if (止まれ) {
      Debug.Log($"立ち止まっているよ");
    }
    else if (ムーンウォークせよ) {
      一歩戻る();
    }
    else {
      var count = 走れ ? 5 : 1;
      for (var i = 0; i < count; i++) {
        一歩進む();
        歩数++;
      }
    }

    var now = DateTime.Now;
    if ((now - 最後に報告した時刻).Seconds > 10) {
      最後に報告した時刻 = now;
      var previous = 最後に報告した歩数;
      await 歩数をサーバに送信して成功したらtrueをもらう(歩数);
      最後に報告した歩数 = 歩数;
      if (previous != default) {
        await 歩数をFirebaseに送信して成功したらtrueをもらう(歩数 - previous);
      }
    }
  }
  ...

さっきの画像と違うじゃないかって?さすがにひどかったので少しだけリファクタリングしました。

それでもまだ直したいところがあります。例えば、

  private Random _random;
  private int 歩数;
  private int 最後に報告した歩数;
  private DateTime 最後に報告した時刻;
  private bool 走れの指示がある;
  private bool ムーンウォークの指示がある;

というフィールドの多さとか。利用箇所が限定的なフィールドは、可能であればローカル変数に留めたいものです。

他にも、

  最後に報告した時刻 = now;
  var previous = 最後に報告した歩数;
 await 歩数をサーバに送信して成功したらtrueをもらう(歩数);
  最後に報告した歩数 = 歩数;
  if (previous != default) {
    await 歩数をFirebaseに送信して成功したらtrueをもらう(歩数 - previous);
  }

このへんとか。ひとえにややこしい。

後、Updateごとに乱数を生成して止まるかどうか判定するのはいろいろとやばそうなのでどうにかしたい。

さて、具体的な修正方法入る前に、結果だけ先に共有しておきます。

ででん!

 

行数増えちゃいましたね。てへ。

いやでもそこはいいんです。Rx化にあたって避けては通れぬ道です。それよりも見てほしいのは、色の散らばり具合です。同じ目的を持つコードを(だいたい)一箇所を集めることができています。

あちらを立てればコチラが立たずのようなコードは、読むにも消すにも直すにも厄介です。今回のユニティちゃんは、そんな観点からの新生を目指します。

RxRxしていく前に、UniRxについて少しだけ

新生に取り掛かる前に、UniRxを一度も触ったことのない人向けに、少しだけ説明させてください。

UniRxではこんなふうにSubscribeというメソッドを使って変数にアクセスします。

変数という表現はあまり的確ではない(ストリームとか、オブザーバブルとかよく呼ばれます)のですが、「値の入っている箱」という意味では同じという点を伝えたいのであえてこの表現で。

変数が増えても一緒ですが、その分長くなります。

Zipとかいう謎のメソッドも増えてますね。

こんな冗長な書き方が真価を発揮するのは、次のようなケースです。

通常の例ではUniTaskを使って5秒待機してからチェックしていますが、Rxの例ではそれをしていません。代わりに、「5秒後にfalseに変化する変数」を使っています。

この「5秒後に」のような点がRxの特徴で、Rxの世界では「変数」という存在に時間軸という概念が増えています。

例えば1から10まで2秒おきに変化する変数とか。

var hensu = Observable.Interval(TimeSpan.FromSeconds(2))
  .Zip(Observable.Range(1, 10), (interval, range) => range)
  ;

ZipCombineLatestSelectもいみわかんねーぞちくしょーっって方は、別に今覚える必要はないので安心してください。UniRxを使うとこんなことができるんだ...!と知った後に、UniRx逆引きから探すのが良いです。

最後に一つだけ、どのスクリプトにもおまじないとかいうクソワードともにAddTo(this)というメソッドが呼ばれていますが、これを消すとwhile(true) {}と同じような目に会います。UniRxに不慣れなうちは絶対につけておきましょう。AddToの実態については次の記事なんかが分かりやすかったです。

【UniRx】AddTo とは何かまとめてみた - Qiita

UniRx の AddTo と TakeUntilDestroy - Qiita

UniRxでユニティちゃんを新生させてみる

次の方針でユニティちゃんを新生させてみます。

  • 変数はなるべくローカル化する
  • Updateメソッドを使わない
  • ムーンウォークの指示」「立ち止まる指示」「走る指示」の3つを「歩くための各種パラメータ」としてまとめる
  • 「立ち止まる」「後ろ向きに歩く」「前に歩く」の3つを「進む方向を決める」「進む」の2つに抽象化する

まずは「歩くための各種パラメータ」の作成です。

パラメータの1つ目、ランダムに立ち止まる機能のため、「3秒ごとにランダムなboolフラグを返す」変数を作ります。  

//3秒ごとにランダムなboolフラグを返す変数
var requestStop = Observable.Interval(TimeSpan.FromSeconds(3))
  .WithLatestFrom(Observable.Return(new Random()), (interval, random) => random.Next(2) < 1)
  .StartWith(false);

0秒目は常にfalseとしたいので、StartWithオペレータを使って初期値を設定しています。

次に、パラメータの2つ目、ムーンウォーク機能です。「ボタンが押されるたびにフラグを反転させる」変数を作ります。

//ボタンが押されるたびにフラグを反転させる
var requestMoonWalk = moonWalkButton
 .OnClickAsObservable()
 .Scan(false, (doRequest, unit) => !doRequest)
 .StartWith(false);

Scanメソッドを使って前回のフラグ状態を覚えておき、ボタンが押されるたびにその値を反転させます。また、ボタンが一度も押されていない時はfalseとしたいので、こちらもStartWithを使って初期値を設定しています。

最後のパラメータは、指示があったら走る機能です。これはReactivePropertyを定義しているだけです。

[SerializeField]
public BoolReactiveProperty requestRun = new BoolReactiveProperty();

BoolReactivePropertyを始め、いくつかのReactivePropertyはInspector上から変更することができるようになっています。今回はひとまず、「Inspector上から値が変更された」=「走ることが指示された」とみなしました。

requestStoprequestRunrequestMoonWalkという3つのパラメータができたので、これらをもとに「進む方向」の変数を作りましょう。

//歩くときのパラメータをまとめる
var walkParams = Observable.CombineLatest(
    requestRun
    , requestMoonWalk
    , requestStop
    , (run, moon, stop) => (run, moon, stop));

//歩く方向を決める
var walkDirection = walkParams.Select(x => {
    var (run, moonWalk, stop) = x;
    
    if (stop) {
      //立ち止まるが指示されているので0方向へ進む
      return Observable.Return(Vector3.zero);
    }
    
    if (moonWalk) {
      //ムーンウォークが指示されている場合は走る命令を無視して後方へ進む
      //Note: ユニティちゃんは後ろ向きには知れない
      return Observable.Return(Vector3.back);
    }
    
    //走る(n歩連続で進む)命令をn個の歩く(1歩進む)命令に変換する
    return Observable.Repeat(Vector3.forward, run ? 5 : 1);
  })
 .Switch();

requestMoonWalkrequestStopの値に応じて方向を変更します。また、requestRunの値に応じて送信数を増幅させます。

パラメータが完成したので、毎フレームごとにwalkDirectionを確認し、その方向に歩きましょう。AddToを忘れずに。

this.UpdateAsObservable()
 .WithLatestFrom(walkDirection, (count, direction) => direction)
 .Subscribe(x => {
    if (x == Vector3.zero) {
      Debug.Log("立ち止まっているよ");
    }
    else if (x == Vector3.forward) {
      一歩進む();
      歩数++;
    }
    else if (x == Vector3.back) {
      一歩戻る();
    }
    else {
      Debug.Log("そんな方向いけないよ...");
    }
  })
 .AddTo(this);

サーバへの進捗報告はこんな感じです。

//サーバへの報告が完了したら報告した歩数を返す
var reportToServer = Observable.Interval(TimeSpan.FromSeconds(10))
   .SelectMany(x => {
      var count = 歩数;
      return 歩数をサーバに送信して成功したらtrueをもらう(count)
       .ToObservable()
       .Zip(Observable.Return(count), (a, b) => b);
    })
   .Share()
  ;
  
 //サーバに報告する
reportToServer.Subscribe()
 .AddTo(this);

サーバに報告するUniTaskをToObservableオペレータでRx化し、Zipオペレータを使って「成功したかどうか」のフラグを「成功したときの歩数」に変化させています。

SelectではなくZipを使っているのは、報告に成功したときのみ変化させたかったからです。Firebaseへの報告は今の所「報告に成功した時点の歩数の差分」なので、サーバへの報告に失敗したらFirebaseへの報告も行わないようにしなければいけません。

最後に、Firebaseへの報告処理はこのようになっています。

//Firebaseに報告する
reportToServer
  //直近の2つの報告を一つにまとめる
 .Pairwise()
 .Subscribe(async x => {
    var countDelta = x.Current - x.Previous;
    await 歩数をFirebaseに送信して成功したらtrueをもらう(countDelta);
  })
 .AddTo(this);

今回と前回のサーバへの報告に成功した時点の歩数をPairwiseオペレータによりまとめ、その差分を算出します。

1つ前のコードでShareというオペレータを付けていましたが、これは、サーバへの2重報告を防ぐためです。ShareをつけないままFirebaseへの送信処理をSubscribeすると、ColdObservableの性質により2重報告が発生してしまいます。詳しくはコチラの記事が参考になります。

【Reactive Extensions】 Hot変換はどういう時に必要なのか? - Qiita

これまでの修正をまとめると、新生ユニティちゃんはこのようになります。

using System;
using UniRx;
using UniRx.Async;
using UniRx.Triggers;
using UnityEngine;
using UnityEngine.UI;
using Random = System.Random;
using Vector3 = UnityEngine.Vector3;
public class SampleScript6 : MonoBehaviour {
  private int 歩数;
  [SerializeField]
  public BoolReactiveProperty requestRun = new BoolReactiveProperty();
  [SerializeField]
  public Button moonWalkButton;

  private void Awake() {
    //3秒ごとにランダムなboolフラグを返す
    var requestStop = Observable.Interval(TimeSpan.FromSeconds(3))
     .WithLatestFrom(Observable.Return(new Random()), (interval, random) => random.Next(2) < 1)
     .StartWith(false);

    //ボタンが押されるたびにフラグを反転させる
    var requestMoonWalk = moonWalkButton
     .OnClickAsObservable()
     .Scan(false, (doRequest, unit) => !doRequest)
     .StartWith(false);

    //歩くときのパラメータをまとめる
    var walkParams = Observable.CombineLatest(
        requestRun
        , requestMoonWalk
        , requestStop
        , (run, moon, stop) => (run, moon, stop));

    //歩く方向を決める
    var walkDirection = walkParams.Select(x => {
        var (run, moonWalk, stop) = x;
        if (stop) {
          //立ち止まるが支持されているので0方向へ進む
          return Observable.Return(Vector3.zero);
        }
        if (moonWalk) {
          //ムーンウォークが指示されている場合は走る命令を無視して後方へ進む
          //Note: ユニティちゃんは後ろ向きには知れない
          return Observable.Return(Vector3.back);
        }
        var multiplier = run ? 5 : 1;
        //走る(n歩連続で進む)命令をn個の歩く(1歩進む)命令に変換する
        return Observable.Repeat(Vector3.forward, run ? 5 : 1);
      })
     .Switch();

    //歩く
    this.UpdateAsObservable()
     .WithLatestFrom(walkDirection, (count, direction) => direction)
     .Subscribe(x => {
        if (x == Vector3.zero) {
          Debug.Log("立ち止まっているよ");
        }
        else if (x == Vector3.forward) {
          一歩進む();
          歩数++;
        }
        else if (x == Vector3.back) {
          一歩戻る();
        }
        else {
          Debug.Log("そんな方向いけないよ...");
        }
      })
     .AddTo(this);

    //サーバへの報告が完了したら報告した歩数を返す
    var reportToServer = Observable.Interval(TimeSpan.FromSeconds(10))
       .SelectMany(x => {
          var count = 歩数;
          return 歩数をサーバに送信して成功したらtrueをもらう(count)
           .ToObservable()
           .Zip(Observable.Return(count), (a, b) => b);
        })
       .Share()
      ;

    //サーバに報告する
    reportToServer.Subscribe()
     .AddTo(this);

    //Firebaseに報告する
    reportToServer
      //直近の2つの報告を一つにまとめる
     .Pairwise()
     .Subscribe(async x => {
        var countDelta = x.Current - x.Previous;
        await 歩数をFirebaseに送信して成功したらtrueをもらう(countDelta);
      })
     .AddTo(this);
  }
  ...

 

 

うわなっげえ!!!

いやまってください結構コメント書いたんです。あとほら、こうやってみるとすごく見通しが良いと思いませんか?Updateメソッドを使ってないし。

終わりに

UniRxを使えばソースコードをこんなふうにキレイに書けるよ(行数は増えるけどね)、っていう記事でした。

 

UniRxには今作っているBeyondTheFieldもめちゃくちゃお世話になっています(Github Sponsorsで寄付可能になってほしい...!)。現段階で17000行ほどのコードですが、まだ一度もUpdateメソッドを使っていません。

慣れるまで大変だったり、メモリに気を使わなければいけなかったり、デメリットも多々あるUniRxですが、慣れた後の開発スピードはなかなかなものです。みなさんもぜひ使ってみてくださいね!何かわからないことがあれば聞いてもらえると嬉しいです!

twitter.com

参考

UniRxを体系的に知りたい時はコチラがおすすめです。

UniRx入門シリーズ 目次 - Qiita

また、リアクティブプログラミングそのものについて興味がある方はコチラの記事がおすすめです。

【翻訳】あなたが求めていたリアクティブプログラミング入門 - ninjinkun's diary

一日1回はお世話になってる逆引きです。ZipLatestとCombineLatest何が違うんだっけとか、UniRxのError周りの操作って何があったっけとか、ある程度慣れたあとの参考文献としては情報量が最適で助かってます。

UniRx オペレータ逆引き - Qiita

「こうこうこういうことを実現したいんだけどうまい検索ワードが見つからなくて...」という方は、UniRxのgithubページを熟読するか、人に聞くのが一番です。指摘も質問もどうぞTwitterまでー!

GitHub - neuecc/UniRx: Reactive Extensions for Unity

Rx全般へのneueccさんの見解なんかも面白かったです。

各言語に広まったRx(Reactive Extensions、ReactiveX)の現状・これから - Build Insider