본문으로 건너뛰기

· 약 17분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.10f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/30c576c7b0a99f41fb87

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

[UniRx 입문 시리즈 목차는 이쪽]({% post_url 2019-11-17-UniRx-Getting-Started-List %})


0. 이전 복습

이전에는 스트림의 구축 방법을 몇 가지 소개 했습니다. 이번에는 더 실용성이 높은 "Update를 변환하는 방법"에 중점을 두고 설명 하겠습니다.

이 포스트에 적은 내용은 과거에 적은 [[UniRx] Update()를 Observable로 변환하는 방법]({% post_url 2019-10-10-UniRx-How-to-convert-Update-to-Observable %})의 내용을 고쳐 쓴 내용 입니다.

1. Update()를 스트림으로 변환하는 방법

Unity의 Update() 호출을 스트림으로 변환하는 방법은 두 가지가 있습니다.

  • UniRx.Triggers의 UpdateAsObservable을 이용하는 방법
  • Observable.EveryUpdate를 이용하는 방법

위 두 방법은 동작 자체는 비슷하지만 내부 구현이 크게 다릅니다. 우선 각각의 사용법과 구조를 설명하겠습니다.

UniRx.Triggers의 UpdateAsObservable를 이용하는 방법

사용법

호출 방법

  1. using UniRx.Triggers; 추가
  2. this.UpdateAsObservable() 선언

발행되는 형태

Unit

using UnityEngine;
using UniRx;
using UniRx.Triggers; // 이 using문이 필요

public class UpdateSample : MonoBehaviour
{
private void Start() =>
// UpdateAsObservable는 Component에 대한
// 확장 메서드로 정의되어 있기 때문에 호출시
// "this"가 필요
this.UpdateAsObservable()
.Subscribe(_ => Debug.Log("Update!"));
}

위처럼 Update 이벤트를 UniRx 스트림으로 변환하여 이용할 수 있습니다.

또한 UpdateAsObservable이 GameObject가 파괴되었을때 자동으로 OnCompleted가 발행되기 때문에 스트림의 수명 관리도 쉽습니다.

(Destroy시 OnCompleted가 발행된다)

구조

UpdateAsObservableObservableUpdateTrigger 컴포넌트에 실체를 갖는 스트림 입니다.

UpdateAsObservable을 호출 하는 타이밍에 해당 GameObject에 ObservableUpdateTrigger 컴포넌트를 UniRx가 자동으로 연결하고 이 ObservableUpdateTrigger가 발행하는 이벤트를 사용하는 구조로 되어 있습니다.

UpdateAsObservable는 ObservableUpdateTrigger를 초기화하는 확장 메서드

public static IObservable<Unit> UpdateAsObservable(this Component component)
{
if (component == null || component.gameObject == null) return Observable.Empty<Unit>();
return GetOrAddComponent<ObservableUpdateTrigger>(component.gameObject).UpdateAsObservable();
}

UpdateAsObservable의 본체

using System; // require keep for Windows Universal App
using UnityEngine;

namespace UniRx.Triggers
{
[DisallowMultipleComponent]
public class ObservableUpdateTrigger : ObservableTriggerBase
{
Subject<Unit> update;

/// <summary>Update is called every frame, if the MonoBehaviour is enabled.</summary>
void Update()
{
if (update != null) update.OnNext(Unit.Default);
}

/// <summary>Update is called every frame, if the MonoBehaviour is enabled.</summary>
public IObservable<Unit> UpdateAsObservable()
{
return update ?? (update = new Subject<Unit>());
}

protected override void RaiseOnCompletedOnDestroy()
{
if (update != null)
{
update.OnCompleted();
}
}
}
}

(코드는 여기에서 인용)

이처럼 UpdateAsObservable를 호출하는 것으로 ObservableUpdateTrigger 컴포넌트를 GameObject에 붙여 ObservableUpdateTrigger에서 실행되는 Update()를 내부에 가지는 Subject를 사용하여 단지 이벤트를 발행하고 있는 간단한 구조로 되어 있습니다.

여기서 주의해야 할 점은 다음 두가지 입니다.

  • ObservableUpdateTrigger라는 수수께끼의 컴포넌트가 갑자기 증가하더라도 그것이 정상 동작이므로 삭제하지 말자
  • 1개의 GameObject마다 1개의 ObservableUpdateTrigger를 공유하고 이용하기 때문에 UpdateAsObservable 자체를 무수히 Subscribe 해도 그다지 비용이 증가 될것은 없다.

특히 컴포넌트가 갑자기 증가하더라도 그것이 정상 동작이므로 삭제하지 말자 라는 점만 기억해두면 좋을 것 같습니다.

Observable.EveryUpdate를 이용하는 방법

사용법

호출 방법

  1. Observable.EveryUpdate() 직접 Subscribe하기

발행되는 형태

long(Subscribe 후 경과한 프레임)

using UniRx;
using UnityEngine;

public class UpdateSample : MonoBehaviour
{
private void Start() =>
Observable.EveryUpdate()
.Subscribe(_ => Debug.Log("Update!"));
}

기본적으로 사용법은 이전의 UpdateAsObservable와 같습니다. 하지만 한 가지 큰 차이는, Observable.EveryUpdate()는 스스로 OnCompleted를 발행하지 않습니다.Observable.EveryUpdate()를 사용하는 경우 반드시 직접 스트림의 수명 관리를 해야 합니다.

구조

Observable.EveryUpdate()는 UniRx의 기능 중 하나 인 "마이크로코루틴"을 이용하여 동작하며, 구조는 UpdateAsObservable에 다소 복잡 합니다. 굉장히 관결하게 정리 하면 "Observable.EveryUpdate()는 호출 될 때마다 싱글톤 상에서 코루틴을 시작 한다"라는 동작으로 보면 됩니다. 이 코루틴은 수동으로 멈추지 않는 한 계속 실행되기 때문에 스트림의 수명 관리를 제대로 하지 않으면 [입문 2]({% post_url 2019-11-06-UniRx-Getting-Started-2 %})에서 언급한 문제를 일으킬 수 있습니다.

그러나 반면 Observable.EveryUpdate()에는 다음과 같은 장점도 존재합니다.

  • 싱글톤에서 작동하기 때문에 게임 진행 내내 존재하는 스트림을 생성할 수 있다.
  • 대량의 Subscribe해도 성능이 저하되지 않는다. (마이크로 코루틴의 성질)

또한 UniRx가 관리하는 싱글톤은 "MainThreadDispatcher"라는 GameObject입니다. UniRx를 사용하고 있으면 어느새 생성될 수 있을 거라고 생각합니다. 이쪽도 UniRx의 동작에 절대적으로 필요하기 때문에 마음대로 삭제하거나 하지 않도록 주의 합시다.

(MainThreadDispatcher는 UniRx가 관리, 이용하고 있는 싱글톤 객체이다. 마음대로 삭제하지 말자)

UpdateAsObservable()와 Observable.EveryUpdate()의 구분

이 두개는 동작은 비슷하지만 내부 구현은 크게 차이가 있었습니다. 각각의 작동 원리를 확실히 파악하고 각 상황에 따라 적절한 쪽을 이용하면 좋을 것 같습니다.

  • UpdateAsObservable(): GameObject가 파기되면 자동으로 멈춘다
  • Observable.EveryUpdate(): 성능상으로 이점이 있지만 Dispose를 수동으로 호출할 필요가 있다.

UpdateAsObservable를 사용하면 좋을 것 같은 장소

  • GameObject에 연관된 스트림을 이용한다.
    • OnDestroy시 OnCompleted가 발행되므로 수명 관리가 편하다.

Observable.EveryUpdate()를 사용하면 좋을 것 같은 장소

  • GameObject를 이용하지 않는 Pure한 Class에서 Update 이벤트를 이용하고 싶을 때
    • 싱글톤을 통해 Update 이벤트를 가져올 수 있으므로 MonoBehaviour를 상속하지 않아도 Update 이벤트를 사용할 수 있다.
  • 게임 중에 항상 존재하고 작동하는 스트림을 준비하고 싶을 때
    • 싱글톤을 사용하고 있기 때문에 OnCompleted가 자동으로 발동하지 않는다.
    • 참고: [UniRx에서 FPS 카운터 만들기]({% post_url 2019-10-23-UniRx-FPS-Counter %})
  • 대량의 Update() 호출이 필요할 때
    • 소량의 Update() 호출보다 압도적으로 성능이 나온다.

솔직히 어느 쪽을 사용해야 할 것인가는 선택의 문제이기도 하다 생각합니다. Observable.EveryUpdate() 쪽이 성능은 좋지만, Dispose를 해야 되는 단점이 있습니다. 에라가 나서 스트림이 멈춘다면 좋지만, 가장 무서운 것은 에러가 발생해도 뒤에서 계속 움직여버리는 경우입니다. 눈치 채보니 쓰레기 스트림이 뒤에서 대량으로 작동 하는 경우와 같습니다.

그래서 아무리 성능에 차이가 있다고 해도 그 성능 차이가 게임의 동작에 영향을 주는 상황이란 거의 없습니다. (엄청난 양의 GameObject를 동시에 생성하고 움직였을 때 라든지?) 때문에, 개인적으로 더 안전한 UpdateAsObservable()을 사용하기를 권장합니다.

2. Update를 스트림으로 변환하는 이유

UniRx를 이용해야 하는 이유중 1개는 "Update를 스트림으로 변환 할 수 있다"는 점이라고 생각합니다. 스트림 화하면 다음과 같은 이점이 있습니다.

  • UniRx 오퍼레이터를 이용하여 로직을 작성 할 수 있게 된다.
  • 로직의 처리 단위가 명확해진다.

오퍼레이터를 이용한 로직의 작성

UniRx는 시간에 관련된 오퍼레이터가 다수 준비되어 있기 때문에 UniRx 스트림에서 논리를 작성하고 나면 시간의 관계 논리를 간결하게 기술 할 수 있습니다.

예를 들어, 버튼을 누르고 있는 동안 일정 간격으로 공격 하는 처리를 생각해 봅시다.

버튼을 누르고 있는 동안 일정 간격으로 공격한다는 것은 예를 들어 슈팅 게임 총알의 발사 등으로 사용할 수 있습니다. "버튼을 누르고 있는 동안 n초마다 총알을 발사한다"라는 상황입니다.

이를 UniRx를 이용하지 않고 구현하는 경우 마지막으로 실행한 시간을 기록하여 매 프레임 비교하는 등 복잡하고 귀찮은 구현이 필요합니다. 하지만 UniRx를 사용하면 다음과 같이 구현할 수 있습니다.

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

public class UpdateSample3 : MonoBehaviour
{
// 실행 간격
[SerializeField]
private float intervalSeconds = 0.25f;

private void Start() =>
// ThrottleFirst는 마지막으로 실행하고
// 일정 시간 OnNext를 차단하는 오퍼레이터
this.UpdateAsObservable()
.Where(_ => Input.GetKey(KeyCode.Z))
.ThrottleFirst(TimeSpan.FromSeconds(intervalSeconds))
.Subscribe(_ => Attack());

private void Attack() => Debug.Log("Attack");
}

이렇게 UniRx를 사용하면 컬렉션에 복잡한 처리를 LINQ에서 짧게 쓰는 것과 마찬가지로, 게임 로직을 선언적으로 간결하게 작성하는 것이 가능하다.

논리가 명확해진다

Unity에서 개발을 진행 하면, Update() 내에서 게임 로직이 담겨 엉망이 되어가는 경우가 대부분이라고 생각합니다.

그것도 UniRx를 사용하여 정리 할 수 있습니다.

이동, 점프, 착지시 효과음의 재생을 하는 로직의 예

이동, 점프, 착지시 효과음의 재생을 한다는 로직을 UniRx를 사용한 경우와 그렇지 않은 경우를 작성해 보겠습니다.

UniRx없이 작성

using System;
using UnityEngine;

public class Sample : MonoBehaviour
{
private CharacterController characterController;

// 점프 중 플래그
private bool isJumping;

void Start()
{
characterController = GetComponent<CharacterController>();
}

void Update()
{
if (!isJumping)
{
var inputVector = new Vector3(
Input.GetAxis("Horizontal"),
0,
Input.GetAxis("Vertical")
);

if (inputVector.magnitude > 0.1f)
{
var dir = inputVector.normalized;
Move(dir);
}
if (Input.GetKeyDown(KeyCode.Space) && characterController.isGrounded)
{
Jump();
isJumping = true;
}
}
else
{
if (characterController.isGrounded)
{
isJumping = false;
PlaySoundEffect();
}
}


}

void Jump()
{
// Jump 처리
}

void PlaySoundEffect()
{
// 효과음 재생
}

void Move(Vector3 direction)
{
// 이동 처리
}
}

UniRx를 사용하여 작성

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

public class Sample : MonoBehaviour
{
private CharacterController characterController;

// 점프 중 플래그
private BoolReactiveProperty isJumping = new BoolReactiveProperty();

void Start()
{
characterController = GetComponent<CharacterController>();

// 점프 중이 아니면 이동
this.UpdateAsObservable()
.Where(_ => !isJumping.Value)
.Select(_ => new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical")))
.Where(x => x.magnitude > 0.1f)
.Subscribe(x => Move(x.normalized));

// 점프 중이 아니라면 점프
this.UpdateAsObservable()
.Where(_ => Input.GetKeyDown(KeyCode.Space) && !isJumping.Value && characterController.isGrounded)
.Subscribe(_ =>
{
Jump();
isJumping.Value = true;
});

// 착지 플래그가 변화 할때 점프 중 플래그를 리셋
characterController
.ObserveEveryValueChanged(x => x.isGrounded)
.Where(x => x && isJumping.Value)
.Subscribe(_ => isJumping.Value = false)
.AddTo(gameObject);

// 점프 중 플래그가 false가 되면 효과음을 재생
isJumping.Where(x => !x)
.Subscribe(_ => PlaySoundEffect());
}

void Jump()
{
// Jump 처리
}

void PlaySoundEffect()
{
// 효과음 재생
}

void Move(Vector3 direction)
{
// 이동 처리
}
}

위 두가지를 비교하면 어떻습니까?

UniRx를 사용하지 않는 경우 Update 내에서 여러 작업을 함께 작성해야 하기 때문에 if문에 의해 중첩이 발생하거나 변수의 범위가 모호해지는 등의 문제점이 있었습니다.

하지만, UniRx를 사용하여 Update를 스트림화 하는 경우 로직 단위로 처리를 분할하고 나열해 기술할 수 있게 되었고, 변수의 범위도 스트림 내에 닫힌 구현이 되었습니다.

이와 같이 Update를 스트림화하는 것으로 처리를 적절한 단위로 구분하여 기술할 수 있게 되며, 변수의 범위도 명확히 할 수 있습니다.

또한 ObserveEveryValueChanged 대해서는 뒤의 보충 내용을 참조하십시오.

3. 정리

Update()를 스트림으로 변환하는 방법은 2가지

  • 일반적인 용도로 사용하는 경우 UpdateAsObservable()를 하면 된다.
  • 특수 용도의 경우 Observable.EveryUpdate()를 사용 하면 된다.

Update()를 스트림으로 변환하면 로직을 설명하기 쉬워진다.

  • UniRx 오퍼레이터를 게임로직에 그대로 사용할 수 있다.
  • 선언적으로, 간결하고 읽기 쉽게 작성할 수 있게 된다.

4. 추가

ObserveEveryValueChanged에 대해

var charcterController = GetComponent<CharacterController>();

// CharacterController의 IsGrounded을 감시
// false → true가 되면 로그출력
charcterController
.ObserveEveryValueChanged(c => c.isGrounded)
.Where(x => x)
.Subscribe(_ => Debug.Log("착지!"))
.AddTo(gameObject);

// ↑ 코드는 ↓와 거의 동의어
Observable.EveryUpdate()
.Select(_=>charcterController.isGrounded)
.DistinctUntilChanged()
.Where(x=>x)
.Subscribe(_ => Debug.Log("착지!"))
.AddTo(gameObject);

지난번 ObserveEveryValueChangedObservable.EveryUpdate + Select + DistinctUntilChanged의 축약이라고 설명했습니다. 사실 이 설명은 미묘하게 잘못 되었습니다.

ObserveEveryValueChanged는 감시 대상의 오브젝트를 약 참조(WeakReference)에서 참조 합니다.

즉, ObserveEveryValueChanged의 모니터링은 GC의 참조 카운트에 포함되지 않습니다. 또한 ObserveEveryValueChanged는 감시 대상의 객체가 GC에 회수되면 OnCompleted를 자동으로 발행 합니다.

이 점에 유의하여 ObserveEveryValueChanged를 이용하면 좋을 것 같습니다.

· 약 37분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.10f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/2a1d4185d7f54e0cca24

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

자기 소개

  • 이름: とりすぷ( @toRisouP )
  • 취미로 Unity를 이용해 게임 개발을 하고 있습니다.

지금 만들고 있는 게임

아이템을 자기 진영에 넣으면 점수가 나는 게임 입니다.

이번 내용

  • ハクレイフリーマーケット 개발을 통해 얻은 지식과 노하우를 정리합니다.
  • 1인 개발에서 얻은 지식이므로, 팀 개발에 그대로 적용 할 수 없을지도 모릅니다
    • 프로그래머 관점에서의 내용 입니다.
  • 생각나는대로 썻기 때문에 내용에 맥락은 없습니다.
    • 궁금하신 부분만 발췌해서 읽으세요.

정리 목록

크게 2가지가 있습니다.

  • 추천 에셋 서비스 편
  • 프로그래밍 편

추천 에셋 서비스 편

추천 에셋 서비스

  • 어떤 기능이 필요하게 되었을 때 이용한 에셋이나 서비스를 소개 합니다.

1. 네트워크를 만들고 싶다

사용한 것 : Phton Cloud

Photon

  • 일본에서는 GMO 인터넷에서 제공하는 네트워크 엔진
  • 서버 클라이언트 형태의 네트워크를 만들 수 있게 된다
  • 그 중에서도 "Photon Cloud"는 서버를 클라우드로 제공 해준다.
    • Unity 용 SDK도 공개되어 있다 (PUN)
    • 20명 동시 연결까지는 무료

공식 사이트

Unity + Photon Cloud에서 할 수 있는 일

  • 방을 만들고 참가자를 모집한다.
  • Transform, Animator등 다른 임의의 정보를 네트워크를 통해 동기화 할 수 있다.
  • 네트워크를 통해 메서드를 호출 해 실행할 수 있다.

네트워크 통신에 필요한 것은 대략 적으로 지원하고 있다.

그러나 네트워크를 쉽게 만들 수 있다고는 말하지 않았다!

네트워크 개발의 어려움

  • 생각해야 될 상태가 늘어난다
    • 같은 객체가 "로컬"과 "네트워크 너머 상대방의 세계(리모트)" 양쪽에 동시에 존재한다.
    • 로컬 및 원격 세계의 상태를 고려하여 객체를 조작하지 않으면 안된다
  • 간단한 것도 비동기 처리 해야 된다
    • 단지 "떨어진 아이템을 줍는다"라는 처리만으로도 매우 귀찮아진다.
    • 참고: 아이템 복사 방지
  • 디버깅이 매우 어렵다
    • 통신시의 미묘한 시기에 발생하는 버그 라든지 재현이 어렵다.
  • 통신 상대가 없으면 디버깅 및 레벨 디자인을 할 수 없다.
    • 동작 확인을 위해 여러 플레이어 필요
    • 실시간으로 사람을 모을 수 밖에 없다.

게임 개발 초보자에게는 네트워크 개발은 추천 할 수 없다

  • Photon이 지원해주는 것은 어디 까지나 통신의 낮은 레이어 부분 만 지원해주고, 응용프로그램은 결국 자신이 구현해야 한다.
  • 오프라인 게임에 비해 네트워크 개발은 구현하지 않으면 안되는 것이 몇 배는 있다
  • 어느정도 개발 경험을 쌓고 프로그래밍에 자신감이 붙은 사람이 아니면 손을 대는 것은 위험하다고 생각한다.

2. 사용자 관리가 하고 싶다

사용한 것: 니프티 클라우드 mobile backend (NCMB)

NCMB

  • 니프트에서 제공하는 mBaaS 서비스
    • (mBaaS : Mobile Backend as a Service)
  • 회원 가입 기능, 데이터 저장, 푸시 알림 등의 서비스를 이용할 수 있다.
  • 200만 API/월 요청까지라면 무료로 이용할 수 있다.

공식 사이트

[역주]

비슷한 서비스로 PlayFab 이나 Firebase도 제공하고 있다.

한국에서는 이 2개의 서비스로 시작하는게 더 나을 것 같다.

NCMB에서 사용자 관리

  • NCMB를 사용하면 간단하게 게임에 회원 가입 및 로그인 기능을 구현할 수 있다.
  • 이메일 주소 인증에도 대응 된다.
  • 자기 부담으로 계정 관리 서버를 만들지 않아서 정말 고맙다.

3. 제품 정품 인증 기능을 원한다.

사용한 것 : 없다

왜?

  • 일련 번호를 가진 사람만 즐길 수 있는 기능을 원한다.
    • 실제 구입한 사람만 즐길 수 있게 하고 싶다
  • 시리얼 정품 인증을 제공 해주는것은 좋은 느낌의 서비스는 존재하지 않는다.

결국 어떻게 했는지?

  • 자기 부담으로 인증 서버를 만들 수 밖에 없었다.
    • Ruby on Rails로 빠르게 Web 서비스를 만들 수 잇어서 좋았다.
  • 자기 부담 서버와 NCMB를 연계시켜 구입자, 미 구매자 관리를 했따.

자세한 내용은 Unity와 NCMB에서 사용자 관리를 구현 해본 이야기를 참조

4. NPC 상대를 만들고 싶다

사용한 것

Behaviour Designer

Behaviour Designer

  • BehaviourTree를 GUI에서 만들 수 있는 에셋
  • NPC의 행동을 최소 단위로 분할하고 그들을 결합하여 사람 같이 움직이게 할 수 있다.
  • 상태 머신을 짜는 것에 비해 매우 알기 쉽게 NPC를 만들 수 있으므로 추천한다.

완성된 실제 Tree

  • 만드는데 걸린 시간은 15 시간 정도
  • 70% 정도는 자작 Task 이다.

실제로 만든 NPPC의 동영상

https://www.youtube.com/watch?v=nLsR3nrmppQ

5. 게임 패드의 관리를 하고 싶다.

사용한 것

Rewired

Rewired

  • 게임 패드의 관리에 특화된 에셋
  • 게임 패드의 자동 검출및 자동 할당을 동적으로 해준다.
  • 게임패드의 형태에 따라 적절하게 변경해준다.
  • 방대한 종류의 게임 패드가 처음부터 등록되어 있다.
    • 일단 수중에 있던 게임 패드는 대략 대응 된다.

[역주]

Unity에서 새로 발표한 new Input System이 기능을 대체할 수 있을지 확인해봐야 될 것 같다.

6. 물리 기반 CharacterController을 원한다.

사용한 것

Easy Character Movement

Easy character Movement

  • 물리 연산과 상호 간섭하면서 작동하는 캐릭터를 쉽게 만들 수 있는 에셋
  • CharacterController과 같은 인터페이스에서 RigidBody를 취급 할 수 있다.
    • Move() 메서드로 이동하면서 AppleForce()로 날려 버린다 같은 것을 할 수 있다.
    • 움직이는 바닥의 영향을 받게 하고 싶다.

7. 개체를 눈에 띄게 하고 싶다

사용한 것

Highlighting System

Highlighting System

  • 스크립트를 연결하면 GameObject에 윤곽선을 달 수 있다.
    • 특별한 쉐이더를 사용하지 않고 스크립트만으로 가능하다.
  • 점멸 기능과 벽을 투명하게 그리는 기능도 있다.
  • 플레이어에 윤곽을 붙여두면 시인성이 향상되므로 추천 한다.

추천 자산 서비스 편 완료

프로그래밍 편

프로그래밍 편

  • Unity에서 프로그램을 해오면서 발생한 문제와 그 해결책을 소개 한다.
  1. 형 안전이 아닌 곳에서 죽는 문제
  2. 매니저 싱글톤의 배치 문제
  3. GOD 클래스가 있는 문제
  4. 컴포넌트간의 연계 방법
  5. View와 Model의 연계

1. 타입 안전이 아닌 곳에서 죽는 문제

"시작은 하는데 왠지 동작이 이상하다. 움직이지 않는다."

"컴파일 에러는 안났지만, 움직일때 에러가 난다"

타입 안전은?

  • 프로그램 작성을 잘못 했을 때 제대로 컴파일 오류가 발생한 상태
    • "타입"에 의해 프로그램의 정당성이 담보된 상태의 수
    • 타입 안전성이 없는 경우, 어딘가 기술을 잘못해도 인간이 그 실수를 알아차릴 방법이 없다.
    • 버그가 발생했을 때, 수상한 곳을 중단점으로 코드를 체크하게 된다.

Unity에서 타입 안전이 아닌 기능

  • SendMessage
  • Tag
  • Scene 전환
  • Invoke
  • Animator 플래그 지정

대부분 문자열 기반으로 처리를 하고 있는 곳

예: 태그

public void OnCollisionEnter(Collision collision)
{
// Enemy의 철자가 틀렸지만 컴파일 에러는 되지 않는다!
if (collision.gameObject.tag == "Eneny")
{
// 처리 부분
}
}

예: Animator 애니메이션 플래그

// IsRunning을 철자가 틀렸지만, 컴파일 에러는 되지 않는다!
animator.SetBool("IsRuning", true);

대책

문자열 기반의 처리를 없앤다

타입 안전하게 쓰는 방법

  • 문자열을 사용하여 동작을 제어하고 있는 것이 악의 근원이다.
  • 문자열을 사용 장소를 제한하고 가능한 "타입"의 형태 모양으로 변경하면 된다.
    • enum을 사용 하든지, 인터페이스를 사용 하든지, 프로퍼티로 감싸던지

예 1

  • Tag 비교에 enum을 사용
enum EntityType
{
Player,
Enemy,
Boss
}

public void OnCollisionEnter(Collision collision)
{
// enum의 ToString은 느리지만..
if (collision.gameObject.tag == EntityType.Enemy.ToString())
{

}
}

예 2

  • 원래 Tag를 사용하지 않고 타입으로 비교
public void OnCollisionEnter(Collision collision)
{
// IEnemy 인터페이스를 구현 한 컴포넌트를 가지고 있는지 알아
var enemyComponent = collision.gameObject.GetComponent<IEnemy>();
if (enemyComponent != null)
{
// 처리
}
}

예 3

  • 애니메이션 플래그를 프로퍼티로 감싼다.
Animator animator;

/// <summary>
/// 이동 애니메이션 플래그
/// </summary>
private bool IsRunning
{
// "IsRunning"라는 문자열이 등장하는 것은 이곳이 유일하다.
// (여기 만 오탈자가 발생하지 않도록 주의 해두면 된다)
set { animator.SetBool("IsRunning", value); }
}

void Start()
{
animator = GetComponent<Animator>();

// bool을 대입하는 것만으로 사용할 수 있다.
IsRunning = true;
}

"타입 안전이 아닌 곳에서 죽는 문제" 요약

  • int 형과 string 타입을 사용하여 조건 판전을 실시하고 있는 곳은 실수가 발생하기 쉽다.
  • 인터페이스를 만들거나, enum을 사용하거나, 프로퍼티로 감싸던지 어쨌든 원시적인 형태가 노출되지 않도록 유의하면 된다.

2. 매니저 싱글톤의 배치 문제

"Editor 상에서 씬을 지정해서 실행하면 에러가 발생한다."

"같은 게임 오브젝트가 2개가 존재한다"

매니저 싱글톤

  • 씬을 횡당하여 리소스 등을 계속 관리하는 싱글톤
    • 장면 전환시 전환 애니메이션 관리
    • 사운드 관리
    • 게임의 설정 항목 관리
  • 항상 1개 이며, 게임의 실행 중에 존재하지 않으면 게임이 올바르게 동작하지 않는다.

매니저 싱글톤은 어떻게 초기화 하나?

  • 씬에 Prefab화한 싱글톤을 배치하는 것 만으로 괜찮지 않나?
    • DontDestroyOnLoad로 지정하고 한 번 배치하면 사라지지 않도록 한다.

안됩니다.

씬에 처음부터 싱글톤을 놓아 두면 안되는 이유

  • 싱글톤을 배치하고 있지 않는 씬에서 게임을 실행하면 싱글톤이 존재하지 않으면 죽는다.
    • Editor에서 디버깅 중에 발생한다.
    • 전체 씬에 싱글톤을 배치하는 것 같은 것은 하고 싶지 않다.
  • 싱글톤이 존재하는 씬을 방문할때마다 하나씩 생성되어 증식된다.
    • 다중 생성되면 지우는 것도 근본적인 해결책이 되지 않는다.

대책

"필요한 타이밍에서 처음으로 싱글톤을 동적으로 생성하면 된다"

필요할때

  • 이미 싱글톤이 존재한다면 그것을 사용한다.
  • 없다면 새로 생성 한다.

이것만으로 해결 할 수 있다 (즉 단순한 지연 초기화)

구현 예

using UnityEngine;

/// <summary>
/// 씬 전환을 실시하는 클래스
/// </summary>
public static class SceneLoader
{
/// <summary>
/// 매니저 싱글톤의 Prefab 경로
/// </summary>
private static readonly string managerPrefabPath = "Managers/TransitionManager";

/// <summary>
/// 싱글턴 전환 애니메이션 제어 구성 요소
/// </summary>
private static TransitionManager _transitionManager;

/// <summary>
/// 이미 싱글 톤이 존재한다면 그것을 돌려주고, 없는 경우 만든다.
/// </summary>
private static TransitionManager TransitionManager
{
get
{
if (_transitionManager != null) return _transitionManager;
if (TransitionManager.Instance == null)
{
var resource = Resources.Load(managerPrefabPath);
Object.Instantiate(resource);
}
_transitionManager = TransitionManager.Instance;
return _transitionManager;
}
}

/// <summary>
/// 씬 전환을 시작
/// </summary>
/// <param name="scene"> 다음 씬 </param>
public static void LoadScene(GameScenes scene)
{
TransitionManager.StartTransaction(scene);
}
}

static 클래스에 매니저 싱글톤의 존재 확인을 끼워 없으면 생성시킨다.

구현 예

SceneLoader.LoadScene(GameScenes.Title);

호출자는 싱글톤을 의식하지 않고 static 클래스에 구현된 메소드를 실행 한다.

매니저 싱글톤의 배치 문제 요약

  • 지연 초기화를 이용하면 해결 할 수 있다.
  • 그러나 처음 액세스 할 때 싱글톤의 생산 비용이 발생하는 단점이 있다.
    • 스탠드얼론일때는 기동 직후 장면에서 취합하여 초기화를 실행한다.
    • 에디터 실행시에만 이 지연 초기화를 사용한다.
    • 라고 하면 좋을지도 모르겠다.

3. GOD 클래스가 있는 문제

"이 클래스는 1000 라인이 넘어 간다."

"필드 변수와 메소드가 많이 있어 어떻게 의존하고 있는지 모르겠다!"

"Update()의 내용이 위험하다!"

[역주]

1000 라인이 넘어간다고 무조건 GOD 클래스는 아니다.

GOD 클래스?

  • 여러가지 기능이 담겨있어 거대한 클래스
  • 피해야 할 안티 패턴
  • 대충 만들다 보면 이렇게 된다.
    • Unity의 공식 튜토리얼의 프로젝트를 그대로 확장 해 나가다보면 GOD 클래스가 되기 쉽다.

GOD 클래스인 PlayerController

  • Input 관리, 이동, 공격, 데미지, 애니메이션, 음향 효과, 입자 효과, UI 관리 등..
  • 이러한 처리가 모두 하나의 클래스에 정의되어 있다.
    • 상태를 나타내는 플래그가 난무하고 Update() 안에서도 이것저것 구현 되어 있다.

이렇게 만드는 사람들이 많을 것이다.

대책

컴포넌트 분할

컴포넌트 분할

  • 단일 책임 원칙을 의식하고 구성 요소를 분할한다.
    • 컴포넌트 1개는 1개의 역할만 담당한다.
    • 이동 처리는 PlayerMover 에서, 애니메이션 관리는 PlayerAnimation 같이 처리 한다.
  • 1개의 컴포넌트가 다루는 영역이 좁아지기 때문에 필요한 최소한의 필드와 메서드만 구현되서 코드의 품질이 좋아진다.

컴포넌트를 분할 할 때 조언

  • 그 컴포넌트가 제공하는 "기능"에 관계있는 처리만을 구현
    • "이동"에 특화된 컴포넌트는 이동 처리에 필요한 것만 제공한다.
    • 자신의 책임 이외의 처리는 다른 컴포넌트에 위양한다.

컴포넌트 분할의 장점

  • 1개의 컴포넌트가 맡는 책임이 명확해진다.
    • 불필요한 일을 생각하지 않고 지금 컴포넌트가 할 일만 생각하고 구현하면 된다.
  • 어떤 처리가 어떤 컴포넌트에 쓰여져 있는지 알기 쉽게 된다.
    • 클래스의 이름으로 처리 위치를 찾을 수 있게 된다.

컴포넌트 분할의 단점

  • 방대한 수의 컴포넌트를 연결해야 하며, 그 관리가 힘들어진다.
    • 악마 같은 RequireComponent를 쓰는 것도 고충이다.
    • MonoBehaviour을 상속하지 않는 Pure한 클래스를 생성하고 그 쪽에 처리를 위양하는 방법도 있다.
  • Update 호출 비용이 증가 한다.
    • (UniRx의 ObservableUpdateTrigger를 사용하면 Update 비용은 일단 줄일 수 있다.)
  • 뒤에서 다룰 "종속성"과 "클래스 간의 연계" 문제가 나온다.

하나님 클래스는 절대 악인가?

  • 규모가 작고 감당할 수 있는 범위라면 모든 처리를 하나의 클래스에 담아도 된다.
  • 처음에는 일단 프로그래밍 하고, 코드에 악취가 날때 분할 하는 방식도 괜찮다.

3. 의존, 참조 관계가 복잡해지는 문제

"어떤 컴포넌트가 어디에 의존하는 거야?"

"컴포넌트를 재사용 하고 싶지만, 어쩐지 여러곳에 의존하고 있어 사용할 수 없다!"

"어? 상호 참조하고 있어 초기화에 실패 하고 있다."

종속성이 복잡해지는 문제

  • 분할된 컴포넌트를 계속 연결해 나가면 복잡해진다.
  • 어디를 만지면 어디에 영향을 미칠지 예측 불가능 해진다.

대책

구현하기 전에 클래스 다이어그램을 작성하고 설계한다.

설계를 제대로 하자

  • 복잡해지는 원인은 대부분 닥치는 대로 구현하기 때문이다.
    • 사양이 원래 복잡한 경우는 어쩔 수 없다.
  • 설계 클래스 다이어그램을 만들어두면 구현 작업을 분담 할 수 있게 된다.
    • 누가 구현해도 설계대로 만들어질 것이다.
  • 잡다한 클래스 다이어그램도 적어 놓으면 나중에 전체 형태를 파악이 가능하게 된다.

클래스 다이어그램을 작성하는 추천 도구

  • PlantUML을 사용하면 쉽게 그림이 그려지므로 추천 한다.
    • 텍스트를 쓰는 것만으로 자동으로 클래스 다이어그램이 생성된다.

[역주]

Visual studio Code에서도 똑같은 확장 프로그램을 설치하여 실행 할 수 있다. 클래스 다아어그램을 문자로 관리를 하면 Git으로 버전관리도 되고, 협업도 된다.

실제로 작성한 클래스 다이어그램 예

클래스 설계시 조언

  • 상호 참조, 순환 참조를 가능한 피하자
    • 상호 참조, 순환 참조는 장점보다 단점이 더 눈에 띄기 때문에 안이하게 이용하지 말자
    • 닫힌 개념에서 국소적으로 사용할 정도로 억제해두면 좋다.
  • "추상화"와 "의존관계역전"을 이용하여 의존 관계를 정리하자.

의존관계역전

설계 노하우의 덩어리

  • SOLID 원칙은 확실히 알고 있으면 좋다.
  • "코드스멜"을 느낄 수 있게 되면 좋다.
    • 익숙해지면 어디를 추상화 해야 되는지 감각적으로 알게 된다.
  • 우선 여러 가지를 만들어 보고 어려움을 겪으면서, 거기에서 설계의 중요성을 실감하면 좋다.

이런 반론도 있었습니다.

"사양이 자주 바뀌기 때문에 설계를 못 해먹겠다!"

"사양이 자주 변하니까 설계하지 않는다"는 올바른가?

  • 솔직히 케바케라고 할 수 밖에 없다.
  • 변경에 유연하게 만드는 것도, YAGNI로 만드는 것도 있다.
    • (YAGNI: "You Ain't Gonna Need It". "필요한 작업만 해라", 사양 변경으로 유연하게 작업한 코드를 작성해 놓으면 코드가 불필요하게 장황해진다. 그러니 당장 필요한 작업에 집중하고 쓸데없는 작업은 하지 말라라는 개발 원칙) Simple is Best
  • 프로젝트에 따라 최적의 개발 스타일이 변화하기 때문에 설계를 안하는 것이 효율이 좋다면 이렇게 할 것이다.
    • 설계하는 것이 나중에 편해지는 경우가 더 많지만, "아무래도 설계하고 싶지 않다"라는 사람에게는 강요할 수 없다.

덧붙여서

'의존, 참고 관계가 복잡해지는 문제' 요약

  • 설계를 잘하는게 답
  • 클래스 다이어그램을 만들어두면 일단 구현헤서 해메지는 않을 것이다.

4. 컴포넌트간의 연계 방법

조각 조각낸 컴포넌트를 어떻게 조화시켜 동작시킬수 있을까?

  • "플레이어가 기절 하면, 이동하지 못하게 하고 기절 애니메이션을 재생하고 효과음을 재생하고 싶다"
  • 상태 관리를 일원화해서 그곳의 변화를 감지해서 마음대로 처리가 움직이는 형태로 하고 싶다.

컴포넌트간의 연계

  • 뭔가 있을 때마다 이벤트를 발행하는 것이 가장 편하다.
    • (Observer 패턴을 적용하여 연계시킨다)
  • 각 컴포넌트는 "OO가 일어나면 XX를 한다"는 것만을 의식하는 형태로 작성한다.

Observer 패턴?

Observer 패턴

  • 무엇인가 잘못되었을 때 감시 대상 측에서 이벤트를 날려주고 구독 측에서 처리하는 디자인 패턴
  • 의존 관계를 역전 할 수 있는 장점이 있다
    • (매 프레임 상태의 플래그를 감시 한다 같은 구현을 없앨 수 있다)

모르면 일단 "UniRx"를 사용하면 OK

UniRx

  • Reactive Extensions for Unity
  • 리액티브 프로그래밍을 Unity에서 사용할 수 있다
  • 할 수 있는 것이 너무 많아서 한마디로 설명 할 수 없다!
    • 과거에 정리한 포스트에 일부 설명하고 있기 때문에 그쪽을 참고하십시오.
    • 정리 링크

UniRx의 ReactiveProperty<T>

  • 이벤트 발행 기능을 가진 변수
  • 값이 바뀌면 통지가 날라온다.
  • 이것을 부모가 되는 클래스에 갖게 하고 자식이 이벤트를 기다리면 OK

구현 예

  • "플레이어가 기절 하면 이동할 수 없게 하고 기절 애니메이션을 재생 시키고 싶다"
  • UniRx를 이용한 구현 예를 소개한다.
using System.Collections;
using UniRx;
using UnityEngine;

public class PlayerHealth : MonoBehaviour
{
/// <summary>
/// 기절 플래그
/// </summary>
public BoolReactiveProperty IsStunned = new BoolReactiveProperty();

/// <summary>
/// 데미지 처리
/// </summary>
public void ApplyDamage()
{
// 기절하지 않았다면 기절 시킨다.
if (!IsStunned.Value) StartCoroutine(StunCoroutine());
}

/// <summary>
/// 기절하는 동안 수행되는 코루틴
/// </summary>
private IEnumerator StunCoroutine()
{
// 기절 플래그 ON
IsStunned.Value = true;
// 적당히 대기 한다.
yield return new WaitForSeconds(5);
// 기절 플래그 OFF
IsStunned.Value = false;
}
}

using UniRx;
using UnityEngine;

/// <summary>
/// 이동 관리 컴포넌트
/// </summary>
public class PlayerMove : MonoBehaviour
{
/// <summary>
/// 이동 허가 플래그
/// </summary>
public bool _canMove;

private void Start()
{
// 참조의 취득은 원하는 방식으로 구현
var playerHealth = GetComponent<PlayerHealth>();

// 기절 플래그가 변경되면 이동 허가 플래그에 반영한다.
playerHealth.IsStunned.Subscribe(x => _canMove = x);

// 다음 부분은 _canMove 플래그를 사용하여 이동 처리를 작성하는 로직
// 이하 생략
}
}

using UniRx;
using UnityEngine;

public class PlayerAnimation : MonoBehaviour
{
private Animator _animator;

private bool IsStunned
{
set => _animator.SetBool("IsStunned", value);
}

private void Start()
{
_animator = GetComponent<Animator>();

var playerHealth = GetComponent<PlayerHealth>();

// 기절 플래그를 써 고쳐하면 Animator의 기절 플래그에 반영
playerHealth.IsStunned.Subscribe(x => IsStunned = x);
}
}

using UniRx;
using UnityEngine;

public class PlayerSound : MonoBehaviour
{
private void Start()
{
var playerHealth = GetComponent<PlayerHealth>();

// 기절 상태에 맞추어 효과음을 재생, 중지
playerHealth.IsStunned.Subscribe(x =>
{
if (x)
{
Play();
}
else
{
Stop();
}
});

}

private void Play()
{
// 생략
}

private void Stop()
{
// 생략
}
}

"컴포넌트 연계 방법" 정리

  • 이벤트 기반 구현을 하는 것이 방법
    • Observer 패턴을 사용할 수 있다.
  • UniRx와 너무 잘 어울린다
    • 바로 리액티브 프로그래밍!

5. View와 Model의 연계

"어쩐지 Model (데이터 실체)과 View(UI)가 상호 의존적이다."

View와 Model이 상호 참조하면 여러가지 위험이 있다.

  • 데이터의 실체를 가지는 컴포넌트(Model)가 UI의 제어까지 해야 된다.
    • Model이 GOD 클래스화 된다.
  • UI의 교체가 어렵다.
    • Model이 View에 커플링이 되어 있기 때문에 쉽게 분리, 교체 할 수 없다.
  • UI를 재사용 할 수 없게 된다.
    • View에 필요한 로직이 Model에 작성되어 있기 때문에, 다른 Model에 같은 View를 적용하려고하면 Model의 구현마다 다시 작성해야 한다.

View와 Model을 잘 다루는 방법

  • 옛날부터 계속 논의되어 온 문제
  • 지금까지 MVC, MVP, MVVM 같은 아키텍쳐 패턴이 고안되어 왔다.
  • Unity에서 기분좋게 사용할 수 있는 패턴은 없는 걸까?

있습니다.

MV(R)P 패턴

MV(R)P 패턴

  • Model-View-(Reactrive)-Presenter 패턴
  • UniRx의 제작자 neuecc씨가 고안한 UniRx를 이용한 Unity의 UI 아키텍쳐 패턴
  • "Presenter"을 준비하고 Model과 View의 사이를 중개한다.
  • UniRx의 ReactiveProperty를 사용하는 것이 쉽다

참고 : UniRx 4.8 - 경량 이벤트 훅과 uGUI의 이벤트에 의한 데이터 바인딩

MV(R)P 패턴 적용 후

[역주]

MV(R)P 설계에서는 Presenter가 Model과 View을 소지하고있다. 따라서 Presenter는 Model과 View를 알고 있는데, 상호 참조를 피하기 위해 Model과 View는 Presenter를 모르는 구조로 되어 있다.

안드로이드의 MVP 예제들에서는 보통 View와 Presenter가 1:1로 서로 알고 있는 구조로 되어 있다. 두개의 공통점은 Model과 View는 서로 모른다는 점이다.

MV(R)P의 경우 MVP에서 변형된 아키텍쳐 패턴으로 유니티에 조금 더 특화된 패턴이라고 생각하면 될 것 같다. 실제로 Unity에서 UniRx를 사용하여 MV(R)P 아키텍쳐를 사용하능 방법에도 여러가지 방법으로 사용하기도 한다.

아래는 MVP 패턴에 대한 추가 링크

MV(R)P이 구현 예

  • uGUI의 InputField의 입력을 Model에 저장한다.
  • Model을 유지하는 데이터에 변경이 있는 경우 InputField도 업데이트 한다.
  • InputField = View 자체로 취급 한다.

Model

using UniRx;
using UnityEngine;

public class Model : MonoBehaviour
{
// 외부에 공개하는 데이터
public ReactiveProperty<string> Name = new ReactiveProperty<string>();
}

Presenter

using UniRx;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// Model과 View를 연결하는 Presenter
/// </summary>
public class Presenter : MonoBehaviour
{
// view
[SerializeField]
private InputField _nameInputField;

// model
[SerializeField]
private Model _model;

private void Start()
{
// view가 업데이트되면 Model의 데이터를 업데이트 한다
_nameInputField
.OnValueChangedAsObservable()
.Subscribe(x => _model.Name.Value = x);

// model이 업데이트 되면 view를 다시 업데이트 한다.
_model.Name
.Subscribe(x => _nameInputField.text = x);
}
}

MV(R)P 패턴은 추천 한다.

  • uGUI를 사용한다면 이 패턴을 사용하면 굉장히 편리하다.
  • View를 위해 약간의 로직이 필요하다면 그것을 Presenter에 써도 좋다.
    • 데이터 형식의 반환 같은 것
  • Presenter의 역할은 "Model과 View를 연결한다" 이므로, 거기에 벗어나는 처리는 하지 않는다.
    • Presenter의 구현은 몇 줄 ~ 수십 줄 정도로 끝날 정도가 적당하다.

[역주]

어떻게 프로그래밍을 하느냐에 따라 달라질 것 같다.

실제 모델에서 비즈니스 로직 처리를 전부 완료하고, 값의 반환을 Presenter에 전달하는 방식으로 구현하거나, View 자체의 논리적인 변화가 너무 많아진다면 View를 위한 컴포넌트를 따로 만들어서 처리하는 방식등을 사용하여 구현해야 할 수 있다.

프로그래밍 편 완료

마지막으로

  • 떠오른 생각을 닥치는 대로 썻는데, 분량이 대단히 많아 졌다.
  • 조금이라도 도움이 되는 부분이 있으면 좋겠다.
  • 이제 두번 다시 네트워크 게임은 만들고 싶지 않다.

감사합니다.

· 약 1분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.10f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/00b8a5bb8e7b68e0686c

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



과거에 쓴 포스트 목록

· 약 24분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.10f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/86fea641982e6e16dac6

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

UniRx 입문 시리즈 목차는 이쪽


0. 이전 복습

지난번 OnNext, OnError, OnCompleted 및 IDisposable의 용도에 대해 설명하고 스트림의 수명 관리 방법에 대해서도 설명하였습니다.

이번은 "스트림 소스를 만드는 법"에 대해 간략히 설명해 드리고자 합니다.

1. 스트림의 소스 (메시지 게시자)는?

UniRx의 스트림은 다음의 3가지로 구성되어 있습니다.

  1. 메시지를 발행하는 소스가 되는 것 (Subject 등)
  2. 메시지 전파하는 오퍼레이터 (Where, Select 등)
  3. 메시지를 구독하는 것 (Subscribe)

특히 UniRx를 사용한지 얼마 안되는 사람들은 "1"의 스트림 소스를 준비하는 방법을 잘 모르실 것이라고 생각합니다. 이번에는 이 스트림의 발단이되는 스트림 소스를 만드는 방법을 소개하고 싶습니다.

2. 스트림 소스가 될 수 있는 목록

스트림 소스를 준비하는 방법은 여러 가지가 있습니다. UniRx에서 제공해주는 스트림 소스를 이용해도 좋으며, 스스로 스트림 소스를 만들 수도 있습니다.

UniRx를 이용할 경우 다음과 같은 방법의 스트림 소스를 제공하고 있습니다.

  • Subject 시리즈를 사용
  • ReactiveProperty 시리즈를 사용
  • 팩토리 메서드 시리즈를 사용
  • UniRx.Triggers 시리즈를 사용
  • 코루틴을 변환하여 사용
  • uGUI 이벤트를 변환하여 사용
  • 기타 UniRx에서 준비되어 있는 것을 사용

각각 순서대로 설명하겠습니다.

Subject 시리즈

제 1회부터 여러번 등장하고 있는 Subject이지만, 이것을 이용하는 패턴이 가장 기본형입니다.

직접 스트림을 만들어 자유롭게 이벤트를 발행하고 싶다고 생각했을때 이 Subject를 사용하면 일단은 문제 없을 것입니다.

그리고 이 Subject는 몇 가지 파생이 존재하고 각각 다른 행동을 취합니다. 용도에 따라 적절한 것을 사용하는 것이 좋습니다. 이번에는 테이블로 간략히 소개하고 있지만, 각 Subject의 자세한 설명은 다음번에 설명하겠습니다.

Subject기능
Subject<T> 가장 간단한 것.OnNext가 실행되면 값을 발행한다.
BehaviourSubject<T>마지막으로 발행 된 값을 캐쉬하고 나중에 Subscribe 될 때 그 캐시를 반환해 준다. 초기 값을 설정 할 수도 있다.
ReplaySubject<T>과거 모든 발행 된 값을 캐쉬하고 나중에 Subscribe 될 때 그 캐시를 모두 정리해 발행한다.
AsyncSubject<T>OnNext를 즉시 발행하지 않고 내부에 캐쉬하고 OnCompleted가 실행 된 시간에 마지막 OnNext 하나만 발행한다. Future 및 Promise 같은 것

AynscSubject는 말 그대로 Future 나 Promise 같은 것입니다. 비동기 처리를 하고 싶을때 이용할 수 있습니다.

역주: Future나 Promise는 다른 언어에서 존재하는 개념. C++에서 std에 Future, Promise api가 포함되어 있어 해당 기능을 이용하면 비동기 처리를 실행할 수 있다.

ReactiveProperty 시리즈

ReactiveProperty<T>

ReactiveProperty<T>는 변수에 Subject의 기능을 붙인 것입니다. (구현도 그런 느낌으로 되어 있습니다.)

변수를 정의하기 쉽게 되어 있고 알기 쉽기 때문에, 초심자에게 추천 합니다.

// int형의 ReactiveProperty
var rp = new ReactiveProperty<int>(10); // 초기값 지정 가능

// 일반적으로 대입하거나 값을 읽을 수 있다.
rp.Value = 20;
var currentValue = rp.Value;

// Subscribe 할 수 있다. (Subscribe시 현재 값도 발행된다)
rp.Subscribe(x => Debug.Log(x));

// 값을 다시 설정할때 OnNext 발행 된다.
rp.Value = 30;

실행결과

20
30

또한 ReactiveProperty는 인스펙터 뷰에 표시하여 이용할 수 있습니다. 이 경우 제네릭 버전이 아닌, 각각의 형태의 전용 ReactiveProeprtyProperty를 사용해야 합니다.

또한 ReactiveProperty는 다음번에 설명할 예정인 MV(R)P 패턴에서 그 진가를 발휘하게 됩니다. 그때 까지 확실하게 마스터 하세요!

using UniRx;
using UnityEngine;

public class TestReactiveProperty : MonoBehaviour
{
// int형의 ReactiveProperty (인스펙터 뷰에 나오는 버전)
[SerializeField]
private IntReactiveProperty playerHealth = new IntReactiveProperty(100);

private void Start() => playerHealth.Subscribe(x => Debug.Log(x));
}

또한, enum도 ReactiveProperty화 해서 인스펙터 뷰에 표시 할 수도 있지만, 이쪽은 좀 더 연구가 필요합니다. 여기에 대해서는 UniRx의 저장 neuecc씨가 블로그 쪽에서 설명하고 있기 때문에 그 쪽을 참고하시면 좋을 것입니다.

UniRx 4.8-경량 이벤트 훅과 uGUI연계에 의한 데이터 바인딩

ReactiveCollection

ReactiveCollection<T>는 ReactiveProperty와 같은 것이며, 상태의 변화를 알리는 기능이 내장된 List<T> 입니다.

ReactiveCollection은 보통의 List처럼 쓸 수 있는 데다 상태의 변화를 Subscribe 할 수 있도록 되어 있습니다. 준비되어 있는 이벤트는 다음과 같습니다.

  • 요소 추가
  • 요소의 제거
  • 요소 수의 변화
  • 요소 재정의
  • 요소의 이동
  • 목록 지우기
var collection = new ReactiveCollection<string>();

collection
.ObserveAdd()
.Subscribe(x =>
{
Debug.Log($"Add [{x.Index}] = {x.Value}");
});

collection
.ObserveRemove()
.Subscribe(x =>
{
Debug.Log($"Remove [{x.Index}] = {x.Value}");
});

collection.Add("Apple");
collection.Add("Baseball");
collection.Add("Cherry");
collection.Remove("Apple");

실행결과

Add [0] = Apple
Add [1] = Baseball
Add [2] = Cherry
Remove [0] = Apple

ReactiveDictionary<T1, T2>

ReactiveDictionary의 Dictionary 버전 입니다. ReactiveCollection과 대부분 행동이 동일하므로 생략합니다.

팩토리 메서드 시리즈

팩토리 메서드는 UniRx가 제공하는 스트림 소스 구축 메서드 군입니다.

Subject만으로는 표현할 수 없는 복잡한 스트림을 쉽게 만들 수 있는 경우가 있습니다. Unity에서 UniRx를 이용하는 경우는 팩토리 메서드를 사용할 수 있는 기회는 그리 없을지도 모르지만, 어딘가에서 도움이 될 수 있다고 생각하기 때문에 기억하는 것도 좋을 것 같습니다.

그러나 팩토리 메서드 수가 많기 때문에 이용 빈도가 높은 것만 소개 하겠습니다.

만약 모든 팩토리 메서드 방법을 알고 싶다면, ReactiveX의 Operators 항목을 참고하는 것이 좋을 것 같습니다.

ReactiveX Creating Observables 항목

Observable.Create

Observable.Create<T>는 자유롭게 값을 발행하는 스트림을 만들 수 있는 팩토리 메서드 입니다. 예를 들어, 일정한 절차에 의해 처리 호출 규칙을 이 팩토리 메서드 내부에 은폐시켜, 결과만을 스트림에서 추출하는 방법등이 있습니다.

Observable.Create는 인수 Func<IObserver<T>, IDisposable>(IObserver<T>를 받고 IDisposable를 반환하는 딜리게이트)를 인수에 취합니다. 실제로 사용법을 보여주는 것이 알기 쉬울 거라고 생각 됩니다.

Observable.Create<int>(observer =>
{
Debug.Log("Start");

for (int i = 0; i <= 100; i += 10)
{
observer.OnNext(i);
}

Debug.Log("Finished");
observer.OnCompleted();

return Disposable.Create(() =>
{
// 종료시 처리
Debug.Log("Dispose");
});
}).Subscribe(x => Debug.Log(x));

실행결과

Start
0
10
20
30
40
50
60
70
80
90
100
Finished
Disposable

Observable.Start

Observable.Start는 주어진 블록을 다른 스레드에서 실행하여 결과를 1개만 발급하는 팩토리 메서드 입니다. 비동기로 무엇인가를 처리를 하고 결과가 나오면 통지를 원할때 사용할 수 있습니다.

// 주어진 블록 내부를 다른 스레드에서 실행
Observable.Start(() =>
{
// google의 메인 페이지를 http를 통해 get 한다.
var req = (HttpWebRequest) WebRequest.Create("https://google.com");
var res = (HttpWebResponse) req.GetResponse();
using (var reader = new StreamReader(res.GetResponseStream()))
{
return reader.ReadToEnd();
}
})
.ObserveOnMainThread() // 메시지를 다른 스레드에서 Unity 메인 스레드로 전환
.Subscribe(x => Debug.Log(x));

하나 주의할 점이 있습니다. Observable.Start 처리를 다른 스레드에서 실행하고 그 쓰레드에서 그대로 Subscribe 내 함수를 실행합니다. 이것은 스레드로부터 안전하지 않은 Unity에서 문제를 일으킬 수 있으므로 주의해야 합니다.

만약 메시지를 다른 스레드에서 메인 스레드로 전환하고자 하는 경우 ObserveOnMainThread의 오퍼레이터를 이용합시다. 이 오퍼레이터를 끼우는 것만으로, 이 오퍼레이터 이 후 Unity 메인 스레드에서 실행되도록 변환 합니다.

Observable.Timer/TimerFrame

Observable.Timer은 일정 시간 후에 메시지를 발행하는 간단한 팩토리 메서드 입니다.

실제 시간을 지정하는 경우 Timer를 사용하고 Unity의 프레임 수로 지정하는 경우 TimerFrame을 이용합시다.

Timer, TimerFrame은 인수에 따라 행동이 달라집니다. 1개 밖에 지정하지 않으면 OnShot 동작으로 종료하고 2개 지정한 경우 주기적으로 메시지를 발행하는 행동입니다. 또한 스케줄러를 지정하여 실행하는 스레드를 지정할 수 있습니다.

또한 비슷한 팩토리 메서드 인 Observable.Interval/IntervalFrame도 존재합니다. 이것은 Timer/TimerFrame의 2개의 인수를 지정하는 경우의 생략 버전 같은 것으로 생각 하시면 됩니다. Interva/IntervalFrame은 타이머를 시작할 때까지의 시간 (첫번째 인수)를 지정할 수 없게 되어 있습니다.

// 5초 후에 메시지 발행하고 종료
Observable.Timer(TimeSpan.FromSeconds(5))
.Subscribe(_ => Debug.Log("5초 경과했습니다."));

// 5초 후 메시지 발행 후 1초 간격으로 계속 발행
// 스스로 정지시키지 않는 한 계속 움직인다.
Observable.Timer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(1))
.Subscribe(_ => Debug.Log("주기적으로 수행되고 있습니다."))
.AddTo(gameObject);

Timer, TimerFrame은 정기적으로 실행을 하고자 할때는 Dispose의 작동을 기억해 주시기 바랍니다. 멈추는 것을 잊지 않고 방치하면 메모리 누수와 NullReferenceException의 원인이 됩니다.

역주:

해당 코드에서 정기적으로 발생하는 Timer의 경우 뒤에 AddTo 메서드를 붙여, 게임오브젝트가 제거 될때 자동으로 Dispose가 호출되게 됩니다.

UniRx.Triggers 시리즈

UniRx.Triggers는 using UniRx.Triggers; 를 사용하는 스트림 소스입니다. Unity 콜백 이벤트를 UniRx의 IObservable로 변환하여 제공 해주고 있습니다. UniRx에서는 이것이 가장 중요하고 유용하다고 생각합니다.

Triggers는 수가 매우 많기 때문에 다 소개 할수 없으므로, GitHub의 wiki를 참고하십시오.

GitHub - UniRx.Triggers

Unity가 제공하는 대부분의 콜백 이벤트를 스트림으로써 취득 가능하게 되어 있으며 GameObject가 Destroy 될때 자동으로 OnCompleted를 발급 해주는 구조로 되어 있기 때문에, 수명 관리도 걱정 없습니다.

using UniRx;
using UnityEngine;
using UniRx.Triggers; // 필수 추가

// <summary>
// WarpZone (라는 이름의 IsTrigger인 Collider가 붙은 영역)에
// 들어왔을때 부유하는 스크림트 (임의)
// </summary>
public class TriggersSample : MonoBehaviour
{
private void Start()
{
bool isForceEnabled = true;
var rb = GetComponent<Rigidbody>();

// 플래그가 유요한 동안 위쪽에 힘을 가한다.
this.FixedUpdateAsObservable()
.Where(_ => isForceEnabled)
.Subscribe(_ => rb.AddForce(Vector3.up * 20));

// WarpZone에 침입하면 플래그를 활성화 한다.
this.OnTriggerEnterAsObservable()
.Where(x => x.gameObject.CompareTag("WarpZone"))
.Subscribe(_ => isForceEnabled = true);

// WarpZone에 나오면 플래그를 해제 한다.
this.OnTriggerExitAsObservable()
.Where(x => x.gameObject.CompareTag("WarpZone"))
.Subscribe(_ => isForceEnabled = false);
}
}

Triggers를 사용하여 Unity 콜백을 스트림으로 변환하면 모든 것을 Awake/Start에 작성할 수 있습니다. 이것의 이점은 다음 번에 자세히 설명하겠습니다.

코루틴에서 변환

사실 Unity의 코루틴과 UniRx는 아주 궁합이 잘 맞습니다. IObservable 및 코루틴은 서로 변환하여 이용하는 것이 가능 합니다.

코루틴에서 IObservable의 변환은 Observable.FromCoroutine을 이용하여 수행 할 수 있습니다. 오퍼레이터 체인으로 복잡한 스트림을 구축하는 것보다 코루틴을 사용해 절차적으로 쓰는 경우가 더 심플하고 알기 쉬운 경우도 존재 합니다. 코루틴은 악으로 단정짓지 말고 차라리 코루틴과 UniRx를 함께 사용하는 것이 편리하다는 것을 기억하세요.

UniRx와 코루틴의 결합에 대한 설명은 다음번에 자세히 설명하겠습니다. 이번에는 간단한 예제만 소개하는 것으로 마치겠습니다.

using System;
using System.Collections;
using UniRx;
using UnityEngine;

public class Example23_Timer : MonoBehaviour
{
// <summary>
// 일시 정지 플래그
// </summary>
public bool IsPaused { get; private set; }

private void Start()
{
// 60초 카운트하는 스트림을 코루틴에서 만든다.
Observable.FromCoroutine<int>(observer => TimerCoroutine(observer, 60))
.Subscribe(t => Debug.Log());
}

// <summary>
// 초기 값에서 0까지 카운트하는 코루틴
// 그러나 IsPaused 플래그가 유요한 경우는 카운트 중지
// </summary>
IEnumerator TimerCoroutine(IObserver<int> observer, int initializeTime)
{
var current = initializeTime;
while (current > 0)
{
if (!IsPaused)
{
observer.OnNext(current--);
}
yield return new WaitForSeconds(1);
}
observer.OnNext(0);
observer.OnCompleted();
}
}

uGUI 이벤트에서 변환

UniRx는 uGUI와도 궁합이 좋고, ReactiveProperty와 결합하여 View와 Model의 관계를 굉장히 명확하게 구현할 수 있습니다. (MV(R)P 패턴이라고 불립니다.)

이번에는 MV(R)P 패턴은 설명 하지 않고 uGUI 이벤트에서 변환하는 방법만 소개 하겠습니다.

라고 해도 크게 소개할 것은 없고, UniRx를 using하고 있으면 uGUI 컴포넌트의 uGUI 이벤트로서 그대로 사용할 수 있도록 되어 있습니다.

using UniRx;
using UnityEngine;
using UnityEngine.UI;

public class Example23_uGUI : MonoBehaviour
{
// 인스펙터에서 설정
[SerializeField] private Button button;
[SerializeField] private InputField inputField;
[SerializeField] private Slider slider;

private void Start()
{
// uGUI의 기본 Unity 이벤트의 이름을 한 Observable이 준비되어 있다.
button.OnClickAsObservable().Subscribe(_ =>
{
Debug.Log("button OnClick!");
});

inputField.OnValueChangedAsObservable().Subscribe(str =>
{
Debug.Log("inputField OnValueChanged : " + str);
});
inputField.OnEndEditAsObservable().Subscribe(str =>
{
Debug.Log("inputField OnEndEdit : " + str);
});

slider.OnValueChangedAsObservable().Subscribe(val =>
{
Debug.Log("slider value changed : " + val);
});

// ----------

// 또한 이러한 방법도 있다.
inputField.onValueChanged.AsObservable().Subscribe();

// 이 두 기법의 차이는 Subscribe시 현재 값의 초기 값의 발행 여부이다
// Subscribe시 초기 값이 필요한 경우는 전자를 사용하면 된다.
inputField.OnValueChangedAsObservable(); // 초기값이 있다.
inputField.onValueChanged.AsObservable(); // 초기값이 없다.
}
}

기타

UniRx는 이외에도 편리한 스트림 소스를 제공 해주고 있습니다. 그 중 일부를 소개하겠습니다.

ObservableWWW

ObservableWWW는 Unity의 WWW를 스트림으로 처리 할 수 있도록 래핑 해 준 것입니다. 호출하는 것으로 UniRx는 코루틴을 실행해 WWW를 처리하고 결과만 알려줍니다.

ObservableWWW.Get("https://google.com")
.Subscribe(x => Debug.Log(x));

(UniRx는 내부에 코루틴을 가지고 있습니다. 그 실체가 되는 GameObject는 MainThreadDispacher 라는 이름으로 씬에 존재합니다. 이 MainThreadDispatcher를 멈추면 UniRx가 올바르게 동작하지 않게 되므로 이 GameObject를 손보는 것은 피하는 것이 현명합니다.)

역주:

Unity 2018.3 이상 버전부터는 ObservableWWW의 사용을 하지 않는 것을 권장하고 있습니다. 그 대신에 유니티에 새로 추가된 UnityWebRequest를 사용하라고 권장하고 있습니다. 실제 편안한 사용을 위해서는 UniTask 라이브러리와 같이 사용해 C#의 Task 처럼 사용을 하거나 Task를 Observable로 변환하여 사용하면 될 것으로 보인다. 자세한 내용은 UniRx.Async를 참고

Observable.NextFrame

이름 그대로 다음 프레임으로 메시지를 발행 해주는 스트림을 만들 수 있습니다. 메시지의 발행 타이밍은 Update가 타이밍이 아닌 코루틴 타이밍 이므로, 실행 타이밍이 매우 중요한 경우에는 주의가 필요합니다.

참고 : Unity 문서 이벤트 함수의 실행 순서

Observable.NextFrame()
.Subscribe(_ => Debug.Log("다음 프레임에서 실행됩니다."));

역주: 업데이트 타이밍은 다음 3가지로 조절 가능하며, 기본 값은 Update 이다.

  • Update (yield return null)
  • FixedUpdate (yield return new WaitForFixedUpdate())
  • EndOfFrame (yield return new WaitForEndOfFrame())

Observable.EveryUpdate

Observable.EveryUpdate는 매 Update 타이밍을 알려주는 스트림 소스 입니다. UniRx.Triggers의 UpdateAsObservable 과 비슷하지만 이쪽은 GameObject에 붙어 Destroy시 OnCompleted가 실행되는 반면 Observable.EveryUpdate스스로 중지하지 않는 한 씬을 거쳐도 계속 움직이는 스트림 입니다. FPS 카운터와 같은 어떤 씬에서도 계속 같은 스트림을 구축해야 될때 사용하면 좋습니다.

참고: UniRx에서 FPS 카운터를 만들어 보자.

ObservableEveryValueChanged

ObservableEveryValueChanged는 스트림 소스 중에서도 이색적인 존재이며, class 그 자체(?)의 확장 메서드로 정의되어 있습니다. 기능으로는 모든 객체의 파라미터를 매 프레임 모니터링하고 변화가 있었을 때에 통지하는 스트림을 생성 할 수 있습니다.

var characterController = GetComponent<CharacterController>();

// CharacterController의 isGrounded를 감시
// false -> true가 되면 로그 출력
characterController
.ObserveEveryValueChanged(c => c.isGrounded)
.Where(x => x)
.Subscribe(_ => Debug.Log("착지!"))
.AddTo(gameObject);

// ↑ 코드는 ↓와 거의 동의어
Observable.EveryUpdate()
.Select(_ => characterController.isGrounded)
.DistinctUntilChanged()
.Where(x => x)
.Subscribe(_ => Debug.Log("착지!"))
.AddTo(gameObject);

// ObserveEveryValueChanged는
// EveryUpdate + Select + DistinctUntilChanged
// 의 축약 버전에 속한다.

3. 정리

스트림 근원(소스)를 만드는 방법은 여러가지가 있다.

  • Subject 시리즈를 사용
  • ReactiveProperty 시리즈를 사용
  • 팩토리 메서드 시리즈를 사용
  • UniRx.Triggers 시리즈를 사용
  • 코루틴을 변환하여 사용
  • uGUI 이벤트를 변환하여 사용
  • 기타 UniRx에서 준비되어 있는 것을 사용

"UniRx.Triggers", "ReactiveProperty", "uGUI에서 변환"이 개인적으로 최우선으로 기억해야 될 것이라고 생각합니다. 이 3가지만 기억한다면 우선 UniRx를 사용한 개발에 80% 정도는 어떻게든 된다고 생각합니다.

· 약 22분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.9f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/851087b4c990d87641e6

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

UniRx 입문 시리즈 목차는 이쪽


0. 이전 복습

지난번 IObserver 인터페이스는 다음과 같이 정의된다고 설명했습니다.

using System;

namespace UniRx
{
public interface IObserver<T>
{
void OnCompleted();
void OnError(Exception error);
void OnNext(T value);
}
}

지난번에는 설명의 편의상 OnNext만 설명 했습니다.

이번에는 생략했던 "OnError", "OnCompleted" 및 "Dispose"에 대해 설명하겠습니다.

1. "OnNext", "OnError", "OnCompleted"

UniRx에서 발행되는 메시지는 모두 이 3가지 중 어느 하나가 되며, 다음과 같은 용도로 이용되고 있습니다.

  • OnNext: 통상 이벤트가 발행되었을 때 통지되는 메시지
  • OnError: 스트림 처리 중 예외가 발생했음을 통지한다.
  • OnCompleted: 스트림이 종료되었음을 통지한다.

"OnNext"메시지

OnNext는 UniRx에서 가장 많이 사용되는 메시지이며, 보통 "이벤트 통지" (EventArgs 포함)을 나타냅니다.

가장 많이 이용되는 메시지이며, 사용법에 따라서는 이 OnNext 메시지만을 기억하고 있어도 문제가 없는 경우도 많습니다.

예 1. 정수값 통지

var subject = new Subject<int>();

subject.Subscribe(x => Debug.Log(x));
subject.OnNext(1);
subject.OnNext(2);
subject.OnNext(3);
subject.OnCompleted();

실행결과

1
2
3

예 1은 간단하게 정수를 통지하고 구독 측에서는 Debug.Log에 표시하는 단순한 샘플 코드 입니다.

예 2. "의미"없는 값을 통지

var subject = new Subject<Unit>();

subject.Subscribe(x => Debug.Log(x));

// Unit 형은 그 자체는 별 의미가없다.
// 메시지의 내용에 의미가 아니라 이벤트 알림 타이밍이 중요한 순간에 사용할 수 있다.
subject.OnNext(Unit.Default);

실행결과

()

예 2는 Unit형 이라는 특수한 형태를 발행하고 있습니다.

이 형태는 "메시지의 내용물에 의미는 없다"라는 표현을 할 때 사용합니다.

이것은 "이벤트가 발생된 타이밍이 중요하며, OnNext 메시지 내용은 상관 없다"라는 경우에 사용할 수 있습니다.

예를 들어, "씬의 초기화 완료", "플레이어 사망"등에서 사용할 수 있습니다.

예 3. 씬의 초기화 완료 후 Unit형 통지

public class GameInitializer : MonoBehaviour
{
// Unit형 사용
private Subject<Unit> initializedSubject = new Subject<Unit>();

public IObservable<Unit> OnInitializedAsync => initializedSubject;

private void Start()
{
// 초기화 시작
StartCoroutine(GameInitializeCoroutine());

OnInitializedAsync.Subscribe(_ => { Debug.Log("초기화 완료"); });
}

private IEnumerator GameInitializeCoroutine()
{
/*
* 초기화 처리
*
* WWW 통신이나 개체 인스턴스화 등
* 시간이 걸리고 무거운 처리를 여기에서 한다고 가정
*/
yield return null;

// 초기화 완료 통지
initializedSubject.OnNext(Unit.Default); // 타이밍이 중요한 통지이므로 Unit로도 충분하다.
initializedSubject.OnCompleted();
}
}

코루틴으로 게임의 초기화를 수행하고 처리가 완료되면 이벤트를 발행해 통지하는 클래스 구현 예 입니다.

이러한 이벤트는 이벤트의 내용물 값은 무엇이든 상관없는 상황에서 Unit형을 사용하는 경우가 많습니다.

(또한 예3에서는 Subject를 사용했지만 이 경우는 AsyncSubject가 적합 할지도 모릅니다. AsyncSubject에 대해서는 다음에 설명하겠습니다.)

"OnError" 메시지

OnError 메시지는 이름 그대로 예외가 스트림 도중에 발생했을 때에 통지되는 메시지로 되어 있습니다.

OnError 메시지 스트림 도중에 catch하여 처리하거나 그대로 Subscribe 메서드에 도달시켜 처리 할 수 있습니다. 만약 OnError 메시지가 Subscribe까지 도달 한 경우, 그 스트림 구독은 종료되고 파기 되어 버립니다.

예 4. 도중에 발생한 예외를 Subscribe로 받는다

var stringSubject = new Subject<string>();

// 문자열을 스트림 중간에서 정수로 변환
stringSubject
.Select(str => int.Parse(str)) // 숫자를 표현하는 문자열이 아닌 경우는 예외가 나온다
.Subscribe(
x => Debug.Log("성공:" + x), // OnNext
ex => Debug.Log("예외가 발생:" + ex) // OnError
);

stringSubject.OnNext("1");
stringSubject.OnNext("2");
stringSubject.OnNext("Hello"); // 이 메시지에서 예외가 발생 한다.
stringSubject.OnNext("4");
stringSubject.OnCompleted();

실행결과

성공 : 1
성공 : 2
예외가 발생 : System.FormatException : Input string was not in the correct format

※ Subscribe의 오버로드 중 Subscribe(OnNext, OnError)를 받는 메서드를 이용하고 있습니다

예 4는 OnNext로 보내져 온 문자열을 Select 오퍼레이터(값의 변환)에서 int로 캐스팅해서 표시하는 스트림을 사용한 예입니다.

이와 같이 스트림 중간에 예외가 발생했다면 OnError 메시지가 발행되고 Subscribe에게 통지가 오고 있는 것을 알 수 있습니다.

또한 OnError을 받은 후 OnNext("4")는 처리가 되지 않습니다. 이처럼 "OnError를 Subscribe가 감지하면 스트림 구독을 중단 한다"는 것을 기억하십시오.

예 5. 도중에 예외가 발생하면 다시 구독하기

var stringSubject = new Subject<string>();

// 문자열을 스트림 중간에서 정수로 변환
stringSubject
.Select(str => int.Parse(str))
.OnErrorRetry((FormatException ex) => // 예외의 형식으로 필터링 가능
{
Debug.Log("예외가 발생하여 다시 구독 합니다");
})
.Subscribe(
x => Debug.Log("성공:" + x), // OnNext
ex => Debug.Log("예외가 발생:" + ex) // OnError
);

stringSubject.OnNext("1");
stringSubject.OnNext("2");
stringSubject.OnNext("Hello");
stringSubject.OnNext("4");
stringSubject.OnNext("5");
stringSubject.OnCompleted();

실행결과

성공 : 1
성공 : 2
예외가 발생하여 다시 구독합니다
성공 : 4
성공 : 5

예 5는 도중에 예외가 발생했을 경우 OnErrorRetr로 스트림을 재 구축하고 구독을 계속하고 있습니다.

OnErrorRetry는 OnError가 특정 예외인 경우에 다시 Subscribe를 시도해주는 예외 핸들링 오퍼레이터 입니다. (여기서 말하는 Subscribe를 다시 시작한다는 것은 Subject에 IObserver을 다시 등록하러 간다는 뜻입니다)

하고 싶은 일오퍼레이터비고
OnError가 오면 다시 Subscribe 하고 싶다.Retry
OnError를 받아 에러 처리를 하고 다른 스트림으로 전환한다.Catch
OnError을 받아 에러 처리를 한 후, OnError을 무시하고 OnCompleted로 대체하고 싶다.CatchIgnore
OnError가 오면 에러 처리를 한 후, Subscribe를 다시 하고 싶다. (시간 지정 가능)OnErrorRetry

"OnCompleted" 메시지

OnCompleted는 "스트림이 완료되었기 때문에 이후 메시지를 발행하지 않겠다"라는 것을 통지하는 메시지입니다.

만약 OnCompleted 메시지가 Subscribe까지 도달한 경우 OnError과 마찬가지로 그 스트림의 구독은 종료되고 파기됩니다. 이 성질을 이용하여 스트림에게 OnCompleted를 적절히 발행하여 올리면 정리하여 구독 종료를 실행할 수 있기 때문에 스트림 뒷정리를 할 경우에는 이 메시지를 발행하도록 합시다.

또한, 한번 OnCompleted를 발행한 Subject는 재이용이 불가능합니다. Subscribe 해도 금방 OnCompleted가 돌아오게 됩니다.

예 6. OnCompleted를 감지

var subject = new Subject<int>();
subject.Subscribe(
x => Debug.Log(x),
() => Debug.Log("OnCompleted")
);
subject.OnNext(1);
subject.OnNext(2);
subject.OnCompleted();

실행결과

1
2
OnCompleted

※ Subscribe의 오버로드 중 Subscribe(OnNext, OnCompleted)을 받는 메서드를 이용하고 있습니다.

예 6과 같이 Subscribe에 OnCompleted를 받은 오버로드를 사용하여 OnCompleted를 감지할 수 있습니다.

Subscribe의 오버로드

UniRx 입문 1 에서 Subscribe에는 여러 오버로드가 존재한다고 설명했습니다.

실제로는 다음 조합의 오버로드가 준비되어 있고, 이용하고 싶은 메시지에 맞게 선택하면 좋을 것 같습니다.

  • Subscribe (IObserber observer) ----------- 기본형
  • Subscribe () ------------------------------ 모든 메시지를 무시
  • Subscribe (Action onNext) ---------------- OnNext 만
  • Subscribe (Action onNext, Action onError) ---- OnNext + OnError
  • Subscribe (Action onNext, Action onCompleted) - OnNext + OnCompleted
  • Subscribe (Action onNext, Action onError, Action onCompleted) - 전부

2. 스트림의 구독 종료 (Dispose)

이어서 IObservable의 "IDisposable"를 설명 하겠습니다.

public interface IObservable<T>
{
IDisposable Subscribe(IObserver<T> observer);
}

IDisposable는 C#에서 제공하는 인터페이스이며, "리소스 해제"를 실시할 수 있도록 하기 위한 메서드 "Dispose()"를 단지 1개 가지고 있는 인터페이스 입니다.

Subscribe의 반환값이 IDisposable로 이라는 것은 즉 Subscribe 메서드가 돌려주는 IDisposable의 Dispose를 실행하면 스트림의 구독을 종료 할수 있다는 것이 됩니다.

예 7. Dispose() 스트림의 구독 종료

var subject = new Subject<int>();

// IDispose 저장
var disposable = subject.Subscribe(x => Debug.Log(x), () => Debug.Log("OnCompleted"));

subject.OnNext(1);
subject.OnNext(2);

// 구독종
disposable.Dispose();

subject.OnNext(3);
subject.OnCompleted();

실행결과

1
2

예 7은 구독을 Dispose를 호출하여 도중에 중지하는 예입니다.

따라서 Dispose를 호출하여 구독을 언제라도 중단 할 수 있습니다.

여기서 주의해야 할 것은 Dispose()를 실행해서 구독이 중단되도 OnCompleted가 발행되는 것은 아니다 라는 점입니다. 구독 중단 처리를 OnCompleted에 사용하는 경우, Dispose에서 정지시켜 버리면 실행되지 않으므로 주의하시기 바랍니다.

예 8. 특정 스트림만 수신 거부

var subject = new Subject<int>();

// IDispose 저장
var disposable1 = subject.Subscribe(x => Debug.Log("스트림1:" + x), () => Debug.Log("OnCompleted"));
var disposable2 = subject.Subscribe(x => Debug.Log("스트림2:" + x), () => Debug.Log("OnCompleted"));
subject.OnNext(1);
subject.OnNext(2);

// 스트림1만 구독종료
disposable1.Dispose();

subject.OnNext(3);
subject.OnCompleted();

실행결과

스트림1 : 1
스트림2 : 1
스트림1 : 2
스트림2 : 2
스트림2 : 3
2 : OnCompleted

예 8은 특정 Subscribe의 Dispose를 호출하여 그 구독만 중지시키고 있는 예입니다.

OnCompleted를 실행하면 모든 스트림을 구독 종료하게되지만 Dispose를 사용하면 일부 스트림만 종료시킬 수 있습니다.

3. 스트림의 수명과 Subscribe 종료 타이밍

UniRx를 사용하는데 있어서 특히 조심하지 않으면 안되는 것이 스트림의 라이프 사이클입니다.

객체가 자주 출현과 삭제를 반복하는 Unity에서는, 특히 이것을 의식하지 않으면 퍼포먼스 저하나 에러에 의한 오동작을 일으키게 됩니다.

'스트림'의 실체는 누가 가지고 있는가?

스트림의 수명 관리를 하는데, "그 스트림은 누구의 소요인가?"를 의식할 필요가 있습니다.

기본적으로, 스트림의 실체는 "Subject"이며 Subject가 파기되면 스트림도 파기 됩니다.

이전에도 설명했지만, "Subscribe"란 Subject에 함수를 등록하는 과정이었습니다.즉, 스트림의 실체는 Subject가 내부에 유지하는 "호출 함수 목록( 및 그 함수에 연관된 메소드 체인)"로, Subject가 스트림을 관리하는 것입니다.

Subject가 파기되면 스트림도 모두 파기됩니다. 반대로 말하면, Subject가 남아있는 한 스트림은 계속 실행 된다 인것입니다. 스트림이 참조하고 있는 객체를 스트림보다 먼저 버리고 방치해 버리면 뒤에서 스트림이 계속 동작 상태가 되기 때문에 성능 저하를 일으키거나 메모리 누수가 발생하거나 NullReferenceException을 발생시켜 게임을 정지시킬 가능성도 있습니다.

스트림의 수명 관리는 세심한 주의를 기울여야 됩니다. 사용이 끝나면 반드시 Dispose를 호출하거나 OnCompleted를 발행하는 습관을 들입시다.

예 9. 플레이어의 좌표를 이벤트 알림으로 갱신

요약

액션 게임을 상정하고 이런 경우를 생각해 봅시다.

  • 플레이어를 조작 할 수 있다.
  • 타이머로 시간을 카운트 하고 있다.
  • 타이머가 0이 되었을 때 플레이어를 초기 좌표로 되돌린다.
  • 플레이어는 화면 밖으로 나오면 사망(소멸)한다.

구현

using System;
using System.Collections;
using UniRx;
using UnityEngine;

/// <summary>
/// 카운트 다운하고 그때 값을 통지한다.
/// 3,2,1,0,(OnCompleted) 이런식으로 이벤트가 날라간다.
/// </summary>
public class TimeCounter : MonoBehaviour
{
[SerializeField] private int TimeLeft = 3;

// 타이머 스트림의 실체는 이 Subject
private Subject<int> timerSubject = new Subject<int>();

public IObservable<int> OnTimeChanged => timerSubject;

private void Start()
{
StartCoroutine(TimerCoroutine());

// 현재의 카운트를 표시
timerSubject.Subscribe(x => Debug.Log(x));
}

private IEnumerator TimerCoroutine()
{
yield return null;

var time = TimeLeft;
while (time >= 0)
{
timerSubject.OnNext(time--);
yield return new WaitForSeconds(1);
}
timerSubject.OnCompleted();
}
}
using UniRx;
using UnityEngine;

// 플레이어 이동 처리
// 타이머가 0이되면 초기 좌표로 돌린다.
public class PlayerMover : MonoBehaviour
{
[SerializeField] private TimeCounter _timeCounter;
private float _moveSpeed = 10.0f;

private void Start()
{
// 타이머 구독
_timeCounter.OnTimeChanged
.Where(x => x == 0) // 타이머가 0이 되었을 때만 실행
.Subscribe(_ =>
{
// 타이머가 0이되면 초기 좌표로 돌린다
transform.position = Vector3.zero;
});
}

private void Update()
{
// 오른쪽 화살표를 누르고 있는 동안 이동
if (Input.GetKey(KeyCode.RightArrow))
{
transform.position += new Vector3(1, 0, 0) * _moveSpeed * Time.deltaTime;
}

// 화면 밖으로 나오면 제거
if (transform.position.x > 10)
{
Debug.Log("화면 밖에 나왔다!");
Destroy(gameObject);
}
}
}

실행 결과 (타이머 0에서 플레이어가 생존 한 경우)

타이머 0이 되었을 때 플레이어의 좌표가 제대로 초기 위치로 이동하게 됩니다.

이와 같이 플레이어가 생존한 경우는 이 코드가 제대로 실행됩니다.

실행 결과 (타이머 0에서 플레이어가 제거 된 경우)

타이머 0의 시점에서 플레이어가 제거 된 경우 이 코드에서는 MissingReferenceException 예외가 발생하고 있습니다.

즉, 이 코드는 타이머 0 시점에서 플레이어가 소멸한 경우 이상이 있다는 것을 알 수 있습니다.

원인

원인은 PlayerMover 이 부분에 있습니다.

private void Start()
{
// 타이머 구독
_timeCounter.OnTimeChanged
.Where(x => x == 0) // 타이머가 0이 되었을 때만 실행
.Subscribe(_ =>
{
// 타이머가 0이되면 초기 좌표로 돌린다
transform.position = Vector3.zero;
});
}

아까 말한대로 스트림의 실체는 Subject가 보관 유지하고 있습니다.

PlayerMover가 삭제 된다해도 TimeCounter.timerSubjec가 transform.position = Vector3.zero;를 호출 하게 됩니다.

그리고 Player의 transform에 액세스하려고 하고 transform을 가져오는데 실패 해서 MissingReferenceException 가 발생해 버리는게 예외의 원인입니다.

이와 같이 스트림의 수명과 객체의 수명이 일치하지 않는 경우, 스트림을 제대로 제거 하지 않는 경우 에러의 원인이 되어 버립니다.

대책

대책은 단순히 Player의 GameObject가 파기되면 스트림의 구독을 중지하면 된다 입니다.

구현 방법에는 여러 가지가 있지만, 이번에는 가장 간단한 AddTo의 사용 예제를 살펴 보도록 합니다.

private void Start()
{
// 타이머 구독
_timeCounter.OnTimeChanged
.Where(x => x == 0) // 타이머가 0이 되었을 때만 실행
.Subscribe(_ =>
{
// 타이머가 0이되면 초기 좌표로 돌린다
transform.position = Vector3.zero;
}).AddTo(gameObject); // 지정된 gameObject가 파기되면 Dispose 한다.
}

UniRx에는 AddTo 라는 메서드가 준비되어 있으며, Subscribe의 뒤에 AddTo(gameObject)라고 적음으로써 지정된 GameObject가 Destroy되면 자동으로 Dispose를 호출하도록 설정할 수 있습니다.

이렇게함으로써 Player가 제거되면 동시에 스트림의 구독을 중지하므로 조금 같은 예외가 발생하지 않습니다.

화면을 나가서 객체가 삭제 되어도 예외가 발생하지 않게 되었다.

4.정리

메시지의 종류는 3 종류가 존재한다.

  • OnNext: 보통 이벤트가 발생했을때 알림 메시지
  • OnError: 스트림을 처리하는 동안 예외가 발생한 경우 통지한다.
  • OnCompleted: 스트림이 종료되었음을 알린다.

스트림의 구독을 중단하는 패턴은 3가지가 있다.

  • Subscribe가 OnCompleted를 감지
  • Subscribe가 OnError를 감지
  • Subscribe가 반환한 IDisposable의 Dispose()를 호출

스트림의 수명과 객체의 수명 관계는 항상 의식할 필요가 있다.

  • 객체를 지웠지만, 스트림은 살아 남은 상태는 절대적으로 피해야 한다.
  • 스트림을 사용한 후에 Dispose를 호출하거나 OnCompleted를 발행하는 버릇을 붙이자.

· 약 26분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.9f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/2f1643e344c741dd94f8

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

UniRx 입문 시리즈 목차는 이쪽


서문

나는 과거에 UniRx에 대한 포스트를 산발적으로 쓰고 있었지만, 체계적으로 학습 할 수 있는 형태는 아니었습니다.

그래서 처음부터 읽어 가면서 체계적으로 UniRx를 학습 할 수 있는 자료를 만들려고 본 포스트를 쓰기로 했습니다.

(여유가 있을 때 작성되기 때문에 부정기 연재가 되지 않을까 생각합니다만 작 부탁드립니다)

0. 소개 "UniRx 란?"

UniRx는 neuecc 님이 작성한 Reactive Extensions for Unity 라이브러리 입니다.

Reactive Extensions (이하 Rx)는 요점만 말하면 다음과 같은 라이브러리 입니다.

  • MiscrosoftResearch가 개발하고 있던 C#용 비동기 처리를 위한 라이브러리
  • 디자인 패턴 중 하나인 Observer 패턴을 기반으로 설계되어 있다.
  • 시간에 관련된 처리 및 실행 타이밍이 중요한 곳에서 처리를 쉽게 작성할 수 있도록 되어 있다.
  • 완성도가 높고 Java, JavaScript, Swift 등 다양한 언어로 포팅되어 있다.

UniRx는 Rx를 기반으로 Unity에 이식된 라이브러리이며, 본가 .NET Rx에 비해 다음과 같은 차이가 있습니다.

  • Unity C#에 최적화되어 만들어져있다.
  • Unity 개발에 유용한 기능이나 오퍼레이터가 추가적으로 구현되어 있다.
  • ReactiveProperty 등이 추가되어 있다.
  • 철저하게 성능 튜닝이 진행되어, 원래 .NET Rx보다 메모리 퍼포먼스가 더 좋다.

처음 이 포스트를 읽는 분들에게는 위의 설명으로는 잘 이해되지 않을 것 같기 때문에, 여기는 대략적으로 이런 특징이 있구나 넘기고, 구체적인 설명에 들어가겠습니다.

1. "event와 UniRx"

event

여러분은 C# 표준 기능의 하나인 event를 이용한 적이 있으십니까?

event는 어떤 타이밍에서 메시지를 확인하고 다른 위치에 쓴 처리를 실행시킬 수 있는 기능입니다.

event 예 (event를 발행하는 측)

using System.Collections;
using UnityEngine;

/// <summary>
/// 100에서 카운트 다운 값을 보고하는 샘플
/// </summary>
public class TimeCounter : MonoBehaviour
{
/// <summary>
/// 이벤ㅌ 핸들러 (이벤트 메시지의 형식 정의)
/// </summary>
public delegate void TimerEventHandler(int time);

/// <summary>
/// 이벤트
/// </summary>
public event TimerEventHandler OnTimeChanged;

private void Start()
{
// 타이머 시작
StartCoroutine(TimerCoroutine());
}

private IEnumerator TimerCoroutine()
{
// 100에서 카운트 다운
var time = 100;
while (time > 0)
{
time--;

// 이벤트 알림
OnTimeChanged(time);

// 1초 기다리는
yield return new WaitForSeconds(1);
}
}
}

event 예 (event를받는 측)

using UnityEngine;
using UnityEngine.UI;

public class TimerView : MonoBehaviour
{
// 각 인스턴스는 인스펙터에서 설정
[SerializeField] private TimeCounter timeCounter;
[SerializeField] private Text counterText; // UGUI의 Text

private void Start()
{
// 타이머 카운터가 변화한 이벤트를 받고 UGUI Text를 업데이트
timeCounter.OnTimeChanged += time => // "=>" 는 람다식이라는 익명 함수 표기법
{
// 현재 타이머 값을 UI에 반영
counterText.text = time.ToString();
};
}
}

위의 코드는 타이머 카운트 다운 숫자를 이벤트로서 통지, 이벤트를 받는 측에서 UGUI Text를 갱신하는 샘플 코드 입니다.

UniRx

왜 갑자기 event의 이야기를 했느냐하면 "UniRx는 event의 완전한 상위 호환이며, event에 비해 보다 유연한 기술을 사용할 수 있다" 이기 때문입니다.

앞의 코드는 UniRx를 이용하면 다음과 같이 작성할 수 있습니다.

이벤트 게시자

using System;
using System.Collections;
using UniRx;
using UnityEngine;

/// <summary>
/// 100에서 카운트 다운하고 Debug.Log에 그 값을 표시하는 샘플
/// </summary>
public class TimeCounterRx : MonoBehaviour
{
// 이벤트를 발행하는 핵심 인스턴스
private Subject<int> timerSubject = new Subject<int>();

// 이벤트의 구독자만 공개
public IObservable<int> OnTimeChanged => timerSubject;

private void Start() => StartCoroutine(TimerCoroutine());

private IEnumerator TimerCoroutine()
{
// 100에서 카운트 다운
var time = 100;
while (time > 0)
{
time--;

// 이벤트를 발행
timerSubject.OnNext(time);

// 1초 기다리는
yield return new WaitForSeconds(1);
}
}
}

이벤트 구독자

using UnityEngine;
using UnityEngine.UI;
using UniRx;

public class TimerViewRx : MonoBehaviour
{
// 각 인스턴스는 인스펙터에서 설정
[SerializeField] private TimeCounterRx timeCounter;
[SerializeField] private Text counterText; // UGUI의 Text

private void Start() =>
// 타이머 카운터가 변화한 이벤트를 받고 UGUI Text를 업데이트
timeCounter.OnTimeChanged.Subscribe(time =>
{
// 현재 타이머 값을 ui에 반영
counterText.text = time.ToString();
});
}

위의 event로 구현하고 있던 코드를 UniRx로 변환한 코드 입니다.

event 대신 Subject라는 클래스가 이벤트 핸들러를 등록할 때 Subscribe 하는 방법이 등장하고 있습니다. 즉 UniRx는

Subject가 이벤트 구조의 핵심이 되고, Subject에 값을 전달(OnNext)하고 Subject에 구독 (Subscribe)해서 메시지를 전달 될 수 있는 시스템으로 되어 있다는 것을 알 수 있다고 생각합니다.

그럼 다음 섹션에서 "OnNext와 Subscribe"에 대해 자세히 설명하겠습니다.

2. "OnNext와 Subscribe"

OnNext와 Subscribe는 모두 "Subject에 구현된 메서드"이며, 각각 다음과 같은 동작을 하고 있습니다.

  • Subscribe: 메시지 수신시 실행을 함수에 등록한다.
  • OnNext: Subscribe에 등록 된 함수에 메시지를 전달하고 실행한다.

아래 코드를 참조하십시오.

OnNext와 Subscrbie

private void Start()
{
// Subject 작성
Subject<string> subject = new Subject<string>();

// 3회 Subscrbie
subject.Subscribe(msg => Debug.Log("Subscribe1 : " + msg));
subject.Subscribe(msg => Debug.Log("Subscribe2 : " + msg));
subject.Subscribe(msg => Debug.Log("Subscribe3 : " + msg));

// 이벤트 메시지 발행
subject.OnNext("안녕하세요");
subject.OnNext("안녕");
}

실행결과

Subscribe1 : 안녕하세요
Subscribe2 : 안녕하세요
Subscribe3 : 안녕하세요
Subscribe1 : 안녕
Subscribe2 : 안녕
Subscribe3 : 안녕

이와 같이, "Subscribe는 메시지 수신시의 처리를 등록 처리", "OnNext는 메시지를 발행하고 Subscribe에 등록 된 처리를 순서대로 호출 해 나가는 처리"를 하고 있음을 알 수 있습니다.

이번에는 구체적으로 Subscribe의 행동에 초점을 맞추고, UniRx의 기본적인 작동 원리와 개념을 설명했습니다.

다음 섹션에서는 좀 더 추상화된 이미지인 "IObserver 인터페이스""IObservable 인터페이스"에 대해 설명하겠습니다.

4. "IObserver와 IObservable"

이전에 Subject에 "OnNext"와 "Subscribe"의 2개의 메소드가 구현되어 있다고 설명했습니다.

이것은 사실 대락적인 설명이며, 정확한 설명은 아닙니다.

더 정확히 설명을 하면 "Subject는 IObserver 인터페이스와 IObservable인터페이스 2개를 구현하고 있다"라고 설명 할 수 있습니다.

이 IObserver 인터페이스와 IObservable 인터페이스는 도대체 무엇인가?에 대해 자세히 설명 드리겠습니다.

IObserver 인터페이스

IObserver 인터페이스(이후 IObserver)는 Rx에서 "이벤트 메시지를 발행 할 수 있다"라는 행동을 정의한 인터페이스 입니다. 정의는 다음과 같습니다.

using System;

namespace UniRx
{
public interface IObserver<T>
{
void OnCompleted();
void OnError(Exception error);
void OnNext(T value);
}
}

※ 소스 코드는 여기 에서 인용

IObserver는 보시는 것 처럼, "OnNext", "OnError", "OnCompleted"의 3개의 메서드 정의만 있는 굉장히 간단한 인터페이스로 되어 있습니다. 방금 전까지 이용했던 OnNext 메서드는 사실 이 IObserver에 정의된 메서드였습니다.

OnError은 "발생한 오류(Exception)을 알리는 메시지를 발행하는 메서드"이며, OnCompleted는 "메시지의 발행이 완료되었음을 알리는 메서드" 입니다.

OnError, OnCompleted의 설명은 차후에 하겠습니다. 이번에는 우선 "OnNext", "OnError", "OnCompleted"의 3가지 메서드(메시지)가 준비되어 있다 라는 정도만 기억 하면 될 것 같습니다.

IObservable 인터페이스

IObservable 인페이스(이후 IObservable)는 Rx에서 "이벤트 메시지를 구독 할 수 있다"라는 행동을 정의한 인터페이스 입니다.

using System;

namespace UniRx
{
public interface IObservable<T>
{
IDisposable Subscribe(IObserver<T> observer);
}
}

※ 소스 코드는 여기 에서 인용

이쪽은 더 심플 합니다. Subscribe 메서드가 단지 1개 정의되고 있습니다.

즉, 방금 전에 호출했던 Subscribe는 따지자면 이 IObservable에 정의된 Subscribe를 실행하고 있었다는 것을 기억하세요.

(보충) Subscribe의 생략 호출

자, 여기서 조금 전의 Subject를 이용했을 때의 코드를 봅시다.

subject.Subscribe(msg => Debug.Log("Subscribe1:" + msg));

이 코드에서 위화감을 느낀 분이 있을까요? 맞습니다. IObservable에 정의된 Subscribe는 인수에 IObserver를 받고 있는데, 이 Subscribe를 쓰는 방법에서는 인수에 무명 메서드를 받고 있습니다. 어떻게 이런 일이 가능하게 되었을까요? UniRx에서는 OnNext, OnError, OnCompleted의 3개의 메시지 중에 필요한 메시지만을 이용할 수 있는 Subscribe의 생략 호출용 메서드가 IObservable에 준비되어 있기 때문입니다. (확장 메서드의 실제 정의는 이곳을 참조)

즉, 앞에서의

subject.Subscribe(msg => Debug.Log("Subscribe1:" + msg));

는 Subscribe의 여러 생략 호출 중 하나인 "OnNext만을 이용하는 경우의 생략 호출"을 이용했다는 것에 지나지 않습니다.

실제로 UniRx를 이용할 때는 이 생략 호출을 사용하는 경우가 대부분이며, Subscribe(IObserver<T> observer) 호출은 거의 없습니다.

Subscribe 생략 호출 예

// OnNext만
subject.Subscribe(msg => Debug.Log("Subscribe1:" + msg));

// OnNext & OnError
subject.Subscribe(
msg => Debug.Log("Subscribe1:" + msg),
error => Debug.LogError("Error" + error));

// OnNext & OnCompleted
subject.Subscribe(
msg => Debug.Log("Subscribe1:" + msg),
() => Debug.Log("Completed"));

// OnNext & OnError & OnCompleted
subject.Subscribe(
msg => Debug.Log("Subscribe1:" + msg),
error => Debug.LogError("Error" + error),
() => Debug.Log("Completed"));

Subject의 정의를 확인

여기서 조금 전까지 사용했던 Subject 클래스를 살펴 봅시다.

namespace UniRx
{
public sealed class Subject<T> : ISubject<T>, IDisposable, IOptimizedObservable<T> {/*구현부 생략*/}
}

Subject는 여러 인터페이스로 구현되어 있는 것 같습니다만, 이중 하나인 ISubject<T>의 정의를 살펴 봅시다.

namespace UniRx
{
public interface ISubject<TSource, TResult> : IObserver<TSource>, IObservable<TResult>
{
}

public interface ISubject<T> : ISubject<T, T>, IObserver<T>, IObservable<T>
{
}
}

Subject는 IObservable 및 IObserver 두 가지를 구현하고 있는, 즉 Subject는 "값을 발행한다", "값을 구독 할 수 있다"라는 두가지 기능을 가진 클래스임을 정의에서 확인할 수 있었습니다.

정리

5. "오퍼레이터"

위에서 "Subscribe는 IObservable에 구현되어 있는 것을 (직접/간접) 호출하고 있다"라는 것을 설명 했습니다.

사실 이것은 즉 "IObservable이면 무엇이든 Subscribe 할 수 있다" 라는 뜻입니다. 즉 상대가 Subject 인지 관계 없이 IObservable만 있으면 Subscrbie 할 수 있다는 것입니다.

이 방법을 응용하면 다음과 같은 것을 할 수 있습니다.

고찰: 이벤트 메시지를 필터링 해 본다

예를 들어 다음과 같은 구현이 있다고 하겠습니다.

private void Start()
{
// 문자열을 발행하는 Subject
Subject<string> subject = new Subject<string>();

// 문자열을 콘솔에 출력
subject.Subscribe(x => Debug.Log($"플레이어가 {x}에 충돌했습니다."));

// 이벤트 메시지 발급
// 플레이어가 언급한 개체의 Tag가 발행되었다는 가정
subject.OnNext("Enemy");
subject.OnNext("Wall");
subject.OnNext("Wall");
subject.OnNext("Enemy");
subject.OnNext("Enemy");
}

실행결과

플레이어가 Enemy에 충돌했습니다.
플레이어가 Wall에 충돌했습니다.
플레이어가 Wall에 충돌했습니다.
플레이어가 Enemy에 충돌했습니다.
플레이어가 Enemy에 충돌했습니다.

그런데, 위와 같이 이벤트가 발생되는 Subject가 있을 때 "Enemy에 닿을 때만 출력하고 싶다!"라고 가정 하겠습니다.

이 정도라면 Subscribe에 if문을 쓰는 것으로 구현할 수 있지만, 그러면 이야기 진행이 되지 않기 때문에 다른 방법을 생각해 보기로 하겠습니다.

Subject와 Subscribe 사이에 "필터링 처리"를 끼워 넣을 수 있을까?

아까도 말했지만 값을 발행하는 처리는 IObserver에, 가입 처리는 IObservable에 각각 정의되어 있습니다.

이것은 "이 두가지가 구현된 클래스를 Subject와 Subscribe에 끼우고, 거기에 필터링 처리를 할 수 있을까?" 라는 발상이 생깁니다.

이러한 발상을 기반으로, 대부분의 Subject와 Subscribe 사이에 끼워 넣어 여러가지 메시지를 처리하는 부품을 UniRx에서는 "오퍼레이터"라고 부릅니다.

필터링 운영자 "Where"

UniRx에는 다양한 오퍼레이터가 많이 준비되어 있으며, 대부분의 경우 기존 오퍼레이터를 조합하는 것만으로도 왠만한 동작은 구현가능하게 되어 있습니다. 이번에는 그 중 하나인 메시지를 필터링 하는 "Where"를 사용하여 메시지가 Enemy의 경우에만 필터링 하도록 하겠습니다.

// 문자열을 발행하는 Subject
Subject<string> subject = new Subject<string>();

// Enemy만 필터링
subject
.Where(x => x == "Enemy") // 필터링 오퍼레이터
.Subscribe(x => Debug.Log($"플레이어가 {x}에 충돌했습니다."));

// 이벤트 메시지 발급
// 플레이어가 언급한 개체의 Tag가 발행되었다는 가정
subject.OnNext("Wall");
subject.OnNext("Wall");
subject.OnNext("Enemy");
subject.OnNext("Enemy");

실행결과

플레이어가 Enemy에 충돌했습니다.
플레이어가 Enemy에 충돌했습니다.

이와 같이 Where 오퍼레이터를 IObservable 및 IObserver 사이에 꽃아 주는 것만으로 메시지를 필터링 할 수 있게 되었습니다.

다양한 오퍼레이터

UniRx에는 Where 이외에도 많은 오퍼레이터가 준비되어 있습니다. 그 일부를 소개하겠습니다.

  • 필터링 "Where"
  • 메시지 변환 "Select"
  • 중복을 제거하는 "Distinct"
  • 일정 개수가 올때까지 대기하는 "Buffer"
  • 단시간에 함께 온 경우 처음만 사용하는 "ThrottleFirst"

등을 들 수 있습니다.

위 오퍼레이터는 정말 많은 오퍼레이터 중 극히 일부에 지나지 않습니다.

UniRx가 제공하는 모든 오퍼레이터 목록은 다른 포스트에 정의하고 있기 때문에 그쪽의 페이지를 참조하십시오.

6. "스트림"

그런데, 지금까지 "Subject를 Subscribe한다"와 "Subject에 오퍼레이터를 끼워넣고 Subscribe한다"와 같은 표현을 사용했습니다. 매번 이렇게 말하면 답답하기 때문에, 이것들을 표현하는 단어인 "스트림"을 소개 하겠습니다.

UniRx에서 "스트림"은 "메시지가 발행된 후 Subscribe에 도달 할때까지의 일련의 처리 흐름"을 표현하는 단어 입니다.

"오퍼레이터를 결합하여 스트림을 구축", "Subscribe하여 스트림을 실행한다", "OnCompleted를 발행하여 스트림을 정지시킨다"등으로 사용 됩니다.

앞으로 이 "스트림"이라는 표현은 많이 사용하기 때문에 기억 해 둡시다.

이번 정리

  • UniRx의 핵심은 Subject 클래스
  • Subject를 Subscribe 하는 것이 기초
  • 그러나 실제로 이용할 때는 IObserver와 IObservable 인터페이스를 의식하자.
  • 이벤트의 발행, 구독이 분리되어 있는 것을 활용하여 이벤트 처리를 유연하게 할 수 있도록 한 것이 "오퍼레이터"
  • 오퍼레이터를 연결하여 만든 일련의 이벤트 처리의 흐름을 "스트림"이라고 부른다.

다음에는 이번에 설명하지 않은 "OnError", "OnCompleted", "Dispose"등에 대해 설명 하겠습니다,

보너스: Where 오퍼레이터를 구현한다면?

본문 중에서 "IObserver와 IObservable가 구현된 클래스를 Subject와 Subscribe에 끼우고 거기에 펄터링 처리를 작성하여 필터하고 있다."고 설명하고 실제로 그것을 구현하는 "Where" 오퍼레이터를 소개 하고 있었습니다.

하지만, 정말로 그럴 수 있는 것인가? 실제로 비슷한 행동을 하는 필터링 오퍼레이터를 스스로 정의해 보도록 합시다.

필터링 오퍼레이터 구현

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 필터링 오퍼레이터
/// </summary>
public class MyFilter<T> : IObservable<T>
{
/// <summary>
/// 상류가 되는 Observable
/// </summary>
private IObservable<T> _source;

/// <summary>
/// 판정식
/// </summary>
private Func<T, bool> _conditionalFunc;

public MyFilter(IObservable<T> source, Func<T, bool> conditionalFunc)
{
_source = source;
_conditionalFunc = conditionalFunc;
}

public IDisposable Subscribe(IObserver<T> observer)
{
// Subscribe되면 MyFilterOperator 본체를 만들어 반환한다.
return new MyFilterInternal(this, observer).Run();
}

// Observer로 MyFilterInternal이 실제로 작동하는 곳
private class MyFilterInternal : IObserver<T>
{
private MyFilter<T> _parent;
private IObserver<T> _observer;
private object _lockObject = new object();

public MyFilterInternal(MyFilter<T> parent, IObserver<T> observer)
{
_parent = parent;
_observer = observer;
}

public IDisposable Run()
{
return _parent._source.Subscribe(this);
}

public void OnNext(T value)
{
lock (_lockObject)
{
if (_observer == null)
{
return;
}

try
{
// 같은 경우에만 OnNext를 통과
if (_parent._conditionalFunc(value))
{
_observer.OnNext(value);
}
}
catch (Exception e)
{
// 도중에 에러가 발생하면 에러를 전송
_observer.OnError(e);
_observer = null;
}
}
}

public void OnError(Exception error)
{
lock (_lockObject)
{
// 오류를 전파하고 정지
_observer.OnError(error);
_observer = null;
}
}

public void OnCompleted()
{
lock (_lockObject)
{
// 정지
_observer.OnCompleted();
_observer = null;
}
}
}
}

Dispose() 되었을 때 제대로 멈추는 것을 고려한 예외처리 때문에 상당히 복잡해 졌지만, 가장 중요한 것은 OnNext 중 다음 부분입니다.

// 같은 경우에만 OnNext를 통과
if (_parent._conditionalFunc(value))
{
_observer.OnNext(value);
}

Source로 되어 있는 Observable로 부터 OnNext가 발행되면 그 메시지 값을 평가하고 조건을 충족시키면 자신의 Subscribe에게 그것을 전달해 주는 처리로 되어 있습니다.

역주:

UniRx를 사용하다 보면, lock나 try catch문 등을 심심치 않게 보게 된다. C#에서의 멀티 쓰레드 관련 기술이나, 예외 처리에 대한 부분을 알아두면 사용하는데 도움이 될 것 같습니다.

사용성을 고려하여 확장 메서드 작성

이대로 오퍼레이터를 사용한다면 일일이 인스턴스화 해줘야 되서 사용성이 상당히 나쁘기 때문에 오퍼레이터 체인으로 Filter를 사용할 수 있도록 확장 메서드를 만들었습니다.

using System;

public static class ObservableOperators
{
public static IObservable<T> FilterOperator<T>(this IObservable<T> source, Func<T, bool> conditionalFunc)
=> new MyFilter<T>(source, conditionalFunc);
}

직접 만든 오퍼레이터를 사용한다.

이제 준비한 오퍼레이터를 실제로 사용해 봅시다.

private void Start()
{
// 문자열을 발행하는 Subject
Subject<string> subject = new Subject<string>();

// filter를 끼고 Subscribe 보면
subject
.FilterOperator(x => x == "Enemy")
.Subscribe(x => Debug.Log(string.Format("플레이어가 {0}에 충돌했습니다", x)));

// 이벤트 메시지 발급
// 플레이어가 언급 한 개체의 Tag가 발행되어, 같은 가정
subject.OnNext("Wall");
subject.OnNext("Wall");
subject.OnNext("Enemy");
subject.OnNext("Enemy");
}

실행결과

플레이어가 Enemy에 충돌했습니다
플레이어가 Enemy에 충돌했습니다

예상대로 메시지를 필터링하여 "Enemy"만 통과하게 되었습니다.

이와 같이 "IObserver와 IObservable의 두 가지를 구현한 클래스를 준비하고 사이에 끼워 넣으면 오퍼레이터로서 동작하여 메시지 처리가 가능하다"를 이해하게 되었습니다.

입문 2

· 약 17분
karais89

환경

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

원문 : https://qiita.com/toRisouP/items/3cf1c9be3c37e7609a2f

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

UniRx에 대한 기사 요약은 여기


UniRx에서 "XX를 하고 싶지만 효율적인 방법을 모르겠어요!" 라고 하는 분들을 위해 UniRx에서 사용할 수 있는 오퍼레이터를 정리해 보았습니다.

전제

  • Observable을 Subscribe (또는 Connect) 된 시점에서 생성된다.
  • Observable을 흐르는 메시지는 OnNext, OnError, OnCompleted의 3 종류가 있다.
  • "Observable의 마지막 값"은 "OnCompleted 발행시에 마지막으로 발행 된 OnNext"라는 의미

오퍼레이터 목록

*가 붙은 것은 복수의 용도가 있거나 표현이 다른 오퍼레이터

팩토리 메서드

하고 싶은 일오퍼레이터비고
값을 하나만 발행하고자 할때Observable.Return
값을 반복 실행하고자 할때Observable.Repeat
지정한 범위에서 수치를 발행하고자 할때Observable.Range
Observable 정의를 Subscribe때까지 지연시키고 싶을때Observable.Defer
일정 시간 후에 값을 표시하려고자 할때Observable.Timer *
지정 프레임 후에 값을 표시 하고자 할때Observable.TimerFrame *
일정한 간격으로 값을 표시 하고자 할때Observable.Timer / Observable.Interval Timer와 Interval의 차이는 타이머의 시작 타이밍
일정한 프레임 간격으로 값을 표시하고자 할때Observable.TimerFrame / Observable.IntervalFrame Timer와 Interval의 차이는 타이머의 시작 타이밍
값을 발행하는 Observable을 스스로 원하는대로 만들고 싶을 때Observable.Create
OnError를 즉시 발행하고자 할 때Observable.Throw
OnCompleted를 즉시 발행하고자 할 때Observable.Empty
아무것도 일어나지 않는 Observable을 정의하고자 할 때Observable.Never
C# Event를 Observable로 변환하고자 할 때Observable.FromEvent * / Observable.FromEventPattern
UnityEvent를 Observable로 변환하고자 할 때Observable.FromEvent *
Update를 Observable로 변환하고자 할 때Observable.EveryUpdate *실제로는 MainThreadDispatcher에서 코루틴이 실행됨 / OnCompleted는 발행되지 않으므로 수명 관리에 주의 / 평상시라면 UpdateAsObservable이 더 낫다
FixedUpdate를 Observable로 변환하고자 할 때Observable.FixedEveryUpdate *실제로는 MainThreadDispatcher에서 코루틴이 실행됨 / OnCompleted는 발행되지 않으므로 수명 관리에 주의 / 평상시라면 FixedUpdateAsObservable이 더 낫다

메시지 필터

하고 싶은 일오퍼레이터비고
조건을 충족 시키는 것만 통과 시키고 싶다Where다른 언어에서는 "filter""이라 불린다."
중복된 것을 제외하고 싶다Distinct
값이 변했을 때만 통과 시키고 싶다DistinctUntilChanged
함께 흘러온 OnNext의 마지막만 통과 시키고 싶다Throttle / ThrottleFrame
함께 흘러온 OnNext의 첫번째만 통과 시키고 싶다ThrottleFirst / ThrottleFirstFrame
가장 먼저 도달한 OnNext만 통과시키고 Observable을 완료 시키고 싶다First / FirstOrDefault
OnNext가 2개 이상 발행되면 오류를 발생시키고 싶다Single / SingleOrDefault
Observable의 마지막 값만 통과시키고 싶다Last/LastOrDefault
처음부터 지정한 개수만 통과 시키고 싶다Take
처음부터 조건이 성립되지 않을 때까지 통과 시키고 싶다TakeWhile
처음부터 지정한 Observable에 OnNext가 올때 까지 통과 시키고 싶다TakeUntil
처음부터 지정한 개수만큼 무시하고 싶다Skip
처음부터 조건이 성립되는 동안 무시하고 싶다SkipWhile
처음부터 지정한 Observable에 OnNext가 올때 까지 무시하고 싶다SkipUntil
형태가 일치하는 것만 통과하고 싶다 (형변환도 동시에 하고 싶다)OfType<T>
OnError 또는 OnCompleted만을 통과시키고 싶다IgnoreElements

Observable 자체의 합성

하고 싶은 일오퍼레이터비고
여러 개의 Observable 중 가장 먼저 메시지가 온 Observable을 채택하고 싶다.Amb
복수의 Observable에 각각 1개씩 메시지가 오면 그것들을 합성하고 흐르게 하고 싶다.Zip
복수의 Observable에 각각 1개 이상 메시지가 오면 그것들을 합성하고 흐르게 하고 싶다(각각의 Observable의 최신의 메시지만 보유)ZipLatest
여러 개의 Observable 중 어느 하나에 값이 오면 다른 Observable의 과거 값과 합성해서 흘려보내고 싶다.CombineLatest
2개의 Observable중 한쪽을 주축으로 삼고 한쪽 Observable의 최신 값을 합성하고 싶어WithLatestFrom
복수의 Observable을 1개에 정리하고 싶다Merge
Observable의 OnCompleted 시 다른 Observable을 연결하고 싶다.Concat
Observable 값을 사용하여 별도의 Observable을 만들고 각각의 값을 합성하고자 한다.SelectMany*다른 언어에서는 "flat Map" 이라 불린다.
여러 개의 Observable을 성공할 때까지 차례로 실행하고 싶다.Observable.CatchCatch(IEnumerable<IObservable<T>>)를 사용하면 차례로 성공할 때까지 1개씩 시행한다

Observable 자체 변환

하고 싶은 일오퍼레이터비고
Observable을 Reactive Property로 변환하고 싶다.ToReactiveProperty*
Observable을 Read Only Reactive Property로 변환하고 싶다.ToReadOnlyReactiveProperty
코루틴에서 Observable을 기다리고 싶다.ToYieldInstructionOnCompleted가 올 때까지 코루틴에서 대기

Observable 분기

하고 싶은 일오퍼레이터비고
Observable을 분기하고 싶다.Publish/ToReactivePropety*Publish 반환값은 IConnectabale Observable.Multicast(Subject)와 동의.
Observable을 분기하고, 초기값을 지정하고자 한다.Publish인수를 주면 Multicast(Behavior Subject)와 동의하게 된다.
Observable을 분기하고, 그 때 Observable의 마지막 값만을 캐시 하고 싶다.PublishLastMulticast(Async Subject)와 동일
Observable을 분기하고, 그 때에 지금까지 발행된 모든 OnNext를 받고 싶다.ReplayMulticast(Replay Subject)와 동의.
Observable을 분기할때 Subject를 지정하고자 한다.Multicast
Observer가 1가지라도 있으면 Connect하고 없으면 Dispose 한다RefCountPublish()RefCount()는 거의 일반적으로 사용한다.
Publish().RefCount()를 축약하고 싶다.Share

메시지끼리의 합성 연산

하고 싶은 일오퍼레이터비고
메시지의 값과 이전 결과를 모두 사용 함수를 적용 할ScanLINQ에서 말하는 Aggregate
메시지를 일정 개수마다 정리하고 싶다Buffer *2번째 인수를 지정함으로써 동작이 바뀐다→상세.
어떤 Observable에 메시지가 올 때까지 값을 막아 정리해 두고 싶다.Buffer*인수에 Observable을 건네준다.
직전의 메세지와 세트로 하고 싶다.PairWise"Bufer(2,1)와 비슷하다"

메시지 변환

하고 싶은 일오퍼레이터비고
값을 변화 하고 싶다 / 값에 함수를 적용 하고싶다Select다른 언어에서 "map" 이라 불린다
형식 변환을 하고 싶다Cast <T>
메시지의 값을 바탕으로 다른 Observable를 호출 그쪽 결과를 이용하고 싶다SelectMany *Observable 합성
메시지 이벤트의 메타 정보를 부여 하고싶다MaterializeOnNext / OnError / OnCompleted의 어느 것인가를 나타내는 정보를 부여한다
마지막 메시지 이후의 시간을 부여 하고싶다TimeInterval
메시지에 타임 스탬프를 부여하고 싶다TimeStamp
메시지를 Unit 형식으로 변환하고 싶다AsUnitObservableSelect(_=>Unit.Default) 동일

시간에 얽힌 처리

하고 싶은 일오퍼레이터비고
일정 시간 후에 값을 표시하고싶다.Observable.Timer / Observabe.TimerFrame 인수를 하나만 지정하면 OneShot된다
일정한 간격으로 값을 표시하고싶다.Observable.Timer / Observable.Interval Timer과 Interval의 차이는 타이머의 시작 타이밍
일정한 프레임 간격으로 값을 표시하고싶다.Observabe.TimerFrame / Observable.IntervalFrame Timer과 Interval의 차이는 타이머의 시작 타이밍
메시지를 시간 지연시키고 싶다Delay / DelayFrame
Subscribe 후 일정 시간 이내에 OnNext이 오지 않는 경우 OnError를 발행하고싶다.Timeout
일정시간 이내에 모아서 값이 오면 안정될 때까지 기다렸다가 마지막 값을 흘리고 싶다.Throttle / ThrottleFrame
값이 오면 일정 시간 Observable을 차단하고 싶다.ThrottleFirst / ThrottleFirstFrame
일정한 간격으로 값을 꺼내고 싶다.Sample
다음 프레임으로 처리하고 싶다.Observable.NextFrame

비동기 처리

하고 싶은 일오퍼레이터비고
처리를 다른 스레드에서 수행 하고 싶다Observable.ToAsync / Observable.StartToAsync를 사용한 경우 Invoke를 호출하여 처리가 시작
Observable의 메시지 처리 스레드를 전환하고싶다ObserveOn
Observable의 메시지 처리 스레드를 Unity의 메인 스레드로 전환하고 싶다ObserveOnMainThread다른 스레드에서 Unity 처리를 호출 할 때 필수
Observable 구축의 실행 스레드를 전환하고 싶다SubscribeOn
Observable 결과를 코루틴에서 기다리고 싶다ToYieldInstruction편리해서 기억하고 싶은
비동기 처리를 연쇄시키고 싶다ContinueWithSelectMany의 단발 판

Subscribe On은 "Subscribe 한 순간의 Observable을 구축하는 처리를 어느 스레드 상에서 실행할 것인가"를 지정하는 오퍼레이터입니다.

Subscribe(x=> /*여기 처리*/) 의 실행 스레드를 지정하고자 하는 경우에 사용해야할 오퍼레이터는 Observe On쪽입니다.

오류 처리

하고 싶은 일오퍼레이터비고
OnError이 오면 다시 Subscribe하고 싶다Retry
OnError를 수신 오류 처리가 하고 싶다Catch
OnError를 수신 오류 처리를 한 후 OnError를 묵살하고 OnCompleted으로 대체하고 싶다CatchIgnore
OnError이 오면 오류 처리를 한 후 일정 시간 후 Subscribe 다시 원한다OnErrorRetry

Observable 완료시의 처리

하고 싶은 일오퍼레이터비고
OnCompleted가 오면 다시 Subscribe하고 싶다Repeat조심하지 않으면 무한 루프 발생
OnCompleted가 오면 다시 Subscribe하고자하지만 단기간에 Subscribe이 반복되었을 때는 Repeat를 취소하고 싶다RepeatSafe무한 루프 방지판. 단지 Uncontrollable이기 때문에 추천하지 않는다.
OnCompleted가 오면 다시 Subscribe하고자하지만 지정된 GameObject가 Disable되면 Repeat를 취소하고싶다.RepeatUntilDisableRepeat보다 안전
OnCompleted가 오면 다시 Subscribe하고자하지만 지정된 GameObject가 파기되면 Repeat를 취소하고싶다.RepeatUntilDestoryRepeat보다 안전
OnCompleted 또는 OnError가 왔을 때 처리를하고 싶다Finally

기타

하고 싶은 일오퍼레이터비고
Subscribe시 초기 값을 흐르고 싶다StartWith
메시지의 결과를 T[]변환하려는ToArrayOnCompleted가 오지 않으면 발동하지 않는다.
Observable 결과를 동기화 기다리고 싶다Wait
Observable의 중간 결과를 로그에 내고 싶다Do
Observable 도중에 부작용을 일으키고 싶은Do
Subscribe 된 때 처리를하고 싶다DoOnSubscribe
이 자리에서 메시지를 사용하여 후속으로는 Unit을 흘리고 싶다ForEachAsyncLast + Do + AsUnitObservable에 가까운
메시지 처리에 배타 락을 걸고 싶을Synchronizelock하기위한 개체를 지정할 수도있다

오퍼레이터 이외

Subject 계

하고 싶은 일Subject
절차적으로 Observable을 구축하여 값을 발행하고자 한다.Subject
Subject에 초기값을 갖게 하고 싶다 / Subscribe 시에 직전의 값을 발행하고자 한다.BehaviorSubject
Subject에 과거에 발행 한 모든 값을 캐시하여 Subscribe시 함께 발행하고 싶다ReplaySubject
Subject를 비동기 처리에 사용하고 / 마지막 OnNext 하나만 캐시시켜 발행하고 싶다AsyncSubject
변수에 대해서 Subscribe 하고 싶다.ReactiveProperty

· 약 5분
karais89

환경

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

원문 : https://qiita.com/toRisouP/items/e101cb50835285b0fe6c

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

UniRx에 대한 기사 요약은 여기


nifty의 mBaaS인 니프티 클라우드 mobile backend (NCMB) 로그인 주변의 처리를 Observable로 변환해 UniRx에서 사용할 수 있도록 해 보았습니다.

역주:

일본에서 제공하는 모바일 백엔드 서비스 중에 하나인 것 같다. 관련 API를 UniRx를 사용하여 Observable 화 시켜 사용하기 편하도록 코드를 작성한 것을 소개한다. 실제 동작은 테스트 하지 못하고, 원문 그대로 소스를 가져왔고, 코드를 한번 훑어 보는 용도를 위해 작성 하였습니다.

IObservable 로 변형한 코드

static class로 정의하고 있습니다. 이용시에 using을 선언하십시오. (using ObservableNcmb;)

사용하기 쉽게 회원 등록시에 필드도 갱신 할 수 있는 기능을 추가 했습니다.

using System.Collections.Generic;
using NCMB;
using UniRx;

namespace ObservableNcmb
{
public static class ObservableNcmbUserAuth
{
/// <summary>
/// ID와 비밀번호로 로그인을 실행
/// </summary>
public static IObservable<NCMBUser> LoginAsync(string id, string password)
{
return Observable.Create<NCMBUser>(observer =>
{
NCMBUser.LogInAsync(id, password, e =>
{
if (e == null)
{
observer.OnNext(NCMBUser.CurrentUser);
observer.OnCompleted();
}
else
{
observer.OnError(e);
}
});
return Disposable.Create(() => {; });
});
}

/// <summary>
/// ID와 비밀번호 회원 가입을 실행한다.
/// </summary>
/// <param name="id">ID</param>
/// <param name="password">비밀번호</param>
/// <param name="optionalData">기타필드</param>
/// <returns></returns>
public static IObservable<NCMBUser> SingUpAsync(
string id,
string password,
Dictionary<string, object> optionalData = null)
{
return SingUpAsync(id: id, password: password, optionalData: optionalData, mail: null);
}


/// <summary>
/// ID 및 이메일 주소와 비밀번호 회원 가입을 실행한다.
/// </summary>
/// <param name="mail">이메일주소</param>
/// <param name="id">ID</param>
/// <param name="password">비밀번호</param>
/// <param name="optionalData">기타필드</param>
/// <returns></returns>
public static IObservable<NCMBUser> SingUpAsync(
string mail,
string id,
string password,
Dictionary<string, object> optionalData = null)
{
return Observable.Create<NCMBUser>(observer =>
{
var user = new NCMBUser
{
Email = mail,
UserName = id,
Password = password
};

if (optionalData != null)
{
foreach (var opt in optionalData)
{
user[opt.Key] = opt.Value;
}
}

user.SignUpAsync(e =>
{
if (e == null)
{
observer.OnNext(user);
observer.OnCompleted();
}
else
{
observer.OnError(e);
}
});
return Disposable.Create(() => {; });
});
}

/// <summary>
/// 로그 아웃을 한다
/// </summary>
public static IObservable<Unit> LogoutAsync()
{
return Observable.Create<Unit>(observer =>
{
NCMBUser.LogOutAsync(e =>
{
if (e == null)
{
observer.OnNext(Unit.Default);
observer.OnCompleted();
}
else
{
observer.OnError(e);
}
});
return Disposable.Create(() => {; });
});
}
}
}

역주:

Observable.Create 메서드를 이용하여, 각 API 별로 새로운 스트림을 정의 하여, UniRx로 변형 하고 있다. OnNext, OnCompleted, OnError등을 사용하여 각각의 스트림을 정의한다. 익명 Observable을 만들때 유용하고, 간단한 Observable을 만들려면 이렇게 진행하는 방식이 괜찮은 것 같다. 자세한 내용은 여기를 참조하세요.

사용 예

실제로 지금 개발하고있는 제품에서 코드를 발췌 해 보았습니다.

유저 등록시 플레이어 이름 (ScreenName)도 함께 등록하는 코드입니다.

/// <summary>
/// 유저의 신규 등록을 한다
/// </summary>
public void SignUp(string id, string mail, string pass, string screenName)
{
// 플레이어의 표시 이름을 등록 할 때 설정
// 회원 테이블에 미리 필드를 늘려 둘 필요가 있다
var optionalData = new Dictionary<string, object>()
{
{"screenName",screenName }
};

// 사용자의 신규 등록
ObservableNcmbUserAuth
.SingUpAsync(mail, id, pass, optionalData)
.Subscribe(user =>
{
// NCMBUser 인스턴스를 저장
UserProfileProvider.SetUser(user);
// 로그인 성공 통지
onSuccessLoginSubject.OnNext(Unit.Default);
},
// 오류 발생시 오류 메시지를 통지
ex => failedReasonSubject.OnNext(ConvertExceptionToText((NCMBException)ex)));
}

실행하면 이렇게 기본 정보에 따라 screenName 필드도 설정됩니다

비동기 처리와 콜백 함수를 실행하는 것은 IObservable로 변환 하면, 모두 UniRx로 취급 할 수 있도록 하면 매우 용이 하기 때문에 추천합니다.

· 약 3분
karais89

환경

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

원문 : https://qiita.com/toRisouP/items/1d0682e7a35cdb04bc38

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

UniRx에 대한 기사 요약은 여기


UniRx를 사용해 FPS 카운터를 만들어 보았습니다.

public class FPSCounter : MonoBehaviour
{
[SerializeField] private int bufferSize = 5; // 버퍼 사이즈
public ReadOnlyReactiveProperty<float> FpsReactiveProperty;

private void Awake()
{
FpsReactiveProperty = this.UpdateAsObservable()
.Select(_ => Time.deltaTime) // Time.deltaTime로 변환
.Buffer(bufferSize, 1) // 지난 bufferSize 분 버퍼
.Select(x => 1.0f / x.Average()) // 평균에서 fps 산출
.ToReadOnlyReactiveProperty();

FpsReactiveProperty.Subscribe(x => Debug.Log(x));
}
}

설명

위의 코드는 과거 BufferSize 분의 Time.deltaTime의 평균치를 가지고 거기에서 FPS를 산출하고 있습니다.

그리고 스트림을 ReadOnlyReactiveProperty로 변화하고 public으로 공개하고 있습니다.

주의해야 할 점으로는 FpsReactiveProperty 초기화 타이밍입니다.

FpsReactiveProperty 초기화가 끝나기 전에 다른 컴포넌트가 FpsReactiveProperty를 Subscribe 해 버리면 NullReferenceException이 발생해 버리므로 주의 합시다.

역주:

ReactiveProperty는 자체적으로 Hot한 성질을 가지고 있기 때문에, FpsReactiveProperty를 사용하는 어느 곳이든 같은 스트림을 사용하게 된다.

추기

Static Property로 처리하는 것이 좋겠다는 이야기를 @neuecc씨한테 받아서 그쪽 버전으로 만들어 보았습니다.

public static class FPSCounter
{
private const int BufferSize = 5; // 샘플 수를 바꾸려면 여기를 바꾼다.
public static IReadOnlyReactiveProperty<float> Current { get; private set; }

static FPSCounter() =>
Current = Observable.EveryUpdate()
.Select(_ => Time.deltaTime)
.Buffer(BufferSize, 1)
.Select(x => 1.0f / x.Average())
.ToReadOnlyReactiveProperty();
}

이 코드의 장점은 MonoBehaviour와 무관한 위치에서 언제든지 호출하여 fps를 확인 할 수 있다는 점입니다.

FPSCounter.Current.Subscribe(fps => Debug.Log(fps));

· 약 6분
karais89

환경

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

원문 : https://qiita.com/toRisouP/items/4fec0e9716be4d415798

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

UniRx에 대한 기사 요약은 여기


UniRx를 사용하여 마우스를 롱클릭을 판정하는 스트림을 만들어 보았습니다.

  • 롱클릭 판정 (일정 시간 이상 마우스가 클릭된 상태로 계속 될 때)
  • 롱클릭 판정 취소 (일정 시간 내에 마우스 클릭이 중단되었을 때)

를 각각 검출 할 수 있는 스트림을 오퍼레이터 체인만 쓰는 패턴과 코루틴과 결합하는 패턴 2종류를 만들어 보았습니다.

오퍼레이터 체인만 사용하는 경우

private void Start()
{
var mouseDownStream = this.UpdateAsObservable()
.Where(_ => Input.GetMouseButtonDown(0));
var mouseUpStream = this.UpdateAsObservable()
.Where(_ => Input.GetMouseButtonUp(0));

// 롱클릭 판정
mouseDownStream
// 마우스 클릭되면 3초 후 OnNext를 흐르게 한다.
.SelectMany(_ => Observable.Timer(TimeSpan.FromSeconds(3)))
// 도중에 MouseUp되면 스트림을 재설정 한다.
.TakeUntil(mouseUpStream)
.RepeatUntilDestroy(gameObject)
.Subscribe(_ => Debug.Log("롱클릭"));

// 롱클릭 취소 판정
mouseDownStream.Timestamp()
.Zip(mouseUpStream.Timestamp(), (d, u) => (u.Timestamp - d.Timestamp).TotalMilliseconds / 1000.0f)
.Where(time => time < 3.0f)
.Subscribe(t => Debug.Log(t + "초에서 취소"));
}

동작을 파악 하는 것이 조금 복잡하지만, 일단은 작동 합니다.

역주:

아래 오퍼레이터의 동작을 알고 있어야 정확한 이해가 가능 합니다.

  • SelectMany
    • 스트림 자체를 다른 스트림으로 대체해 버린다.
    • 위 예에서는 mouseDownStream 스트림 자체를 Observable.Timer로 대체 한다고 생각하면 된다. (마우스 클릭 스트림이 발행된 이후 3초 후에 대한 스트림을 새로 만듬)
  • TakeUntil
    • 스트림의 메시지가 오면 OnCompleted를 통해 스트림을 종료시킨다.
    • 위 예에서는 mouseUpStream이 발행되면 OnCompleted가 발행되고, Repeat가 실행되면서 다시 Subscribe 하게 되는 구조 (Repeat란?)
  • Timestamp
    • 스트림이 발행한 시간을 타임스탬프 값으로 반환한다.
  • Zip
    • 두개의 오퍼레이터가 모두 실행될때에 대한 처리를 하나로 묶어 처리해준다.

코루틴과 조합하는 경우

public class LongClick : MonoBehaviour
{
#region 롱클릭 스트림
private Subject<Unit> _longClickSubject;

private IObservable<Unit> LongClickAsObservable =>
_longClickSubject ?? (_longClickSubject = new Subject<Unit>());
#endregion

#region 롱클릭 취소 스트림
private Subject<float> _longClickCancelSubject;

private IObservable<float> LongClickCancelAsObservable =>
_longClickCancelSubject ?? (_longClickCancelSubject = new Subject<float>());
#endregion

private Coroutine _longClickCoroutine;

private void Start()
{
_longClickCoroutine = StartCoroutine(LongClickCoroutine(3.0f, _longClickSubject, _longClickCancelSubject));

LongClickAsObservable.Subscribe(_ => Debug.Log("롱클릭"));
LongClickCancelAsObservable.Subscribe(t => Debug.Log(t + "초 취소"));
}

/// <summary>
/// 롱클릭 판정 코루틴
/// </summary>
private IEnumerator LongClickCoroutine(float threshold,
IObserver<Unit> longClickObserver,
IObserver<float> longClickCancelObserver)
{
var isLongClicked = false;
var count = 0.0f;

while (true)
{
if (Input.GetMouseButton(0))
{
count += Time.deltaTime;
if (!isLongClicked && count > threshold)
{
isLongClicked = true;
longClickObserver.OnNext(Unit.Default);
}
}
else if (Input.GetMouseButtonUp(0))
{
isLongClicked = false;
if (count > 0)
{
longClickCancelObserver.OnNext(count);
count = 0;
}
}
yield return null;
}
}

private void OnDestroy()
{
if (_longClickCoroutine != null)
StopCoroutine(_longClickCoroutine);
}
}

판정 처리를 무한 루프하는 코루틴안에 쓰고, 결과를 스트림으로 외부에 통지하는 형태로 구현하였습니다.

오퍼레이터 체인을 사용하여 흐름을 파악하기 힘든 스트림이 된다면, 포기하고 절차적으로 쓰는 것이 더 좋을 수 있습니다. 잡다한 처리는 전부 코루틴에 장착되어 사용하는 측에서는 알 수 없기 때문에 허용 범위 일수도 있을까 싶습니다.

또한 이러한 범용적으로 사용할 것 같은 처리는 ObservableTrigger로 정의해두면 좋을지도 모르겠습니다.

역주:

Observable.FromCoroutine 을 사용하는 방안을 생각해 보았는데, 해당 메서드는 Observer을 1개 밖에 사용하지 못하는 방식이라, 코루틴 2개(판정, 취소)를 각각 만들어줘야 되는 방식입니다. 위 예처럼 2개 이상의 Observer을 사용하는 경우는 Subject를 만들고 해당 Subject의 OnNext를 호출하는 방식이 더 나은 방법이라고 판판이 되네요.