본문으로 건너뛰기

UniRx에서 카운트 다운 타이머를 만들자

· 약 11분
karais89

환경​

  • macOS Mojave v10.14.6
  • Unity 2019.2.5f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/581ffc0ddce7090b275b

이 포스팅은 원문을 단순히 구글 번역을 하여 정리한 내용입니다. 일본어를 잘하시는 분은 원문을 보시는게 더 좋으실 것 같습니다.

UniRx에 대한 기사 요약은 여기


Unity 개발에서 카운트 다운 타이머 (또는 카운트 업 타이머)를 구현해야 되는 경우가 많습니다.

이번에는 그 카운트 다운 타이머를 UniRx를 사용하여 구현 할 예정입니다.

Rx로 만든 카운트 다운 타이머​

지정한 초만큼 카운트 다운하는 스트림

/// <summary>
/// countTime 만큼 카운트 다운하는 스트림
/// </summary>
/// <param name="countTime"></param>
/// <returns></returns>
private IObservable<int> CreateCountDownObservable(int countTime)
{
return Observable
.Timer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1)) // 0초 이후 1초 간격으로 실행
.Select(x => (int) (countTime - x)) // x는 시작하고 나서의 시간(초)
.TakeWhile(x => x > 0); // 0초 초과 동안 OnNext 0이 되면 OnComplete
}

Rx로 초를 카운트 다운하는 경우 위와 같은 방법으로 구현할 수 있습니다.

(Generate 및 plan/Pattern를 사용해도 좋지만, UniRx에는 이러한 기능이 아직 구현되지 않았다.)

다만, 이 CreateCountDownObservable로 만든 스트림은 Cold라는 점에 주의할 필요가 있습니다.

(Subscribe한 타이밍부터 시작하고 카운트다운이 시작되는 점, 1개의 타이머 스트림을 여러번 Subscribe하려면 Hot 변환이 필요하다는 점 등)

Hot/Cold 내용은 여기를 참고하십시오.

Rx에서 타이머를 만드는 장점/단점​

Rx에서 타이머를 만드는 것과 Unity 표준 코루틴과 InvokeRepeat에서 타이머를 만드는 것을 비교하면 Rx로 만드는 경우 다음과 같은 장단점이 있습니다.

장점

  • 스트림 자체가 시간을 관리 해준다.
  • 타이머를 감시하는 측의 처리가 쉽다.
  • 타이머를 바탕으로 다른 작업을 시작 하기 쉽다.

단점

  • 어느 순간에 타이머의 시간을 얻을 수 없다 (임시 변수에 저장할 필요가 있다)
  • 타이머를 중단하거나 남은 시간을 고쳐 쓰기가 어렵다 (불가능하지는 않다)
  • Rx 자체가 어렵고 이해하기 어렵다 (Hot/Cold를 알지 못하면 오동작 가능성이 있다)

타이머를 Rx에서 만드는 가장 큰 장점은 "타이머를 감시하는 측의 처리가 쉽다"와 "타이머를 바탕으로 다른 작업을 시작하기 쉽다" 입니다.

예) Rx 타이머의 값을 사용하여 작업​

예를 들어, 방금 전의 CountDownTimer을 이용하여 다음과 같은 기능을 구현하려고 합니다.

구현 목록

  • Start() 시점에서 60초를 카운트 한다.
  • 타이머의 숫자를 UnityEngine.UI.Text에 그린다.
  • 카운트가 10초 이하가 되면 위의 Unity.UI.Text의 글자의 색상을 붉은 색으로 변하게 한다.
  • 카운트가 10초 이하가 되면 카운트마다 효과음을 울린다.
  • 계산이 끝나면 Unity.UI.Text의 문자를 지운다.
  • 계산이 끝나면 효과음을 울린다.

RxCountDownTimer.cs

/// <summary>
/// 카운트 구성 요소
/// </summary>
public class RxCountDownTimer : MonoBehaviour
{
/// <summary>
/// 카운트 다운 스트림
/// 이 Observable을 각 클래스가 Subscribe 한다.
/// </summary>
public IObservable<int> CountDownObservable => _countDownObservable.AsObservable();

private IConnectableObservable<int> _countDownObservable;

// 60초 카운트 스트림을 생성
// Publish로 Hot 변환
private void Awake() =>
_countDownObservable = CreateCountDownObservable(60).Publish();

// start시 카운트 시작
private void Start() =>
_countDownObservable.Connect();

/// <summary>
/// countTime 만큼 카운트 다운하는 스트림
/// </summary>
/// <param name="countTime"></param>
/// <returns></returns>
private IObservable<int> CreateCountDownObservable(int countTime) =>
Observable
.Timer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1))
.Select(x => (int) (countTime - x))
.TakeWhile(x => x > 0);
}

CountDownTextComponent.cs

/// <summary>
/// 타이머의 시간을 기초로 Text를 업데이트 할 컴포넌트
/// </summary>
public class CountDownTextComponent : MonoBehaviour
{
/// <summary>
/// UnityEditor에서 할당 한다.
/// </summary>
[SerializeField] private RxCountDownTimer _rxCountDownTimer;

/// <summary>
/// uGUI의 Text
/// </summary>
private Text _text;

private void Start()
{
_text = GetComponent<Text>();

// 타이머의 남은 시간을 표시한다.
_rxCountDownTimer
.CountDownObservable
.Subscribe(time =>
{
// onNext에서 시간을 표시한다.
_text.text = $"남은 시간 : {time}";
}, () =>
{
// onComplete에서 문자를 지운다.
_text.text = string.Empty;
}).AddTo(gameObject);

// 타이머가 10초 이하로 되는 타이밍에 색을 붉은 색으로 한다.
_rxCountDownTimer
.CountDownObservable
.First(timer => timer <= 10)
.Subscribe(_ => _text.color = Color.red);
}
}

CountDownSoundComponent.cs

[RequireComponent(typeof(AudioSource))]
public class CountDownSoundComponent : MonoBehaviour
{
// 효과음
[SerializeField] private AudioClip _seCountDownTick;
[SerializeField] private AudioClip _seCountDownEnd;

private AudioSource _audioSource;

/// <summary>
/// UnityEditor에서 할당 한다.
/// </summary>
[SerializeField] private RxCountDownTimer _rxCountDownTimer;

private void Start()
{
_audioSource = GetComponent<AudioSource>();

// 카운트가 10초 이하가 되면 효과음을 1초마다 울리게 한다.
_rxCountDownTimer
.CountDownObservable
.Where(time => time <= 10)
.Subscribe(_ => _audioSource.PlayOneShot(_seCountDownTick));

// 계산이 완료된 시점에서 효과음을 울린다.
_rxCountDownTimer
.CountDownObservable
.Subscribe(_ => { ; }, () => _audioSource.PlayOneShot((_seCountDownEnd)));
}
}

RxCountDownTimer에서 카운트 다운 스트림을 만들고 그것을 CountDownTextComponent와 CountDownSoundComponent에서 모니터링하여 값의 변화와 동시에 처리를 실시하게 구현되어 있습니다.

Rx를 사용하면 "타이머의 현재 시간이 흐른다"는 이벤트와 같은 (라기 보다는 그 이상)것을 보다 적은 코드로 처리할 수 있게 됩니다.

"값의 변화를 감시하고 처리한다", "변화하는 값이 (복잡한) 조건을 충족했을 때 처리한다" 라는 부분에서는 Rx를 사용하는 것이 유용합니다.

예) 타이머를 바탕으로 복잡한 처리를 한다.​

방금 전의 60초 타이머의 동작을 바꾸어 봅시다.

변경할 목록

  • Start()시 3초간 카운트 다운을 한다.
  • 3초 카운트 다운이 끝나고 나서 60초 카운트 다운을 시작한다.
  • 60초 카운트 다운이 끝나고 난 후 5초 정도 기다린 후 다른 씬으로 이동한다.

"게임 시작 전 3초 카운트 다운" → "게임 시작 60초 카운트 다운" → "5초 동안 결과 표시후 게임 종료" 순서라고 생각해 주십시오.

이를 Rx에서 쓰면 다음과 같습니다.

public class GameTimerManager : MonoBehaviour
{
/// <summary>
/// 경기 시작 전 카운트 다운
/// </summary>
public IObservable<int> GameStartCountDownObservable { get; private set; }

/// <summary>
/// 경기 중 카운트 다운
/// </summary>
public IObservable<int> BattleCountDownObservable { get; private set; }

private void Start()
{
// 경기 전 3초 타이머
// 3초 타이머의 스트림을 Publish로 Hot으로 변환 (아직 Connect는 하지 않는다)
var startConnectableObservable = CreateCountDownObservable(3).Publish();
// 외부에 공개하기 위해 Observable로 저장
GameStartCountDownObservable = startConnectableObservable;

// 경기 중 60초 타이머
// 60초 타이머의 스트림을 Publish로 Hot으로 변환 (아직 Connect는 하지 않는다)
var battleConnectableObservable = CreateCountDownObservable(60).Publish();
// 외부에 공개하기 위해 Observable로 저장
BattleCountDownObservable = battleConnectableObservable;

// 3초 타이머의 OnComplete에서 60초 타이머를 Connect한다 (60초 타이머 시작)
GameStartCountDownObservable
.Subscribe(_ => { ; }, () => battleConnectableObservable.Connect());

// 60초 타이머 뒤에 Concat으로 5초 타이머를 연결하고 OnComplete에서 Scene를 전환한다.
BattleCountDownObservable
.Concat(CreateCountDownObservable(5))
.Subscribe(_ => { ; }, () =>
{
SceneManager.LoadScene("NextScene");
}).AddTo(gameObject);

// 3초 타이머 시작
startConnectableObservable.Connect();
}

/// <summary>
/// countTime 만큼 카운트 다운하는 스트림
/// </summary>
/// <param name="countTime"></param>
/// <returns></returns>
private IObservable<int> CreateCountDownObservable(int countTime) =>
Observable
.Timer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1))
.Select(x => (int) (countTime - x))
.TakeWhile(x => x > 0);
}

조금 복잡 할지도 모릅니다만, 이 코드만으로 구현이 완료됩니다.

상태를 관리하는 필드 변수는 불 필요 하고, 스트림의 합성만으로 구현할 수 있었습니다.

정리​

  • Rx를 이용하면 타이머를 보다 적은 코드로 구현할 수 있다.
  • 스트림 자신이 시간을 유지하기 위해 임시 저장 변수가 불필요 하다. (그러나 임의의 프레임에서의 시간 취득은 할 수 없게 된다)
  • 타이머의 복잡한 구현은 Rx의 합성 메서드로 구현 할 수 있다.
  • UI 갱신 등에 Rx 스트림을 편리하게 사용할 수 있다. (Model에서 View로 통지)
  • Rx의 Hot/Cold의 개념을 알고 있지 않으면 오동작 가능성이 있다.
  • Rx 자체가 어렵다.
  • UniRx가 더 유행 했으면 좋겠다.