まえがき
本記事は、VR法人HIKKY Advent Calendar 2024 の 6日目の記事です。
前日の記事は、三田村さんのAtlas化ツールに関しての開発秘話に関するインタビューでした!
Reactive ExtensionsとUniRx/R3の違い
Reactive Extensions(以下Rx)とUniRxは、密接に関連していますが、異なる概念です:
-
Reactive Extensions (Rx) - Microsoftが開発した非同期およびイベントベースのプログラミングのための基本概念とパターン
-
UniRxとは、neueccさんが作成されているReactive Extensions for Unityなライブラリです
-
R3は開発元はUniTaskやMagicOnionなどを公開しているCysharp社であり、メインの開発者はUniRxの作者でもあるneuecc氏です。こちらは、.NET 全般向けを対象にしております。
なぜR3ではなく、UniRxなのか
より新しい実装としてR3が存在しますが、本記事では、Reactive Programmingの概念を学ぶために、UniRxを使用して説明を行います。これには以下の理由があります:
-
多くの既存のUnityプロジェクトでUniRxが使用されており、保守や機能追加の際の知識として重要です。
-
R3は最低でもUnity 2021.3以上を必要とします。本記事では、より広い読者層に向けて、バージョン制約の少ないUniRxを用いて基本概念を説明します。
-
Reactive Programmingの基本概念は実装に依存せず、UniRxで学んだ知識はR3やその他のReactive実装にも応用可能です。
はじめに
Unity開発において、UniRxは非常に強力なライブラリとして知られています。複雑な非同期処理やイベント処理を簡潔に書け、コードの可読性と保守性を大きく向上させることができます。
この記事は、UniRxの基礎から実践的な使い方まで、体系的な理解を提供することを目標としています:
- UniRxを使ってみたいが、どこから始めればいいかわからない方
- なんとなくSubjectやReactivePropertyを使っているが、本当に正しい使い方なのか不安な方
- チームにUniRxを導入したいが、学習コストが心配な方
前提知識
本記事は以下の知識を持つ読者を想定しています:
- C#の基本文法の理解
- LINQの基本的な使い方の理解
- Unityでの開発経験
特にLINQの理解は重要です。UniRxはLINQの考え方を時間軸に拡張したものと考えることができるためです。LINQに不安がある方は、まずはそちらの学習をお勧めします。
Reactive Extensions (Rx)とは
Reactive Extensions(以下Rx)は、Microsoftが開発した非同期およびイベントベースのプログラミングのためのフレームワークです。その革新的な特徴は、「イベントを時間軸上のストリームとして扱い、それをLINQ形式で操作できる」という点にあります。
例えば、従来のイベント処理ではこのように書いていた処理を:
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
StartCoroutine(DelayedProcess());
}
}
IEnumerator DelayedProcess()
{
yield return new WaitForSeconds(2f);
Debug.Log("処理実行");
}
Rxを使用すると、以下のように宣言的に記述できます:
Observable.EveryUpdate()
.Where(_ => Input.GetKeyDown(KeyCode.Space))
.Delay(TimeSpan.FromSeconds(2))
.Subscribe(_ => Debug.Log("処理実行"));
LINQからRxへ:ストリーム処理の考え方
Rxを理解する上で重要なのは、「イベントをストリームとして捉える」という考え方です。これを理解するために、まずはLINQとの比較から見ていきましょう。
LINQとRxの違いについて
上記の最初の図が示すように、LINQとRxは非常によく似た構造を持っています。どちらもデータの流れを変換していく形になっていますが、大きな違いは処理の駆動方法にあります。LINQではforeachによって処理が引っ張られていくのに対し、Rxではイベントソースから処理が押し出されていきます。
Observable/Observerパターンの構造
上記のの図は、RxのコアとなるObservable/Observerパターンの関係性を表しています。IObservableは値の発行元として機能し、IObserverは値の受け取り手として機能します。この関係性は「Subscribe」を通じて確立され、「IDisposable」によって解除可能です。
HotとColdの違いについて
シーケンス図は、HotとCold Observableの決定的な違いを示しています:
- Hot Observableは、Subscribeの有無に関わらず値を発行し続けます。後からSubscribeしたObserverは、Subscribe以降の値のみを受け取ります。
- Cold Observableは、Subscribeされた時点で値の発行を開始します。各Observerは独立したストリームを持ち、最初から全ての値を受け取ります。
イベントストリームの実装例
実際のコードでの実装例も見てみましょう:
// Hot Observable(Subject)の例
var subject = new Subject<int>();
var hotObservable = subject.AsObservable();
// Cold Observableの例
var coldObservable = Observable.Range(1, 3);
// Hot ObservableとCold Observableの動作の違いを確認
Debug.Log("=== Hot Observable ===");
var hot1 = hotObservable.Subscribe(x => Debug.Log($"Hot1: {x}"));
subject.OnNext(1); // Hot1: 1が出力
var hot2 = hotObservable.Subscribe(x => Debug.Log($"Hot2: {x}"));
subject.OnNext(2); // Hot1: 2とHot2: 2が出力
Debug.Log("=== Cold Observable ===");
var cold1 = coldObservable.Subscribe(x => Debug.Log($"Cold1: {x}"));
// Cold1: 1, 2, 3が出力
var cold2 = coldObservable.Subscribe(x => Debug.Log($"Cold2: {x}"));
// Cold2: 1, 2, 3が出力
このように、図とコードを組み合わせることで、Rxのコアとなるコンセプトをより深く理解することができます。特にHotとColdの違いは実際のアプリケーション開発で重要な判断基準となるため、しっかりと理解しておくことをお勧めします。
データコレクションのストリーム(LINQ)
LINQでは、データのコレクションに対して操作を行います:
var numbers = new[] { 1, 2, 3, 4, 5 };
var result = numbers
.Where(x => x % 2 == 0) // 偶数をフィルタリング
.Select(x => x * 2); // 2倍に変換
イベントのストリーム(Rx)
Rxでは、時間軸上で発生するイベントをストリームとして扱います:
Observable.EveryUpdate()
.Where(_ => Input.GetMouseButtonDown(0)) // クリック時のみフィルタリング
.Select(_ => Input.mousePosition) // マウス位置に変換
.Subscribe(pos => Debug.Log($"クリック位置: {pos}"));
Observable/Observerパターンの理解
Rxの根幹を成すのが、Observable/Observerパターンです。このパターンでは、以下の3つのインターフェースが重要な役割を果たします:
-
IObservable<T>
:イベントの発行元 -
IObserver<T>
:イベントの購読者 -
IDisposable
:購読の解除を管理
public class MouseClickObservable : IObservable<Vector2>
{
private List<IObserver<Vector2>> observers = new();
public IDisposable Subscribe(IObserver<Vector2> observer)
{
observers.Add(observer);
return new Unsubscriber(() => observers.Remove(observer));
}
public void Update()
{
if (Input.GetMouseButtonDown(0))
{
foreach (var observer in observers)
{
observer.OnNext(Input.mousePosition);
}
}
}
}
Hot vs Cold Observable:重要な使い分け
Rxを使用する上で最も理解しておくべき概念の1つが、HotとColdのObservableです。この違いを理解していないと、思わぬバグを引き起こす可能性があります。
Cold Observable:遅延実行型
Cold Observableは、Subscribeされるまで値を発行しない特徴を持ちます。これはLINQの遅延実行に似た概念です:
// Cold Observableの例
var coldStream = Observable.Range(1, 3)
.Select(x => x * 2);
// この時点では実行されない
Debug.Log("Subscribeする前");
// Subscribeした時点で初めて実行される
coldStream.Subscribe(x => Debug.Log($"値: {x}"));
このコードを実行すると、Subscribeのタイミングで初めて値が計算され、発行されます。また、新しいSubscribeごとに独立したストリームが作られます。
Hot Observable:即時実行型
対してHot Observableは、Subscribeの有無に関わらず値を発行し続けます:
// SubjectはHot Observableの一種
var subject = new Subject<int>();
// 最初のSubscribe
subject.Subscribe(x => Debug.Log($"Observer1: {x}"));
subject.OnNext(1); // Observer1が1を受け取る
// 2つ目のSubscribe
subject.Subscribe(x => Debug.Log($"Observer2: {x}"));
subject.OnNext(2); // Observer1とObserver2の両方が2を受け取る
ColdからHotへの変換
実際のアプリケーション開発では、ColdなObservableをHotに変換したいケースがよくあります。これにはPublish
とConnect
を使用します:
var source = Observable.Interval(TimeSpan.FromSeconds(1))
.Take(5)
.Publish(); // HotなConnectableObservableに変換
// この時点ではまだ値は発行されない
var subscription1 = source.Subscribe(x => Debug.Log($"Observer1: {x}"));
var subscription2 = source.Subscribe(x => Debug.Log($"Observer2: {x}"));
// Connect()を呼び出すことで値の発行を開始
var connection = source.Connect();
ReactivePropertyによる状態管理
UniRxの強力な機能の1つがReactiveProperty
です。これは通常のプロパティをリアクティブに扱えるようにした型です:
public class PlayerStatus : MonoBehaviour
{
// HPの状態をReactivePropertyで管理
private ReactiveProperty<int> _hp = new ReactiveProperty<int>(100);
void Start()
{
// HPの変更を監視
_hp.Subscribe(hp =>
{
Debug.Log($"HPが変化: {hp}");
if (hp <= 0)
{
GameOver();
}
});
}
public void TakeDamage(int damage)
{
// 値を変更すると自動的にSubscribeしている箇所に通知される
_hp.Value -= damage;
}
}
Schedulerによる実行制御
Rxでは、非同期処理の実行タイミングをSchedulerで制御できます。UniRxでは主に以下のSchedulerを使用します:
// メインスレッドで実行(デフォルト)
Observable.Timer(TimeSpan.FromSeconds(1), Scheduler.MainThread)
.Subscribe(_ => Debug.Log("1秒経過"));
// Time.timeScaleの影響を受けない
Observable.Timer(TimeSpan.FromSeconds(1), Scheduler.MainThreadIgnoreTimeScale)
.Subscribe(_ => Debug.Log("実時間で1秒経過"));
// 別スレッドで実行
Observable.Timer(TimeSpan.FromSeconds(1), Scheduler.ThreadPool)
.Subscribe(_ => Debug.Log("別スレッドで1秒経過"));
実践的なユースケース
イベントの連鎖処理
例えば、「ボタンクリック → 2秒待機 → エフェクト表示 → 3秒待機 → 次のシーンへ遷移」という一連の処理を実装する場合:
button.OnClickAsObservable()
.Take(1) // 1回だけ実行
.Delay(TimeSpan.FromSeconds(2))
.Do(_ => ShowEffect())
.Delay(TimeSpan.FromSeconds(3))
.Subscribe(_ => SceneManager.LoadScene("NextScene"));
入力の制御(デバウンス/スロットリング)
ユーザー入力の処理を最適化する例:
// 入力の連続受付を抑制(最後の入力から0.5秒待って処理)
searchInput.OnValueChangedAsObservable()
.Throttle(TimeSpan.FromSeconds(0.5))
.Subscribe(text => SearchItems(text));
まとめ
Rxは非同期処理やイベント処理を宣言的に記述できる強力なライブラリです。特にUnityでの開発において、UniRxを使いこなすことで以下のメリットが得られます:
- コードの可読性向上
- 複雑な非同期処理の簡潔な記述
- イベントの購読/解除の容易な管理
- 状態変更の追跡と反応的な更新
ただし、その概念の理解には時間がかかるため、段階的に学習していくことをお勧めします。まずは単純なイベント処理から始めて、徐々に複雑な機能を取り入れていくのが良いでしょう。
参考資料
この記事が、皆さんのReactive Programmingへの第一歩となれば幸いです。