UniRx 공부하기 - 2
Observable 이란 ?
UniRx를 사용하기 위해서는 데이터 변환을 감지하는 스트림을 생성한 뒤 구독하여야 이벤트가 발생한다.
Observable 객체의 생성 방식을 다양하게 있다.
Subject 시리즈 사용
ReactiveProperty 시리즈를 사용
팩토리 메소드 시리즈 사용
UniRx.Trigger 시리즈 사용
Coroutine을 변환하여 사용
UGUI 이벤트 변환 사용
그외 ..
이 중, 게임을 만들면서 주로 사용되는건 ReactiveProperty, 팩토리 메소드, UnirRx.Trigger, UGUI 이벤트 변환 사용등을 많이 사용할 것 같다.
UniRx.Trigger 내부를 잘몰라서 찾아보니 Collision, PointerDown 등 유니티 내부에서 지원하는 이벤트 등이 있어서 많이 사용할 것으로 판단했다.
Coroutine은 안그래도 Unitask에 대체되고 있어서 사용하진 않을 것 같고, 5번 빼고는 많이 사용하지 않을까 싶다.
Observable 객체의 생성 방식
1. Subject 시리즈를 이용
Subject<T> 시리즈는 크게 4가지가 있다.
Subject<T> 종류 | 설명 | 예상 사용 용도 |
Subject<T> |
|
|
AsyncSubject<T> |
|
|
BehaviorSubject<T> |
|
|
ReplaySubject<T> |
|
|
예상 사용 용도는 이렇게 사용하면 사용할 수 있지 않을까? 하면서 생각해봤는데, 사실 이건되는데 저건 안된다. 이런식으로 생각한건 아니고 일반 Subject<T> 에서 예를 든 내용도 사실 AsyncSubject<T>에서도 사용할 수 있고, Subject<T> 성향에 맞춰서 작성해 보았다.
Subject<T>의 간략한 사용 코드
public class UniRxUIObject : MonoBehaviour
{
[SerializeField]
Button mButton;
[SerializeField]
TextMeshProUGUI mText;
// Start is called before the first frame update
void Start()
{
Subject<int> mySubject = new Subject<int>();
mySubject.AsObservable().Subscribe(n => SomeMethod(n));
// subject객체를 통해 직접 메시지 발행 가능
mySubject.OnNext(0);
mySubject.OnNext(55);
mySubject.OnNext(22);
mySubject.OnNext(7);
mySubject.OnCompleted();
}
public void SomeMethod(int _value)
{
Debug.Log($"SomeMethod {_value}");
}
}

AsyncSubject<T>의 간략한 사용 코드
이쪽은 조금 MVVM 패턴에서 VM 은 없지만 MV만 있는 패턴이라고 생각하고,
이해하면 편할 것 같다.
대략 구조는 DataModelManager가 싱글톤으로 데이터 모델(CharStatusModel)을 가지고 있고, 그 모델을 UniRxAsyncSubjectObject 에서 스트림에 구독하고 감지하는 것이다.
DataModelManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using UniRx.Triggers;
using Cysharp.Threading.Tasks;
using CustomUniRxData;
using UnityEngine.UI;
public class DataModelManager
{
static DataModelManager _mDataModelManager;
public static DataModelManager GetInstance()
{
if (_mDataModelManager == null)
_mDataModelManager = new DataModelManager();
return _mDataModelManager;
}
DataModelManager()
{
if (null == _mCharStatusModel)
_mCharStatusModel = new ReactiveProperty<CharStatusModel>();
if (null == _mAsyncCharStatusModel)
_mAsyncCharStatusModel = new AsyncSubject<CharStatusModel>();
}
ReactiveProperty<CharStatusModel> _mCharStatusModel;
public IReadOnlyReactiveProperty<CharStatusModel> CharStatusModel;
public AsyncSubject<CharStatusModel> _mAsyncCharStatusModel;
// 현재 예시에서는 이것만 사용할 것.
}
CharStatusModel.cs
public class CharStatusModel : IModelBase
{
CharEssentialModel _essentialModel;
public CharEssentialModel EssentialModel { get { return _essentialModel; } }
CharBaseStatModel _baseStatModel;
public CharBaseStatModel BaseStatModel { get { return _baseStatModel; } }
public CharStatusModel()
{
this.Init();
}
public void Init()
{
// 캐릭터 Essential Model
if (null == _essentialModel)
_essentialModel = new CharEssentialModel();
// 캐릭터 BaseStat Model
if (null == _baseStatModel)
_baseStatModel = new CharBaseStatModel();
}
public void SetHp(int _hp)
{
_essentialModel.HP = _hp;
}
}
UniRxAsyncSubjectObject.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using UniRx.Triggers;
using Cysharp.Threading.Tasks;
using CustomUniRxData;
using UnityEngine.UI;
public class UniRxAsyncSubjectObject : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
var asyncModel = DataModelManager.GetInstance()._mAsyncCharStatusModel;
asyncModel.Subscribe(_ => { Debug.Log(_.EssentialModel.HP); });
}
// Update is called once per frame
void Update()
{
if(Input.GetKeyDown(KeyCode.Q))
{
var asyncModel = DataModelManager.GetInstance()._mAsyncCharStatusModel;
var input = new CharStatusModel();
input.SetHp(3);
asyncModel.OnNext(input);
input = new CharStatusModel();
input.SetHp(42);
asyncModel.OnNext(input);
input = new CharStatusModel();
input.SetHp(15);
asyncModel.OnNext(input);
}
if(Input.GetKeyDown(KeyCode.W))
{
var asyncModel = DataModelManager.GetInstance()._mAsyncCharStatusModel;
asyncModel.OnCompleted();
}
}
}

코드에서 3, 42 ,15 를 이벤트 데이터에 넣고, OnComplete로 가장 마지막에 들어온 메시지를 방출할 수 있도록 했다.
AsyncSubject<T> 특성 상, OnComplete를 실행하지 않으면 아무런 메시지도 방출할 수 없으니, 주의해야한다.
나머지는 사용법이 비슷해서 클래스만 바꿔서 테스트 할 수 있을 것이라고 생각하기에 기술하지는 않는것으로..
2. ReactiveProperty<T> 를 사용
위의 AsyncSubject<T>의 예시에서 코드를 데이터 감지용으로 조금 변경했다.
모델을 데이터 모델을 만들고, 그 모델의 데이터가 변경될 때, 구독한 함수가 수행되는 것이 의도였다.
이번에도 MVVM 느낌의 패턴으로 접근하면 더욱 이해가 쉬울 것이다.
ReactiveProperty<T>는 Subject<T> 시리즈와 달리 변수 x.Value 와 같은 접근자를 이용해 현재 데이터를 가져올 수 있다.
UniRxReactiveProperty.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using UniRx.Triggers;
using Cysharp.Threading.Tasks;
using CustomUniRxData;
using UnityEngine.UI;
public class UniRxReactiveProperty : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
var statusModel = DataModelManager.GetInstance().CharStatusModel;
statusModel.Subscribe(_ => Debug.Log(_.EssentialModel.HP));
}
// Update is called once per frame
void Update()
{
if(Input.GetKeyDown(KeyCode.Q))
{
var statusModel = DataModelManager.GetInstance().CharStatusModel;
statusModel.Value.SetHp(30);
statusModel.Value.SetHp(15);
statusModel.Value.SetHp(0);
}
}
}
DataModelManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using UniRx.Triggers;
using Cysharp.Threading.Tasks;
using CustomUniRxData;
using UnityEngine.UI;
public class DataModelManager
{
static DataModelManager _mDataModelManager;
public static DataModelManager GetInstance()
{
if (_mDataModelManager == null)
_mDataModelManager = new DataModelManager();
return _mDataModelManager;
}
DataModelManager()
{
if (null == _mCharStatusModel)
_mCharStatusModel = new ReactiveProperty<CharStatusModel>(new CharStatusModel());
if (null == _mAsyncCharStatusModel)
_mAsyncCharStatusModel = new AsyncSubject<CharStatusModel>();
}
ReactiveProperty<CharStatusModel> _mCharStatusModel;
public IReadOnlyReactiveProperty<CharStatusModel> CharStatusModel => _mCharStatusModel;
public void ChangeStatusModel(CharStatusModel _newModel)
{
_mCharStatusModel.Value = _newModel;
}
public AsyncSubject<CharStatusModel> _mAsyncCharStatusModel;
}
CharacterStatueModel.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using UniRx.Triggers;
using Cysharp.Threading.Tasks;
using CustomUniRxData;
using UnityEngine.UI;
using GlobalDataSpace;
using GlobalDataSpace.RelativeCharacter;
/// <summary>
/// Char Essential Model => 캐릭터가 가지고 있을 필수 정보
/// </summary>
public class CharEssentialModel
{
#region Constructor
public CharEssentialModel()
{
_hp = 100;
_mp = 100;
}
#endregion
int _hp;
int _mp;
public int HP { get { return _hp; } set { _hp = value; } }
public int MP { get { return _mp; } }
}
public class CharBaseStatModel
{
#region Constructor
public CharBaseStatModel()
{
_mDct_BaseStat = new Dictionary<CharacterBaseStatType, int>();
for(int i = (int)CharacterBaseStatType.STR; i <= (int)CharacterBaseStatType.WIS; ++i)
{
if (!_mDct_BaseStat.ContainsKey((CharacterBaseStatType)i))
_mDct_BaseStat.Add((CharacterBaseStatType)i, 50);
}
}
#endregion
Dictionary<CharacterBaseStatType, int> _mDct_BaseStat;
}
public class CharStatusModel : IModelBase
{
CharEssentialModel _essentialModel;
public CharEssentialModel EssentialModel { get { return _essentialModel; } }
CharBaseStatModel _baseStatModel;
public CharBaseStatModel BaseStatModel { get { return _baseStatModel; } }
public CharStatusModel()
{
this.Init();
}
public void Init()
{
// 캐릭터 Essential Model
if (null == _essentialModel)
_essentialModel = new CharEssentialModel();
// 캐릭터 BaseStat Model
if (null == _baseStatModel)
_baseStatModel = new CharBaseStatModel();
}
public void SetHp(int _hp)
{
var _resHp = _hp;
_essentialModel.HP = _hp;
var _newModel = new CharStatusModel();
_newModel._essentialModel = _essentialModel;
DataModelManager.GetInstance().ChangeStatusModel(_newModel);
}
}

3. 팩토리 메서드 시리즈 이용
팩토리 메서드는 UniRx에서 스트림을 만들어 주기 위한 방식 중에 하나로, Create, Timer, Interval 등으로 사용할 수 있고, Observable 하게 감지할 수 있게 할 수 있다.
팩토리 메서드를 사용하는 방법은 사용자에 따라 다양한 방법이 있기도 하겠지만, 쿨타임을 예시로 사용해보았다.
코드는 쿨타임 5초를 가진 스킬이 버튼을 통해 시작되고, 0.1초에 한번씩 쿨타임이 소모되면서 업데이트 되는 형식이다.
TestSkillModel.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using UniRx.Triggers;
using Cysharp.Threading.Tasks;
using CustomUniRxData;
using UnityEngine.UI;
public class SkillModel
{
// Skill Index
int _mi_SkillIndex;
// Skill CoolTime
float _mf_SkillCoolTime; // MilliSecond
// Proc CoolTime
float _mf_ProcSkillCoolTime;
public SkillModel()
{
this._mi_SkillIndex = 1;
this._mf_SkillCoolTime = 5000.0f;
this._mf_ProcSkillCoolTime = 0.0f;
}
#region Getters
public float GetSkillCoolTime() => _mf_SkillCoolTime;
public float GetProcSkillCoolTime() => _mf_ProcSkillCoolTime;
public int GetSkillIndex() => _mi_SkillIndex;
#endregion
#region CoolTime
public void StartCoolTime()
{
_mf_ProcSkillCoolTime = _mf_SkillCoolTime;
}
public void DecreaseCoolTime(float _decreaseValue)
{
float res = _mf_ProcSkillCoolTime - _decreaseValue;
_mf_ProcSkillCoolTime = res < 0.0f ? 0f : res;
}
#endregion
}
UniRxFactoryMethodObject.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using Cysharp.Threading.Tasks;
using CustomUniRxData;
using UnityEngine.UI;
using TMPro;
using UniRx.Triggers;
using System;
public class UniRxFactoryMethodObject : MonoBehaviour
{
#region ##Inspector
[SerializeField]
Button mButton;
[SerializeField]
TextMeshProUGUI mText;
#endregion
SkillModel _mc_skillModel;
void Start()
{
float _decreaseTickTime = 100; // millisecond 기준 : 0.1초
_mc_skillModel = new SkillModel();
Observable.Interval(TimeSpan.FromMilliseconds(_decreaseTickTime)).Subscribe(_ =>
{
if(_mc_skillModel.GetProcSkillCoolTime() >= 0f)
{
_mc_skillModel.DecreaseCoolTime(_decreaseTickTime);
mText.text = $"SkillCoolTime : {_mc_skillModel.GetProcSkillCoolTime()}";
}
});
}
public void OnClickStartCoolTime()
{
_mc_skillModel.StartCoolTime();
}
public void SomeAsyncMethod(int _value)
{
Debug.Log($"SomeAsyncMethod {_value}");
}
}

이렇게 Update 함수 없이도 쿨타임이 줄어드는 것을 감지할 수 있다.
4. UniRx.Triggers 시리즈 이용
UniRx.Triggers 시스템은 Unity에 맞춰서 개발된 이벤트 트리거 시스템으로 보인다.
단순 버튼 클릭 이벤트를 발생시키는 OnClickAsObservable() 뿐만 아니라 UpdateAsObservable처럼 이벤트와 유니티 라이프 사이클에 관련한 함수도 지원해준다.
이 Trigger 시리즈는 사용하기 나름이지만, UI 에서 가장 많이 사용될 것이라고 생각된다. OnValueChanged 와 같은 토글 이벤트도 사용할 수 있도록 지원한다.
UniRxTriggerObject.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using Cysharp.Threading.Tasks;
using CustomUniRxData;
using UnityEngine.UI;
using TMPro;
using UniRx.Triggers;
public class UniRxTriggerObject : MonoBehaviour
{
#region ##Inspector
[SerializeField]
Button mButton;
Toggle mToggle;
[SerializeField]
TextMeshProUGUI mText;
#endregion
void Start()
{
mToggle.OnValueChangedAsObservable().Subscribe(_ =>
{
Debug.Log($"SubsCribe ValueChanged!!");
});
mButton.OnClickAsObservable().Subscribe(_ =>
{
Debug.Log($"SubsCribe Click!!");
});
this.UpdateAsObservable().Subscribe(_ =>
{
Debug.Log($"Subscribe Update!");
});
}
}


OnClick 이벤트를 등록하지 않아도 등록해둔 SubsCribe Click! 로그 출력.
5. Coroutine을 변환하여 사용
UniRx는 Coroutine을 스트림에 등록해서 사용할 수 있다.
코루틴으로 Observable을 생성해서 실행하는 확장 함수에
FromCoroutine(Func<CancellationToken, IEnumerator> coroutine, bool publishEveryYield = false)
이런 CancellationToken 이 있어서, Unitask 확장인 줄 알았는데, 이는 단순히 안전하게 처리하기 위해 토큰을 인자로 받는것이었다.
FromCoroutine, StartAsCoroutine 등이 있는데, 요즘은 Coroutine 보단, Unitask로 많이 비동기 처리를해서 크게 사용할 것 같진 않다.
UniRxCoroutine. cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using CustomUniRxData;
using UnityEngine.UI;
using TMPro;
using UniRx.Triggers;
using System.Threading;
using Cysharp.Threading.Tasks;
public class UniRxCoroutine : MonoBehaviour
{
#region ##Inspector
[SerializeField]
Button mButton;
[SerializeField]
Button mButton2;
[SerializeField]
Button mButtonStop;
[SerializeField]
TextMeshProUGUI mText;
#endregion
// Start is called before the first frame update
CancellationTokenSource mCts;
void Start()
{
// Coroutine
mButton.OnClickAsObservable().Subscribe( _ =>
{
Observable.FromCoroutine(
() => Co_ProcAny(0)
).Subscribe();
});
// Unitask
mButton2.OnClickAsObservable().Subscribe( async _uni =>
{
mCts = new CancellationTokenSource();
await Uni_ProcAny(mCts.Token, 0);
});
// Unitask Stop
mButtonStop.OnClickAsObservable().Subscribe(_ =>
{
mCts.Cancel();
mCts.Dispose();
});
}
public IEnumerator Co_ProcAny(int _index)
{
while(_index < 6)
{
Debug.Log($"Co_ProcAny Index :: {_index++}" );
yield return new WaitForSeconds(1);
}
}
private async UniTask Uni_ProcAny(CancellationToken token, int _index)
{
while (_index < 5)
{
if (token.IsCancellationRequested)
{
Debug.Log($"Uni_ProcAny Break!! Index {_index}");
break;
}
Debug.Log($"Co_ProcAny Index :: {_index++}");
Debug.Log(mCts.Token == token);
await UniTask.Delay(System.TimeSpan.FromSeconds(1), cancellationToken: token);
}
}
public void Update()
{
}
}