LoginSignup
204

2022年現在におけるUniRxの使いみち

Last updated at Posted at 2022-01-24

はじめに

私が「UniRx入門」という記事を書き始めてから5年、最後に投稿してから4年が経過してしまいました。
記事を更新していなかったこの数年間で、UniRxを取り巻く環境が大きく変わってしまいました。
UniRxというライブラリ自体に特に大きな更新はないのですが、UnityのC#バージョンがアップデートされたり、UniRxよりも便利なライブラリが登場したりしました。

今回は2022年現在のUnityにおけるUniRxの立ち位置と、その使い方について解説します。

UniRxの立ち位置

現代のUniRxの状況

2017年頃の古いバージョンのUnityにおいては.NET 3.5相当のかなり貧弱なC#しか用いることができませんでした。
非同期処理にasync/awaitすら使えず、「コルーチン」で書くか「UniRx」で書くかという状況でした。
そのため、時間が関係した処理はコルーチンかUniRxを使って書かざるを得ませんでした。

ですが、2022年現在においてはC# 8.0/.NET Standard 2.0と、かなり新しいめのC#が利用可能になりました。
このため何か機能を実装するにあたって2017年ごろと比べて取れる選択肢が大幅に増え、一昔前では一般的だった手法も現代では時代遅れとなったものもあります。
UniRxもそのうちのひとつです。
現代においては無理にUniRxを使う必要がなく、async/awaitで書いたり、他のライブラリを併用した方がスマートに終わるというパターンが存在しています。

UniRx自体は確かに便利で強力なライブラリではあり、ユースケースがぴったりハマれば非常に便利です。
ですがUniRx自体がそれなりにややこしく、また記法も特殊であるため、使いこなすようになるまでにかなり時間がかってしまいます。
その上で、現代においては「他にベターな選択肢があったりするのでUniRxを無理に使う必要はない」という状況であったりします。

そのため、 UniRxはUnity初心者が真っ先に学習するようなライブラリではなくなった(async/awaitから先に学習した方がより実践的である) という状況です。

とはいえど、UniRxの出番が完全に無くなったわけではなく、用途によってはまだまだ現役で便利に使えるライブラリです。
そういった前提を踏まえて、現代におけるUniRxの用途をまとめてみます。

現代のUniRxの用途

UniRx自体は「イベント処理」と「非同期処理」に特化したライブラリです。
このうち「イベント処理」についてはUniRxは現役バリバリで使えるレベルに便利です。
一方の「非同期処理」についてはasync/awaitが使える現代においては、UniRxを使うことはまずない状況でしょう。

また、UniRxの特徴である豊富なオペレータ群も、ぴったりハマるシチュエーションであればまだまだ扱えます。
ただし複雑なオペレータチェインになるくらいなら、素直にasync/awaitに書き下したほうがトータルでわかりやすくなる場面も多いです。

UniRxの用途

  • イベント処理の機構として使う
    • 複数のオブジェクトに対してメッセージを伝達する場面においてはまだまだ使える
    • MessageBrokerとしての用途もあるが、現代においては「MessagePipe」という選択肢もある
  • 複雑なイベント処理のロジックを組む
    • ぴったりハマるオペレータがあるなら簡潔に書ける
    • 少しでも応用をしようとすると一気に難しくなるので、だったらasync/awaitを使ったほうがよい
  • 非同期処理の機構として
    • 単発の非同期処理を扱う場合は、現代においてはasync/awaitで代替することを推奨
    • ただしRetrySwitchなどの一部オペレータが便利なのでそれ目的で使うことも無くはない
  • UI周りの実装に使う
    • MV(R)Pパターンが便利

UniRxとasync/awaitとUniTask

さきほどから何度もasync/awaitを取り上げていますが、こちらはイメージとしては「便利になったコルーチン」という認識でいったんは問題ありません。
async/await自体は非同期処理の待機処理を簡素に書けるようにするための、C#の言語機能です。
(仕組みはぜんぜん異なるが)async/awaitは見た目としてはUnityのコルーチンにとても似ています。

UniRxにおいてもコルーチンとの組み合わせは便利だったのですが、async/awaitにおいてもUniRxと組み合わせるとその利便性が高まります。
特にUniTaskというライブラリが強力であり、こちらを導入することでUnityにおけるasync/awaitの扱いを大幅に強化することができます。
async/awaitとUniTaskは必ずセットで扱って欲しいレベルで強力です

ここで誤解してほしくないのですが、「async/awaitがあるからUniRxは不要になった」というわけではありません
UniRxはUniRxで使える場面はまだまだありますし、async/awaitもまた便利に使える場面は多数あります。
昔はUniRxしか選択肢が無かったが、現代においてはUniRxを使わなくてももっと簡単に実装できる手法が増えたというわけです。

UniRxが便利な場所、async/awaitが便利な場所

UniRxは決して万能ではありません。
シチュエーションによってはasync/await(もしくは、まったく別の機構)を使ったほうがキレイに書ける場合も多々あります。

ではUniRxとasync/awaitをどう使い分けるかですが、基本的には次の使い分けで問題ありません。

  • UniRxが便利に使える場面

    • 不特定多数に対して複数回イベントメッセージを伝達する場合
    • 特定の処理の流れ(シーケンス)を何回も繰り返し実行する場合
    • 依存関係を逆転したい場合
    • Push型で駆動する必要がある場合
  • async/await(およびUniTaskの組み合わせ)が便利に使える場面

    • 1回だけ実行される処理」を待ち受ける場合
    • 手続き的(if文やfor文)で処理を書き下したい場合
    • 処理の動作がPull型で済む場合

UniRxは「繰り返し実行すること」に特化しています。
そのため「何度か発行されるイベントを処理する」「Update()のループの代わりに使う」といった用途にマッチしています。

一方でasync/awaitは「なにかの処理を1回だけ待つ」に特化しています。
そのため「初期化処理が終わるのを待つ」「外部との通信を待つ」といった場合はasync/awaitで書いたほうがスマートに書けます。
(単発の処理をUniRxで書くことももちろんできるが、冗長になりやすい)

2.実際のUniRxの用途

それでは現代において、UniRxをどのような場面で使うのかを紹介します。
中にはかつてはUniRxを使うこともあったが、現代では別の手法で書いたほうがよいというものもあります。

A.イベント通知に使う

イベントとは、「何かしらの条件を満たした時に、そのときの情報を通知し別の場所で処理を実行する」という仕組みを指します。
イベントを用いると「条件を判定する部分」と「実際に処理を行う部分」を分離して実装することができるようになります。

イベントの使いみちとしては次のパターンが考えられます。

  • 不定期に何回か繰り返す処理を扱いやすくする
  • 実際の処理部分が複数あるときに、その条件判定の部分を一箇所に集約する
  • コンポーネント間の依存関係を整理する

「不定期に何回か繰り返す処理を扱いやすくする」はUnityにおいてはOnTriggerEnterなどのイベントが例として挙げられます。
「いつ起きるかわからないが、発生したときは即座に対応した処理を実行したい」といったときにイベントの概念が使えます。

「実際の処理部分が複数あるときに、その条件判定の部分を一箇所に集約する」は、たとえば「Input」が挙げられます。
ゲームを遊んでいる人間(プレイヤー)からのInputを受け付けてキャラクタを操作することになります。
このときに何も考えずに愚直に実装すると、さまざまなコンポーネントにif(Input.GetKeyDown("Attack"))といった処理が散らかってしまいます。
こういった問題もイベントの概念を使うことでスマートに実装することが可能となります。

UniRxとイベント

UniRxはイベント処理を扱うのに長けたライブラリです。
Observableというオブジェクト(および概念)でイベントを扱うことができます。

UniRxを用いたイベント処理の例として、さきほどの「プレイヤーからのInputを受け付けてキャラクタを操作する」を考えてみます。

UniRxでInputイベントを扱う

まず「Inputを判定してそれをObservableに変換するコンポーネント」を考えます。

using UniRx;
using UnityEngine;

namespace Events
{
    public sealed class InputEventProvider : MonoBehaviour
    {
        /// <summary>
        /// 攻撃ボタン入力
        /// </summary>
        public IReadOnlyReactiveProperty<bool> Attack => _attack;

        /// <summary>
        /// 移動方向入力
        /// </summary>
        public IReadOnlyReactiveProperty<Vector3> MoveDirection => _moveDirection;

        /// <summary>
        /// ジャンプ入力
        /// </summary>
        public IReadOnlyReactiveProperty<bool> Jump => _jump;

        // 実装
        private readonly ReactiveProperty<bool> _attack = new BoolReactiveProperty();
        private readonly ReactiveProperty<bool> _jump = new BoolReactiveProperty();
        private readonly ReactiveProperty<Vector3> _moveDirection = new ReactiveProperty<Vector3>();

        private void Start()
        {
            // Destroy時にDispose()する
            _attack.AddTo(this);
            _jump.AddTo(this);
            _moveDirection.AddTo(this);
        }

        private void Update()
        {
            // 各種入力をReactivePropertyに反映
            _jump.Value = Input.GetButton("Jump");
            _attack.Value = Input.GetButton("Attack");
            _moveDirection.Value = new Vector3(
                x:Input.GetAxis("Horizontal"), 
                y:0,
                z:Input.GetAxis("Vertical"));
        }
    }
}

このコンポーネントを用意したら、実際に入力イベントを使って処理を行うコンポーネントからこれを参照させます。
これで「UniRxを用いて入力イベントを扱う」ことができました。

using System;
using UniRx;
using UnityEngine;

namespace Events
{
    /// <summary>
    /// 例:Inputをみて移動する
    /// </summary>
    public class PlayerMove : MonoBehaviour
    {
        [SerializeField] private float _moveSpeed = 1.0f;
        [SerializeField] private InputEventProvider _inputEventProvider;

        private CharacterController _characterController;

        private void Start()
        {
            _characterController = GetComponent<CharacterController>();

            // ジャンプ
            // ジャンプボタン入力イベントを判定
            _inputEventProvider.Jump
                // ボタンが押された時に、
                .Where(x => x)
                // 接地中であり、
                .Where(_ => _characterController.isGrounded)
                // 最後にジャンプしてから1秒以上経過しているなら、
                .ThrottleFirst(TimeSpan.FromSeconds(1))
                .Subscribe(_ =>
                {
                    // ジャンプ処理を実行する
                    Jump();
                });
            
            // 移動処理
            _inputEventProvider
                .MoveDirection
                // 一定値以上入力されたなら
                .Where(x=>x.magnitude > 0.5f)
                .Subscribe(x =>
                {
                    // そっち方向に移動する
                    _characterController.Move(x.normalized * _moveSpeed);
                });
        }

        private void Jump()
        {
            // ジャンプ処理(省略)
        }
    }
}

補足:「Pub/Sub」という概念

イベント処理の一種として、「Pub/Sub」というものがあります。

一般的なイベント処理においては、一般的には「誰からメッセージが送られてくるか」を購読側がある程度は意識する必要があります。
それをPub/Subにおいては「イベントメッセージそのもの」に注目し、「誰から送られてきているかは気にしない」というモデルとなっています。
(同様に送信側も「誰が受信しているかは気にしない」という形になります)

Pub/Subを使うことで、送信側と受信側をより疎結合に近づけることができます。
コンポーネント間の参照関係や依存関係を整理し振り回されることなく、よりデータフローを中心とした実装を行うことが可能となります。
しかし、その一方で正しくメッセージ管理できないとスパゲッティコードが加速するという問題点もPub/Subは持っています。
初心者にオススメできる機能ではないのですが、こういったものもあると頭の片隅においておくといずれ役に立つときがくるでしょう。

さてこのPub/Subですが、実装する方法はいくつかあります。

  • UniRxの「MessageBroker」という機能を使う
  • MessagePipeというライブラリを使う

Pub/Subを軽く試してみたいのであればUniRxのMessageBrokerが簡単に使えてオススメです。
そこからさらに踏み込んで、「DIと組み合わせて使いたい」「サーバ通信を絡めたPub/Subを行いたい」といった場合はMessagePipeを使ってみるとよいでしょう。

B.非同期処理に使う

先に結論から述べると、非同期処理にUniRxを使うことは非推奨です
かつて、2017年以前のUnityにおいては非同期処理の選択肢としてまともなものがUniRxくらいしかありませんでした。
しかし現代においてはasync/awaitUniTaskの登場によって、わざわざUniRxを使って非同期処理を扱う必要性がなくなりました。

(そもそも「非同期処理」とは何を指すかですが、こちらは語ると長くなるため別記事で改めて投稿予定です)

追記

「非同期処理にUniRxを使うことは非推奨」ではあるものの、一部のOperatorが非常に便利だったりします。
とくにOnErrorRetryはエラー発生時に指定回数までリトライをしてくれるものです。
こういったOperatorをasync/awaittry-catchで書くと結構煩雑になったりするので、上手くasync/awaitObservableを併用するのも上級者向けではありますがテクニックとして存在します。

ただ、愚直にToObservable().OnErrorRetry()とだけしても上手くは動かず、Observable.Defer()と併用する必要があったりします。
このあたりの挙動は結構ややこしいので、なぜObservable.Defer()が必要なのかわからない人は、このテクニックは使わないほうが安全かもしれないです。

private async UniTaskVoid SampleAsync(string uri, CancellationToken token)
{
    var result = 
        // Observable.Deferで包むことで、Retry発火時にGetAsyncを再度実行するようにする
        await Observable.Defer(() => GetAsync(uri, token).ToObservable())
        // エラー発生時は1秒待ってから、合計3回まで試行する
        .OnErrorRetry((UnityWebRequestException ex) => Debug.LogException(ex), retryCount: 3, TimeSpan.FromSeconds(1));

    Debug.Log(result);
}

private async UniTask<string> GetAsync(string uri, CancellationToken token)
{
    using (var uwr = UnityWebRequest.Get(uri))
    {
        await uwr.SendWebRequest().WithCancellation(token);
        return uwr.downloadHandler.text;
    }
}

例:AsyncOperation

Unityで登場する非同期処理として扱う必要があるオブジェクトとして例:AsyncOperationがあります。
こちらはUnityAPI上で何か非同期な処理を呼び出した時に返されるオブジェクトです。

AsyncOperationなオブジェクトを返すUnityのAPIの例】

  • UnityWebRequest.SendWebRequest()
  • SceneManager.LoadSceneAsync()
  • AssetBundle.LoadAssetAsync

AsyncOperationは本来であればコルーチンで処理するオブジェクトですが、UniTaskを導入している場合はasync/awaitで記述することが可能です。

UnityWebRequest
// テクスチャをダウンロードする
public async UniTask<Texture> FetchTextureAsync(string uri, CancellationToken token)
{
    using (var uwr = UnityWebRequestTexture.GetTexture(uri))
    {
        await uwr.SendWebRequest().WithCancellation(token);
        return ((DownloadHandlerTexture) uwr.downloadHandler).texture;
    }
}

(以下非推奨)

かつて、UniRxくらいしかまともな非同期処理のハンドリング方法がなかった時代は次のような書き方をしていました。
async/awaitと比較してもらえるとわかりますが、UniRxの方が圧倒的に冗長で煩雑です。

/// <summary>
/// テクスチャをダウンロードするObservable
/// </summary>
public IObservable<Texture> FetchTextureObservable(string uri)
{
    // コルーチンをObservableに変換する
    return Observable.FromCoroutine<Texture>(observer => 
        FetchTextureCoroutine(uri, observer));
}

/// <summary>
/// 通信するコルーチン
/// </summary>
private IEnumerator FetchTextureCoroutine(
    string uri,
    IObserver<Texture> observer)
{
    using (var uwr = UnityWebRequestTexture.GetTexture(uri))
    {
        yield return uwr.SendWebRequest();

        if (uwr.result != UnityWebRequest.Result.Success)
        {
            observer.OnError(new Exception(uwr.error));
        }
        else
        {
            var result = ((DownloadHandlerTexture) uwr.downloadHandler).texture;
            observer.OnNext(result);
            observer.OnCompleted();
        }
    }
}

C.Model-View-(Reactive)Presenterパターンに使う

Model-View-(Reactive)Presenter、通称MV(R)PパターンはUnityにおいて主にUIの実装周りで使われることが多いパターンです。
ViewModelの2つのオブジェクトをUniRxを用いて連結する実装パターンとなります。

こちらは別の記事で解説しています。

D.MonoBehaviourのロジックを記述する

UniRxに存在するUpdateAsObservable()Observable.EveryUpdate()を愛用している方は多いでしょう。
こちらについてはUniRxをそのまま使ってもよいが、場合によってはasync/awaitの方が簡単にかける場合もあるとおぼえておくとよいでしょう。

簡単なロジックをUniRxで記述する

例として次のロジックを考えてみます。

  • 条件を満たしたら処理を実行し、その後数秒間クールタイムに入る

(攻撃を出したらその後数秒間は再度攻撃ができない、みたいな)

これをUniRxで記述すると次のようになります。

using System;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace Samples
{
    public sealed class SimpleLogicUniRx : MonoBehaviour
    {
        private void Start()
        {
            // 毎フレーム実行
            this.UpdateAsObservable()
                // 攻撃ボタンが押されたら
                .Where(_ => Input.GetButtonDown("Attack"))
                // 処理を1回走らせたあと、1秒間クールタイム
                .ThrottleFirst(TimeSpan.FromSeconds(1))
                .Subscribe(_ => Action())
                .AddTo(this);
        }

        /// <summary>
        /// 定期的に実行する処理
        /// </summary>
        private void Action()
        {
            // なんかする
            Debug.Log("Action!");
        }
    }
}

こういった「オペレータを組み合わせるだけで実装できる処理」についてはUniRxを使って書いても問題ありません。

UniRxでの記述が難しいやつ

UniRxのメリットは「豊富なオペレータが使える」ですが、逆にデメリットとしてオペレータの範囲外の処理がものすごく書きづらくなってしまいます。

さきほどの例の「条件を満たしたら何か処理を実行しその後数秒間クールタイムに入る」を少し拡張して、次のような処理を考えてみましょう。

  • 条件Aを満たしたら処理Xを呼び出し、N秒間クールタイムに入る
  • 条件Bを満たしたら処理Yを呼び出し、M秒間クールタイムに入る
  • 処理XとYはそれぞれ排他であり、お互いのクールタイム中はお互いの処理がブロックされる

わかりやすく言い換えるなら、「強攻撃を出したらクールタイムが長い。弱攻撃を出したらクールタイムが短い。クールタイム中は攻撃が一切できない」みたいなパターンです。

さて、これをUniRxだけで記述しようとするとどうなるでしょうか。
オペレータの単純な連結だけでは実装できず、かなり入り組んだものを書く必要がでてきそうです。
このような途中で条件分岐が入ったり、条件によって処理の内容が大幅に変わってしまうものをUniRxは非常に苦手としています。

こうった場合はUniRxを使わずにasync/await(またはコルーチン)で書いてしまったほうが結果としてキレイに実装できます。

コルーチン版
using System.Collections;
using UnityEngine;

namespace Samples
{
    public sealed class ComplexLogicCoroutine : MonoBehaviour
    {
        private void Start()
        {
            StartCoroutine(LogicLoop());
        }

        private IEnumerator LogicLoop()
        {
            // Destroyされるまで無限ループ
            while (true)
            {
                if (Input.GetButtonDown("AttackA"))
                {
                    // 入力Aが実行されたら処理Xを呼んで1秒待機
                    ActionX();
                    yield return new WaitForSeconds(1);
                }
                else if (Input.GetButtonDown("AttackB"))
                {
                    // 入力Bが実行されたら処理Yを呼んで2秒待機
                    ActionY();
                    yield return new WaitForSeconds(2);
                }
                else
                {
                    // 入力がないなら1フレーム待機
                    yield return null;
                }
            }
        }

        private void ActionX()
        {
            Debug.Log("do X!");
        }

        private void ActionY()
        {
            Debug.Log("do Y!");
        }
    }
}
async/await+UniTask版
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Samples
{
    public sealed class ComplexLogicUniTask : MonoBehaviour
    {
        private void Start()
        {
            // CancellationToken生成
            var token = this.GetCancellationTokenOnDestroy();

            // ループ起動
            LogicLoopAsync(token).Forget();
        }

        private async UniTaskVoid LogicLoopAsync(CancellationToken ct)
        {
            // Destroyされるまで無限ループ
            while (!ct.IsCancellationRequested)
            {
                if (Input.GetButtonDown("AttackA"))
                {
                    // 入力Aが実行されたら処理Xを呼んで1秒待機
                    ActionX();
                    await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
                }
                else if (Input.GetButtonDown("AttackB"))
                {
                    // 入力Bが実行されたら処理Yを呼んで2秒待機
                    ActionY();
                    await UniTask.Delay(TimeSpan.FromSeconds(2), cancellationToken: ct);
                }
                else
                {
                    // 入力がないなら1フレーム待機
                    await UniTask.Yield();
                }
            }
        }

        private void ActionX()
        {
            Debug.Log("do X!");
        }

        private void ActionY()
        {
            Debug.Log("do Y!");
        }
    }
}

MonoBehaviourのロジックを記述する まとめ

  • Update()FixedUpdate()に関係したロジックはUniRxで記述ができる
  • ただしUniRxを使う場合は既存のオペレータで実装できる範囲内に留めておく
  • 少しでもUniRxで書けないと感じたらさっさと諦めてコルーチンやasync/awaitに書き直したほうが最終的に読みやすくなる

E.Update()などのスコープを分離する

UniRxの使いみちとして「Update()などのスコープを分離する」があります。

  • Update()FixedUpdate()などにベタ書きされた処理をコンテキストごとに分離する
  • Update()FixedUpdate()の実行開始タイミングを調整する

このような場合にUniRxを用いることができます。

例:Update()をコンテキストごとに分離する

たとえば次のような、Update()に複数の処理がまとめて記述されてるコードがあったとします。

using UnityEngine;

namespace Samples
{
    public sealed class SamplePlayer : MonoBehaviour
    {

        // ここにたくさんのフィールド変数が定義されていたとして

        private void Update()
        {
            // 攻撃処理のチェックが走る
            CheckAttack();

            // Playerの位置に応じた処理が走る
            CheckPosition();

            // Playerの体力に応じた処理が走る
            CheckHealth();

            // 移動処理
            Move();
        }

        // ↓ にメソッドが並んでたとする
        
        /*
         * 省略
         */
    }
}

さて、このUpdate()をみて感じることは無いでしょうか。
これは処理の順番は入れ替えても大丈夫なのか」「どの処理とどの処理がセットなんだ
といった疑問は出てこないでしょうか。

これらの処理は順不同で並べ替えても正しく動作するかもしれませんし、実際は裏でつながっておりこの順序が重要かもしれません。
このような旨がコメントに書いてあるかもしれませんし、まったくコメントが無いコードかもしれません。
Update()という1つのメソッドに並べて書いている以上は、常にこういったことを意識してコードを書く/読む必要が生まれてしまいます。

こういった問題はUniRxを使うことで多少はマシにすることができます

using System;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace Samples
{
    public sealed class SamplePlayer : MonoBehaviour
    {
        private void Start()
        {
            // Playerの位置に応じた処理
            this.UpdateAsObservable()
                .Subscribe(_ => CheckPosition())
                .AddTo(this);

            // Playerの体力に応じた処理
            this.UpdateAsObservable()
                .Subscribe(_ => CheckHealth())
                .AddTo(this);

            // 攻撃処理のチェックと移動処理
            this.UpdateAsObservable()
                .Subscribe(_ =>
                {
                    CheckAttack();
                    Move();
                })
                .AddTo(this);
        }
        
        // ↓ にメソッドが並んでたとする

        /*
         * 省略
         */
    }
}

このように、各処理ごとに別々のObservableに格納することで処理同士のスコープを明確に区切ることができました。
また処理ごとにObservableが分離しているため、一部処理だけオペレータを追加したり調整もしやすくなります。

例:一部処理だけ実行開始タイミングをずらす

Update()を各処理ごとのObservableに分けてしまう利点として、「実行開始タイミングを調整しやすい」というものがあります。

たとえば特定のメソッドが呼び出されるまではUpdate()の処理の一部をスキップしておきたい、といった場合です。
(敵キャラが画面内に映るまで移動処理を止めておきたい、など)

using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace Samples
{
    public sealed class SampleEnemy : MonoBehaviour
    {
        private void Start()
        {
            // 画面に映ったら初期化処理を呼び出す
            this.OnBecameVisibleAsObservable()
                .Take(1)
                .Subscribe(_ => Initialize())
                .AddTo(this);
        }

        /// <summary>
        /// 初期化処理
        /// </summary>
        private void Initialize()
        {
            // 毎フレームMove()を呼び出すようにする
            this.UpdateAsObservable()
                .Subscribe(_ => Move())
                .AddTo(this);
        }

        /// <summary>
        /// 移動処理
        /// </summary>
        private void Move()
        {
            // 移動処理
        }
    }
}

Update()などのスコープを分離する まとめ

これはUniRxが処理をすべてObservableというオブジェクトに包む性質を利用した、ちょっとしたテクニックです。
覚えておくと地味ながら便利なので、個人的にはたまに使う小技だったりします。

なお、このテクニックをasync/await + UniTaskで記述することも可能ではあります。
ただしUniRxと比べこちらは記述量が増え冗長になってしまいます。
そのためこの用途においてはUniRxに軍配が上がります。

同じようなことをasync/awaitで書けなくもないが、冗長
private void Start()
{
    var token = this.GetCancellationTokenOnDestroy();

    UniTask.Void(async () =>
    {
        while (!token.IsCancellationRequested)
        {
            CheckPosition();
            await UniTask.Yield();
        }
    });

    UniTask.Void(async () =>
    {
        while (!token.IsCancellationRequested)
        {
            CheckHealth();
            await UniTask.Yield();
        }
    });

    UniTask.Void(async () =>
    {
        while (!token.IsCancellationRequested)
        {
            CheckAttack();
            Move();
            await UniTask.Yield();
        }
    });
}

F.依存関係の整理に使う

UniRxは依存関係を整理するのに用いることができます。
(UniRxの機能のというよりかは、Observerパターンの性質そのもの)

さきほど説明した「イベント処理」と同じ話ではあります。
(視点が依存関係の整理になっているだけで、やってることはイベント処理そのもの)

例:PlayerとPlayerManager

たとえば、次のようなケースを考えてみます。

  • PlayerManager : Playerを生成し、ライフサイクルを管理する
  • Player : 自分が死んだことをPlayerManagerに伝える

さて、これを愚直にそのまま実装すると、次のようなコードになるでしょう。

PlayerManager
using UnityEngine;

namespace Samples2
{
    public class PlayerManager : MonoBehaviour
    {
        // PlayerのPrefab
        [SerializeField] private Player _playerPrefab;

        // 今存在しているPlayerの実体
        private Player _currentPlayer;

        private void Start()
        {
            CreatePlayer();
        }

        public void OnPlayerDead()
        {
            // Playerが死んだ時の処理がここに
            _currentPlayer = null;

            // 新しいPlayerを生成する
            CreatePlayer();
        }

        private void CreatePlayer()
        {
            // Playerを生成
            _currentPlayer = Instantiate(_playerPrefab);

            // PlayerにManagerを教える
            _currentPlayer.Initialize(this);
        }
    }
}
using UnityEngine;

namespace Samples2
{
    public class Player : MonoBehaviour
    {
        private PlayerManager _playerManager;

        public void Initialize(PlayerManager playerManager)
        {
            // 初期化時にManagerを保持する
            _playerManager = playerManager;
        }
        
        private void OnDestroy()
        {
            // 今回はOnDestroyされたら「死亡」という扱いにする
            _playerManager.OnPlayerDead();
        }
    }
}

このコードにはひとつ大きな問題が存在します。
それはPlayerPlayerManagerが相互参照していることです。

相互参照は後にスパゲッティコードへと発展するリスクが非常に高いです。
そのためできる限り相互参照は除去すべきです。

そこでこれをUniRx(またはUniTask)を用いて整理してみます。

UniRxを用いて依存関係を整理する

UniRxのObservableを利用することで、参照関係を一方通行に整理することが可能となります。

using UniRx;
using UnityEngine;

namespace Samples2
{
    public class PlayerManager : MonoBehaviour
    {
        // PlayerのPrefab
        [SerializeField] private Player _playerPrefab;

        // 今存在しているPlayerの実体
        private Player _currentPlayer;

        private void Start()
        {
            CreatePlayer();
        }

        private void OnPlayerDead()
        {
            // Playerが死んだ時の処理がここに
            _currentPlayer = null;

            // 新しいPlayerを生成する
            CreatePlayer();
        }

        private void CreatePlayer()
        {
            // Playerを生成
            _currentPlayer = Instantiate(_playerPrefab);

            // 生成したPlayerを監視して
            // 死亡イベントが来たらOnPlayerDeadを実行
            _currentPlayer
                .PlayerDeadAsync
                .Subscribe(_ => OnPlayerDead())
                .AddTo(this);
        }
    }
}
using System;
using UniRx;
using UnityEngine;

namespace Samples2
{
    public class Player : MonoBehaviour
    {
        /// <summary>
        /// Playerが死亡した通知を発行するObservable
        /// </summary>
        public IObservable<Unit> PlayerDeadAsync => _playerDeadSubject;

        // 1回だけ通知を発行するケースにおいてはAsyncSubjectが便利
        private readonly AsyncSubject<Unit> _playerDeadSubject = new AsyncSubject<Unit>();

        private void OnDestroy()
        {
            // Playerが死亡した通知を発行する
            _playerDeadSubject.OnNext(Unit.Default);
            _playerDeadSubject.OnCompleted();

            _playerDeadSubject.Dispose();
        }
    }
}

UniRxのObservableを用いることで通知フローを整理し、参照関係を一方通行にすることができました。
そもそも、Observableは「不特定多数に自分の状態を監視させる」という用途のための機能です。
そのためこのような「何か状態が変化したとき、それを相手に伝える」という場面においてはObservableは強力に作用します。

応用:状態の通知

Observableの「不特定多数に自分の状態を監視させる」という用途をもう少し考えてみます。

たとえばアクションゲームにおいて「何かイベントが起きたときそれに応じて複数の処理が同時に走る」というパターンはよく存在します。
そのような処理を実装する場合、イベント発行側が通知先をすべて把握するようなコードは大変扱いにくいものになります。

例として次のような実装があったとします。

  • Playerがダメージを受けた時、次の処理が走る
    • Healthが低下する
    • ノックバックする
    • ダメージアニメーションを再生する
    • パーティクルエフェクトが出る
    • 効果音を再生する
    • デバッグ時だけUIに数値を出したい

これを「ダメージを受けたときにひとつひとつ通知先のメソッドを呼び出していく」という実装にするとこうなります。

この実装の弱点は「PlayerCoreがすべてを管理しなくてはいけない」という点にあります。
通知先をすべてPlayerCoreが知っている状態にし、なおかつ状況に応じて通知する/しないの判断をPlayerCoreが行わなくてはいけません。
そのためPlayerCoreの責務が膨れ上がり、どんどん複雑なコードへと成長してしまいます。


こういった問題はObservableを使うことで解決することができます。

PlayerCoreObservableを定義し各コンポーネントが必要に応じてSubscribeする形式にしてあげます。
こうすることでPlayerCoreの責務が大幅に減り、「イベント通知をどう扱うか」の責務が各コンポーネントに分散されます。

UniTaskを用いて依存関係を整理する

UniTaskasync/awaitを使って依存関係を整理することができます。
ただしUniRxと違い、UniTaskは「イベント通知回数が1回に限る」場合においてのみ利用可能です。

Player
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Samples2
{
    public class Player : MonoBehaviour
    {
        /// <summary>
        /// Playerが死亡したら完了するUniTask
        /// </summary>
        public UniTask PlayerDeadAsync => _playerDeadUtc.Task;

        private readonly UniTaskCompletionSource _playerDeadUtc = new UniTaskCompletionSource();

        private void OnDestroy()
        {
            // Playerが死亡したUniTaskを完了させる
            _playerDeadUtc.TrySetResult();
        }
    }
}
PlayerManager
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Samples2
{
    public class PlayerManager : MonoBehaviour
    {
        // PlayerのPrefab
        [SerializeField] private Player _playerPrefab;

        // 今存在しているPlayerの実体
        private Player _currentPlayer;

        private void Start()
        {
            SetupPlayerAsync(this.GetCancellationTokenOnDestroy()).Forget();
        }

        private void OnPlayerDead()
        {
            // Playerが死んだ時の処理がここに
            _currentPlayer = null;

            // 新しいPlayerを生成する
            SetupPlayerAsync(this.GetCancellationTokenOnDestroy()).Forget();
        }

        private async UniTaskVoid SetupPlayerAsync(CancellationToken token)
        {
            // Playerを生成
            _currentPlayer = Instantiate(_playerPrefab);

            // 生成したPlayerを監視して
            // 死亡したらOnPlayerDeadを実行
            await _currentPlayer.PlayerDeadAsync;
            token.ThrowIfCancellationRequested();

            OnPlayerDead();
        }
    }
}

依存関係の整理に使う まとめ

UniRxを用いることで依存関係を整理し、イベントフローを改善することができます。
また通知回数が1回に限り場合においては「UniRx」か「UniTask+async/await」のどちらでも記述することも可能です。

なお、個人的には、通知回数が1回だけならUniTask+async/await、何度も通知するならObservableと自分は使い分けをしています。
というのも、Observableという型だけを見たときに、「このObservableは何度イベントを発行するんだろうか」がわかりません。
だったら「UniTaskなら絶対に多くても1回しか発行されない」「Observableなら複数回発行されるだろう」と割り切れた方が楽だからです。
(相手側の実装内容を想像して書く、みたいな余計なことしなくて済むようになる)

G.スクリプトの実行順の調整に使う

UnityにはScript Execution Orderという、スクリプトの実行順序を調整するための機構があります。
このScript Execution Orderですが、これは最後の砦として残しておくべきものであり安易に触るべきではない機能です

では実行順序を管理したいとなったときにどうするかというと、UniRxを用いてイベントで動作するようにしてしまうことです。
つまり、「前のコンポーネントが終わったら次のコンポーネントが連鎖して実行される」という仕組みにします。
こうすることでコンポーネント間のUpdate()FixedUpdate()の呼び出し順を考えなくてよくなります。

例:UniRxでコンポーネント間のタイミングを調整する

たとえば、次のような実装を考えてみます。

  • PlayerInputでボタン入力を受け付ける
  • ボタン入力をチェックして、PlayerMoveControllerがジャンプ処理を行う
  • ジャンプ状態になったらPlayerAnimationがジャンプアニメーションを再生する

「各コンポーネントがUpdate()を実行し、条件を満たしたら各処理を実行する」といった愚直な実装も可能ではあります。
ですがこの書き方の場合、実行順序がずれて処理が1F遅れたり、状態チェックを取りこぼすなどの不具合が起きかねません。

そのためそのようなUpdate()を多用するコードを止め、UniRxを用いて処理が連鎖する実装にしてみます。

using UniRx;
using UnityEngine;

namespace Samples3
{
    /// <summary>
    /// 入力イベントを管理する
    /// </summary>
    public sealed class PlayerInput : MonoBehaviour
    {
        /// <summary>
        /// ジャンプボタンの入力状態を表すReactiveProperty
        /// </summary>
        public IReadOnlyReactiveProperty<bool> JumpButton => _jumpButton;

        private readonly ReactiveProperty<bool> _jumpButton = new ReactiveProperty<bool>(false);

        /*
         * 他にもいろいろイベント並んでるだろうけど省略
         */

        private void Start()
        {
            // Destroy時にDisposeされるようにする
            _jumpButton.AddTo(this);
        }

        private void Update()
        {
            // ボタンの状態を反映して通知する
            _jumpButton.Value = Input.GetButton("Jump");
        }
    }
}
using System;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace Samples3
{
    public sealed class PlayerMoveController : MonoBehaviour
    {
        /// <summary>
        /// 接地しているか
        /// </summary>
        public IReadOnlyReactiveProperty<bool> IsGrounded => _isGrounded;

        private readonly ReactiveProperty<bool> _isGrounded = new ReactiveProperty<bool>(false);

        /// <summary>
        /// ジャンプイベント
        /// </summary>
        public IObservable<Unit> OnJump => _jumpSubject;

        private readonly Subject<Unit> _jumpSubject = new Subject<Unit>();

        [SerializeField] private LayerMask _groundLayerMask;
        [SerializeField] private PlayerInput _playerInput;
        private Rigidbody _rigidbody;

        private void Start()
        {
            _isGrounded.AddTo(this);
            _jumpSubject.AddTo(this);
            _rigidbody = GetComponent<Rigidbody>();

            // 接地状態のチェック
            this.FixedUpdateAsObservable()
                .Subscribe(_ =>
                {
                    // Rayを足元に飛ばす
                    _isGrounded.Value = Physics.SphereCast(origin: transform.position + Vector3.up * 0.04f,
                        radius: 0.02f,
                        direction: Vector3.down, hitInfo: out var _, maxDistance: 0.05f, _groundLayerMask);
                })
                .AddTo(this);


            // 入力イベント処理
            _playerInput.JumpButton
                // ジャンプボタンが押された瞬間に接地してたら実行
                .Where(x => x && _isGrounded.Value)
                // InputイベントはUpdate()タイミングなので、
                // 次のFixedUpdateタイミングにタイミングを調整する
                .ObserveOnMainThread(MainThreadDispatchType.FixedUpdate)
                .Subscribe(_ =>
                {
                    // ジャンプ実行
                    Jump();
                })
                .AddTo(this);
        }

        private void Jump()
        {
            _rigidbody.AddForce(Vector3.up * 10.0f, ForceMode.VelocityChange);

            // ジャンプイベント発行
            _jumpSubject.OnNext(Unit.Default);
        }
    }
}
using UniRx;
using UnityEngine;

namespace Samples3
{
    public sealed class PlayerAnimation : MonoBehaviour
    {
        [SerializeField] private Animator _animator;
        [SerializeField] private PlayerMoveController _moveController;

        /// <summary>
        /// ジャンプアニメーション再生
        /// </summary>
        private bool IsJumping
        {
            set => _animator.SetBool("Jumping", value);
        }

        private void Start()
        {
            // ジャンプイベントが来たらジャンプアニメーション開始
            _moveController.OnJump.Subscribe(_ => IsJumping = true).AddTo(this);

            // 接地したらジャンプアニメーション解除
            _moveController.IsGrounded.Where(x => x).Subscribe(_ => IsJumping = false).AddTo(this);
        }
    }
}

UniRxを用いることで、3つのコンポーネントが競合することなく順序立てて動作させることができるようになりました。
このように各コンポーネントからUpdate()FixedUpdate()を極力排除することで、動作順序を完全に制御できるようになります。
(まさにリアクティブ)

例:初期化順の調整(async/await)

実行順序の調整はUpdate()FixedUpdate()のみに限らず、初期化(Start()Awake())の順序も重要になることがあったりします。
そのようなシチュエーションにおいてもUniRxは利用ができます。
ですが、「初期化」といった基本的に1回しか実行されない処理については「async/await+UniTask」で書いたほうがキレイになります。

EnemyManager
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Samples3
{
    public sealed class EnemyManager : MonoBehaviour
    {
        [SerializeField] private Enemy _enemyPrefab;


        /// <summary>
        /// 敵を複数体生成して初期化する
        /// </summary>
        public async UniTask InitializeAsync(CancellationToken token)
        {
            // 10個生成してすべての初期化が終わるまで待つ
            // (UniTask.WhenAllを暗黙的に呼び出している)
            await Enumerable.Range(0, 10).Select(x => CreateEnemyAsync(token));
        }

        /// <summary>
        /// 敵を1体生成する
        /// </summary>
        private async UniTask CreateEnemyAsync(CancellationToken token)
        {
            var enemy = Instantiate(_enemyPrefab);
            // 初期化が終わるのを待つ
            await enemy.InitializedAsync;
            // 初期化中にキャンセルされてたら止める
            token.ThrowIfCancellationRequested();
        }
    }
}
Enemy
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

namespace Samples3
{
    /// <summary>
    /// 敵
    /// </summary>
    public sealed class Enemy : MonoBehaviour
    {
        /// <summary>
        /// 初期化が完了しているかどうか
        /// </summary>
        public UniTask InitializedAsync => _initialTaskCompletionSource.Task;
        private readonly UniTaskCompletionSource _initialTaskCompletionSource = new UniTaskCompletionSource();

        private AsyncOperationHandle<GameObject> _operationHandle;
        
        /// <summary>
        /// この敵オブジェクトの子要素(非同期で初期化される)
        /// </summary>
        private GameObject _child;
        
        /// <summary>
        /// Startで初期化処理が実行される
        /// </summary>
        private void Start()
        {
            var ct = this.GetCancellationTokenOnDestroy();
            InitializeAsync(ct).Forget();
        }

        /// <summary>
        /// 初期化処理
        /// </summary>
        private async UniTaskVoid InitializeAsync(CancellationToken token)
        {
            _operationHandle = Addressables.InstantiateAsync("Child");
            await _operationHandle;
            _child = _operationHandle.Result;
        }

        private void OnDestroy()
        {
            Addressables.Release(_operationHandle);

            // もしDestroyが先に走った場合はキャンセルする
            _initialTaskCompletionSource.TrySetCanceled();
        }
    }
}

H.コレクションの変動を通知する

コレクションとはArrayDictionaryのような複数個のデータを扱うオブジェクトを指します。

UniRxでコレクションの変動を監視

UniRxにはReactiveCollectionReactiveDictionaryといったオブジェクトが用意されています。

これらのオブジェクトを用いることで、「配列や辞書の中身が変動した」ということを即時に検知してイベント処理を実行することが可能となります。

補足:ObservableCollections

UniRxのReactiveCollectionに近い機能を提供するライブラリとして、ObservableCollectionsがあります。

こちらはUniRxに依存すること無く、さまざまなコレクションの監視ができるようになるライブラリとなっています。
(UniRxには存在しない、ObservableHashSetObservableRingBufferなどもあります)

「UniRxを導入したくない」「UniRxが提供するReactiveCollectionよりも高度なことがしたい」といった場合はこちらの導入も検討するとよいでしょう。

I. (以下、思いついたら加筆します)

まとめ

  • 「UniRxのみ」を用いていろいろ実装する時代ではなくなった
    • 現代はUniRx以上に便利なライブラリや言語機能が揃っている
    • とくにasync/await+UniTaskはすごく強力で、UniRxの代わりに利用できるパターンが多い
  • しかし一方でUniRxの用途が完全になくなったわけではない
    • 単なるObserverパターンの実装ライブラリとして使っても便利
    • ReactivePropertyはとくに利便性が高く、現役で全然使える
  • UniRxを過信しすぎない
    • 使用箇所の見極めは大事
    • async/await+UniTaskの方がキレイに書けるパターンもある
    • (async/awaitがわからないならコルーチンを使ってもいい)

関連資料

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
204