【Unity】値の変更を検出して処理を実行する方法

ゲーム中で「ある値を監視して毎フレームごとに値の変化を監視し、値が変化した時に特定の処理を実行する」という実装方法は色々にありますが、最も一般的な Update メソッド内で値の監視処理を書く実装は規模が大きくなると徐々にコードが見づらくなっていきます。

監視対象の変数が増えた時にUpdate内の実装量が増えるため本来 Update メソッド内でやりたいことが分かりずらくなったり、処理が複雑化すると不具合の要因になったりもします。

そこで今回は、最も一般的な変数の監視方法と、UniTask, UniRx を用いた値の変更検出方法を 5つ紹介したいと思います。

確認環境

  • Unity 2022.3.4f1
  • UniRx
  • UniTask

エディター上で動作を確認しています。

(1) 一般的な検出方法

(1-1) Updateで検出する

最も一般的な監視方法です。Update メソッド内で前回の値と現在の値を比較して異なる場合に処理を実行します。

以下例では y のローカル位置をフレームごとに監視し位置が変わるとログを出力しています。

// Normal_VariableMonitoring.cs

using UnityEngine;

public class Normal_VariableMonitoring : MonoBehaviour
{
    // 直前のYの位置を覚えておいて毎フレーム呼ばれるUpdateで判定する

    float _previousLocalPosY;

    float CurrentValue => transform.localPosition.y;

    void Start()
    {
        _previousLocalPosY = CurrentValue;
    }
    void Update()
    {
        // 前のフレームから値が変わったかどうか?
        if (CurrentValue != _previousLocalPosY)
        {
            try
            {
                OnValueChanged(); // 変数が変わったときの処理の呼び出し
            }
            finally
            {
                // 次の判定のために今の値を覚えておく
                _previousLocalPosY = CurrentValue;
            }
        }
    }

    void OnValueChanged() // 変数が変わったときの処理
    {
        Debug.Log("ValueChanged. y=" + CurrentValue);
    }
}

この実装の長所と短所は概ね以下の通りです。

  • 長所
    • 特殊な前提知識やライブラリを必要としない
    • 変数が少ないうちは理解しやすい
    • 全ての値が監視できる特殊な条件判定にも対応しやすい
  • 短所
    • 変数が増えると監視処理の実装が長くなり理解しにくくなる
    • 定型的な実装になりがちで書くのが面倒

(1-2) プロパティで検出する

こちらも一般的な監視方法です。プロパティを使用して値が変更された時を検出して処理を実行します。

以下例では自作のプロパティ Value が変更されるとログを出力しています。

public class Normal_VariableMonitoring2 : MonoBehaviour
{
    // プロパティを作成して値の変更を検出する

    float _value;
    public float Value
    {
        get => _value;
        set
        {
            if (_value == value) return;
            OnValueChanged(value);
            _value = value;
        }
    }

    void OnValueChanged(float value) // 変数が変わったときの処理
    {
        Debug.Log("ValueChanged. Value=" + value);
    }
}

この実装の長所と短所は概ね以下の通りです。

  • 長所
    • 特殊な前提知識やライブラリを必要としない
    • 変数が少ないうちは理解しやすい
    • 変数が増えてもプロパティが増えるだけなので他の個所に影響が無い
  • 短所
    • 全ての値は監視できない場合がある
    • 他のプロパティと関係した複合的な条件の判定はできない
    • 定型的な実装になりがちで書くのが面倒

特にプロパティを通さないで値が変割る場合(Transformの値など)は監視できません。他から作用を受けて位置が変わる場合などには適用できません。

また、複数の他のプロパティと連携して両方の値が変わった時に処理を実行する処理は実装が難しいです。

(2) UniRxを使った検出方法

(2-1) ReactivePropertyで検出する

UniRx というイベント処理に強みを持つライブラリを使った実装です。

以下例では _sampleStr という文字列を ReactiveProperty という変数に持たせて監視しています。値が変更された場合にログを出力しています。プロパティを使った実装と考え方はほぼ同じです。

// UniRx_VariableMonitoring_1.cs

using System;
using Cysharp.Threading.Tasks;
using UniRx;
using UnityEngine;

// UniRxを使った変数の監視(1)
public class UniRx_VariableMonitoring_1 : MonoBehaviour
{
    // ReactivePropertyを使って自身自身の値の変更をインベントで受け取る

    readonly ReactiveProperty<string> _sampleStr = new();
    public string SampleStr { get => _sampleStr.Value; set => _sampleStr.Value = value; }
    public IObservable<string> SampleStrChanged => _sampleStr;

    void Start()
    {
        _sampleStr
            .Skip(1)
            .Subscribe(value => OnValueChanged())
            .AddTo(gameObject);
    }

    void OnValueChanged() // 変数が変わったときの処理
    {
        Debug.Log("ValueChanged. Str=" + _sampleStr.Value);
    }
}

// 以下のように指定するとOnValueChangedが実行される
var script = FindObjectOfType<UniRx_VariableMonitoring_1>();
script.SampleStr = "Test";

専用のライブラリということで単一の変数監視が簡単です。

また監視対象の変数が増加しても Update メソッドに対して変更が必要ありません。どの変数をどのように監視するのかを Start メソッドに記述します。

  • 長所
    • 監視対象が増えてもUpdateメソッドに処理を書く必要がない
    • 専用の構文で書くのが楽
    • イベント通知のため値が変わったときの処理をメソッド化できる
    • オペレーターという条件指定を使うとメソッドが呼ばれる前に条件が絞れる
  • 短所
    • UniRxを使うのでライブラリの知識が必要(リアクティブプログラミングという分野の知識が必要
    • 複合的な変数の条件の監視は工夫が必要
    • 扱いを間違えるとリソースリークや動作不良を起こす事がある

(2-2) ObserveEveryValueChangedで検出する

UniRx を使った値の変更検出の 2パターン目です。

以下例では最初の例と同じく y のローカル位置の監視と、自身のフィールド変数値をフレームごとに監視し位置が変わるとログを出力しています。

// UniRx_VariableMonitoring_2.cs

using System;
using UniRx;
using UnityEngine;

// UniRxを使った変数の監視(2)
public class UniRx_VariableMonitoring_2 : MonoBehaviour
{
    // (2) ObserveEveryValueChangedを使って任意の変数を監視して変更をインベントで受け取る

    [SerializeField] int _value;

    void Start()
    {
        transform
            .ObserveEveryValueChanged(t => t.localPosition.y)
            .Skip(1) // 初回は無視
            .Subscribe(OnValueChanged1)
            .AddTo(gameObject);

        this.ObserveEveryValueChanged(self => self._value)
            .Skip(1)
            .Subscribe(OnValueChanged2)
            .AddTo(gameObject);
    }

    void OnValueChanged1(float value) // 変数が変わったときの処理
    {
        Debug.Log("ValueChanged. y=" + transform.localPosition.y);
    }

    void OnValueChanged2(int value) // 変数が変わったときの処理
    {
        Debug.Log("ValueChanged. _value=" + _value);
    }
}

先ほどは ReactiveProperty という専用の変数を使って値を監視していましたがこちらは任意の変数に対して監視を行うことができます。

長所は先ほどとほぼ同じですが、追加で任意のクラスに含まれる変数がなんでも監視できるようになっています。

  • 長所
    • 監視対象が増えてもUpdateメソッドに処理を書く必要がない
    • 専用の構文で書くのが楽
    • クラスに含まれる変数なら何でも監視できる
    • イベント通知のため値が変わったときの処理をメソッド化できる
    • オペレーターという条件を書くことができるのでメソッドが呼ばれる前に条件が絞れる
  • 短所
    • UniRxを使うのでライブラリの知識が必要(リアクティブプログラミングという分野の知識が必要
    • 複合的な変数の条件の監視は難易度が高い
    • 扱いを間違えるとリソースリークや動作不良を起こす事がある

UniRx の「オペレーター」は数がかなり数があり何でも出来ますが、複雑なものもあるため調べて使うのは少し大変かもしれません。

(3) UniTaskを使って変更を「待つ」

次に UniTask を使った値の変更検出方法です。

このライブラリは処理を「待つ」事に強みがあるライブラリのため「値が変更されるまで処理を待つ」ことで変更を検出します。

以下の例では y の位置と自分のフィールド変数の2つの値の変更の監視をしています。

UniTask_VariableMonitoring.cs

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

// UniTaskを使った変数の監視
public class UniTask_VariableMonitoring : MonoBehaviour
{
    [SerializeField] int _value;

    private void Start()
    {
        //CancellationToken token = this.GetCancellationTokenOnDestroy();
        CancellationToken token = destroyCancellationToken;
        // 開始するときにタスクを起動しておく
        OnValueChanged1(token).Forget();
        OnValueChanged2(token).Forget();
        OnValueChanged3(token).Forget();
    }

    // Yの位置が変わったときの処理
    async UniTask OnValueChanged1(CancellationToken token)
    {
        try
        {
            while (true)
            {
                await UniTask.WaitUntilValueChanged(transform, 
                    t => t.localPosition.y, cancellationToken: token);

                // ここに変数が変わったときの処理を書く
                Debug.Log("ValueChanged. y=" + transform.localPosition.y);
            }
        }
        catch (System.OperationCanceledException) { } // nop
    }

    // 自分自身の変数の値が変わったときの処理
    async UniTask OnValueChanged2(CancellationToken token)
    {
        try
        {
            while (true)
            {
                await UniTask.WaitUntilValueChanged(this, 
                    self => self._value, cancellationToken: token);

                // ここに変数が変わったときの処理を書く
                Debug.Log("ValueChanged. value=" + _value);
            }
        }
        catch (System.OperationCanceledException) { } // nop
    }

    // 両方の変数が変わったときの処理
    async UniTask OnValueChanged3(CancellationToken token)
    {
        try
        {
            while (true)
            {
                var t1 = UniTask.WaitUntilValueChanged(this, 
                    self => self._value, cancellationToken: token);

                var t2 = UniTask.WaitUntilValueChanged(this, 
                    self => self._value, cancellationToken: token);

                await UniTask.WhenAll(t1, t2);

                // ここに変数が変わったときの処理を書く
                Debug.Log($"ValueChanged. y={transform.localPosition.y}, value={_value}");
            }
        }
        catch (System.OperationCanceledException) { } // nop
    }
}

起動時に値の変更を「待つ」タスクを起動してバックグラウンドで待機させておきます。値が変更されるまで待つという処理はライブラリ側で API が用意されていためそれを使用します。

そして変更が発生すると待ちが終了するので、その後ろに処理を記述していきます。

  • 長所
    • 監視対象が増えてもUpdateメソッドに処理を書く必要がない
    • クラスに含まれる変数なら何でも監視できる
    • 複雑な条件判定が可能のため複数の値の監視が容易
    • 後続の処理に順序性がある場合に処理しやすい
  • 短所
    • UniTask を使うのでライブラリの知識が必要(ちょっと難しいかも
    • UniRx より柔軟だが書き方には慣れが必要
    • 待ちのキャンセル処理の考慮と try ~ catch でコード量が増加する
    • 扱いを間違えるとリソースリークや動作不良を起こすため気を遣う

「後続の処理に順序性がある場合」とは、変数が変わった後の処理が複数フレームにまたがって A→B→C のように処理を実行する場合、以下のように

async UniTask OnValueChanged()
{
    wile(true)
    {
        await UniTask.WhenAll(...

        // 以下のように時間のかかる処理をシーケンシャルに書ける
        await A(); // 時間がかかる処理
        await B(); // Aが終わった後Bを実行 (これも時間がかかる
        await C(); // Bが終わった後Cを実行 (これも時間がかかる
        // 全部終わったら監視を再開
    }
}

といった、時間のかかる処理を順番に処理しつつ処理中は監視を行わない、のような実装が順序通り書けることを指します(通常コールバックなどに書いていた内容を順番に並べて書くことができます

ただ、別で起動したタスクを裏で待機させっぱなしにして変更を待つという概念を習得する必要があったり、UniTask (非同期処理)を覚える必要があり使用はややハードルが高いかもしれません。実装難易度は一番高いと思います。

まとめ

最後に簡単にまとめると以下のようになります。

  • Updateは簡単で単純だけど問題が起きることもある、複雑な条件も対応できる
  • プロパティは監視できない場合があるがそうでない場合実装が簡単
  • UniRxは単体の監視が容易だが複合的な値の変更にやや弱い
  • UniTaskは複雑な監視がUpdateメソッド外でできるが習得は難しい

UniRx と UniTask については以下の書籍が非常に詳しいのでもし余裕があれば買ってみるといいかもしれません。