블로그

  • UniRx 입문 4 – Update를 스트림으로 변환하는 방법 및 장점

    UniRx 입문 시리즈 목차는 이쪽


    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

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

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

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

    (Destroy시 OnCompleted가 발행된다)

    구조​

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

    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);

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

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

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

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

  • UniRx 입문 3 – 스트림 소스를 만드는 방법

    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

    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를 받고 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(t));
        }
    
        // <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 카운터와 같은 어떤 씬에서도 계속 같은 스트림을 구축해야 될때 사용하면 좋습니다.

    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% 정도는 어떻게든 된다고 생각합니다.

  • UniRx 입문 2 – 메시지의 종류/스트림의 수명

    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를 발행하는 버릇을 붙이자.