블로그

  • MV(R)P 설계에 대한 생각


    환경

    UniRx 입문 시리즈 목차는 이쪽

    MVP 패턴이란?

    MVP 패턴에 대한 자료를 찾아보면, 정확한 정의는 없고, 대략적인 공통점들은 있다.

    기본적인 기조는 같지만, 세부 적인 구현 내용은 개발자의 역량에 달린 것으로 보인다.

    대략적으로 MVP 패턴은 아래 특징을 가지고 있다.

    Model – View – Presneter

    Model​

    • Data와 관련된 모든 처리를 담당한다. 비즈니스 로직 처리.
      • 비즈니스 로직은 컴퓨터 프로그램에서 실세계의 규칙에 따라 데이터를 생성·표시·저장·변경하는 부분을 일컫는다.

    View​

    • 사용자에게 보여지는 UI 부분 (유니티에서는 모든 렌더링 되는 Object)

    Presenter​

    • View에서 요청한 정보(User actions)로 Model을 가공하여(Update model) 변경된 Model정보를 받아(Model changed) View에게 전달(Update UI)해주는 부분
    • 접착제 역할

    관계도​

    특징​

    • View와 Model은 서로를 알지 못한다. (어떤 방법으로든 접근할 수 없다)
    • Presenter은 View와 Model을 알고 있다.

    여기서 알고 있다는 부분에 대한 해석으로 좀 헤매였던 부분이 있었는데, 알고 있다는 부분은 해당 인스턴스를 직접적으로 조작한다로 해석해도 무방할것으로 보인다.

    → 직접 조작하지만 않으면 알고 있지 않은 것 (이벤트 방식, SendMessage 등)

    MV(R)P​

    UniRx 플러그인을 사용하면 유니티에서 MVP 패턴을 좀 더 쉽게 구현할 수 있다. (MVP 패턴에서 구현해야 되는 이벤트 기반 코드들을 더 쉽게 사용) 2020년 10월 9일 기준 v7.10(19년 7월 1일) 까지 나와 있는 상태이고, 원래는 Reactive Programming을 유니티에서 쉽게 사용 하기 위해 만들어진 플러그인이다.

    UniRx를 사용하면 MVP (MVRP) 패턴을 구현할 수 있습니다.

    MVVM 대신 MVP를 사용해야하는 이유?

    유니티는 UI 바인딩을 제공하지 않으며, 바인딩 레이어를 만드는 것은 복잡하며, 오버헤드가 크다.

    MVP 패턴을 사용하는 Presenter는 View의 구성요소를 알고 있으며 업데이트 할 수 있다. 실제 바인딩을 하지 않지만, View를 구독(Observable)하여 바인딩 하는 것과 유사하게 동작하게 할 수 있다. (복잡하지 않고, 오버 헤드도 적게 사용 가능)

    이 패턴을 Reactive Presenter라고 한다.

    // Presenter는 씬의 canvas 루트에 존재.
    public class ReactivePresenter : MonoBehaviour
    {
        // Presenter는 View를 알고 있다(인스펙터를 통해 바인딩 한다)
        public Button MyButton;
        public Toggle MyToggle;
        
        // Model의 변화는 ReactiveProperty를 통해 알 수 있다.
        Enemy enemy = new Enemy(1000);
    
        void Start()
        {
            // Rx는 View와 Model의 사용자 이벤트를 제공한다.
            MyButton.OnClickAsObservable().Subscribe(_ => enemy.CurrentHp.Value -= 99);
            MyToggle.OnValueChangedAsObservable().SubscribeToInteractable(MyButton);
    
            // Model들은 Rx를 통해 Presenter에게 자신의 변화를 알리고, Presenter은 Viw를 업데이트 한다.
            enemy.CurrentHp.SubscribeToText(MyText);
            enemy.IsDead.Where(isDead => isDead == true)
                .Subscribe(_ =>
                {
                    MyToggle.interactable = MyButton.interactable = false;
                });
        }
    }
    
    // Model. 모든 프로퍼티는 값의 변경을 알려 준다. (ReactiveProperty)
    public class Enemy
    {
        public ReactiveProperty<long> CurrentHp { get; private set; }
    
        public ReactiveProperty<bool> IsDead { get; private set; }
    
        public Enemy(int initialHp)
        {
            // 프로퍼티 정의
            CurrentHp = new ReactiveProperty<long>(initialHp);
            IsDead = CurrentHp.Select(x => x <= 0).ToReactiveProperty();
        }
    }

    View는 하나의 Scene이며, Unity의 hierarchy이다. (하나의 개체 혹은 객체?)

    View는 초기화시 Unity 엔진에 의해 Presenter와 연결된다.

    XxxAsObservable 메서드를 사용하면 오버 헤드없이 이벤트 신호를 간단하게 생성 할 수 있습니다. SubscribeToText 및 SubscribeToInteractable은 간단한 바인딩 처럼 사용할 수 있게 하는 helper 클래스 입니다. 이것은 단순한 도구 일 수 있지만 매우 강력합니다. Unity 환경에서 자연스럽게 느껴지며 고성능과 깨끗한 아키텍처를 제공합니다.

    MV(R)P
    • V-> RP-> M-> RP-> V가 완전히 Reactive(반응적인)한 방법으로 연결되었다.
    • GUI 프로그래밍은 ObservableTrigger의 이점도 제공합니다. ObservableTrigger는 Unity 이벤트를 Observable로 변환하므로이를 사용하여 MV(R)P 패턴을 구성 할 수 있습니다. 예를 들어 ObservableEventTrigger는 uGUI 이벤트를 Observable로 변환합니다.
    var eventTrigger = this.gameObject.AddComponent<ObservableEventTrigger>();
    eventTrigger.OnBeginDragAsObservable()
        .SelectMany(_ => eventTrigger.OnDragAsObservable(), (start, current) => UniRx.Tuple.Create(start, current))
        .TakeUntil(eventTrigger.OnEndDragAsObservable())
        .RepeatUntilDestroy(this)
        .Subscribe(x => Debug.Log(x));

    설계 방향​

    • MVP 패턴을 보면서 헷갈리거나 정립되지 않는 부분은 과감히 내 방식으로 정립하고, 구현 후 문제점 발생시 개선하는 방향으로 진행.
    • 완전히 디자인 패턴을 따르지는 않을 예정 (클린 코드가 되는 대신 생산성이 저하되는 부분은 과감히 생산성을 따르는 방향)
    • 하나의 Presenter에 여러개의 Model이 존재할 수 있다.
      • 각 모델의 경우 역할별로 클래스화 작업.
    • 하나의 Presenter에 여러개의 View가 존재할 수 있다.
    • Presenter는 각 팝업, 각 오브젝트 별로 존재한다. (컴포넌트 개념으로 생각)
      • 팝업의 아이템이 존재한다면 그 아이템도 각각의 Presenter가 존재. 구조가 복잡하지 않는다면 없어도 무방.
    • 간단한 예제에서는 항상 View-Presenter-Model은 1개씩 존재 했기 때문에, 각 Presetenr 1개에 2개이상의 view와 model이 존재해도 문제 없는지에 대한 고민을 함.
    • 그리고 Model의 구현시 거의 모든 역할을 Model에서 한다고 생각하면 될것으로 보임 (Presenter는 Model의 메서드를 호출하는 정도의 역할)
      • 보통의 예제에서는 간단한 메서드 구현 정도는 Presenter에서 해주는 부분도 있지만, Model이 전부 해주는게 더 일반적인 구조인것으로 보임.

    ​

  • UniRx의 용도

    환경

    UniRx 입문 시리즈 목차는 이쪽

    현대에서 UniRx를 어떤 경에서 사용하는지 소개합니다. 일부는 예전에는 UniRx를 사용하기도 했지만, 현대에서는 다른 방법으로 쓰는 것이 좋다는 것도 있습니다.

    A. 이벤트 통지에 사용​

    이벤트란, “무언가의 조건을 만족했을 때, 그 때의 정보를 통지해 다른 장소에서 처리를 실행한다”라고 하는 구조를 가리킵니다. 이벤트를 이용하면 “조건을 판정하는 부분”과 “실제로 처리를 실시하는 부분”을 분리해 구현할 수 있게 됩니다.

    이벤트의 사용법으로서는 다음의 패턴을 생각할 수 있습니다.

    • 비 정기적으로 여러 번 반복하는 처리를 다루기 쉽다.
    • 실제의 처리 부분이 복수 있을 때, 그 조건 판정의 부분을 한 곳에서 처리한다
    • 컴포넌트간의 종속성 구성

    “비 정기적으로 여러 번 반복하는 처리를 다루기 쉽다”는 Unity에서는 OnTriggerEnter등의 이벤트를 예로 들 수 있습니다. “언제 일어날지 모르지만, 발생했을 때는 즉시 대응한 처리를 실행하고 싶다”라고 하는 때에 이벤트의 개념을 사용할 수 있습니다.

    “실제의 처리 부분이 복수 있을 때, 그 조건 판정의 부분을 한 곳에서 처리한다”는, 예를 들면 “Input”을 들 수 있습니다. 게임을 하고 있는 사람(플레이어)으로부터의 Input을 받아들이고 캐릭터를 조작하게 됩니다. 이 때 아무것도 생각하지 않고 어리석게 구현하면 다양한 컴포넌트와 if(Input.GetKeyDown("Attack"))같은 처리가 흩어져 버립니다. 이러한 문제도 이벤트 개념을 사용하여 스마트하게 구현할 수 있습니다.

    UniRx와 이벤트​

    우선 “Input을 판정해 그것을 Observable로 변환하는 컴퍼넌트”를 생각합니다.

    using UniRx;
    using UnityEngine;
    
    namespace Events
    {
        public sealed class InputEventProvider : MonoBehaviour
        {
    /// <summary>
    /// 공격 버튼 입력
    /// </summary>
            public IReadOnlyReactiveProperty<bool> Attack => _attack;
    
    /// <summary>
    /// 이동 방향 입력
    /// </summary>
            public IReadOnlyReactiveProperty<Vector3> MoveDirection => _moveDirection;
    
    /// <summary>
    /// 점프 입력
    /// </summary>
            public IReadOnlyReactiveProperty<bool> Jump => _jump;
    
    // 구현
            private readonly ReactiveProperty<bool> _attack = new BoolReactiveProperty();
            private readonly ReactiveProperty<bool> _jump = new BoolReactiveProperty();
            private readonly ReactiveProperty<Vector3> _moveDirection = new ReactiveProperty<Vector3>();
    
            private void Start()
            {
    // Destroy시 Dispose()
                _attack.AddTo(this);
                _jump.AddTo(this);
                _moveDirection.AddTo(this);
            }
    
            private void Update()
            {
    // 다양한 입력을 ReactiveProperty에 반영
                _jump.Value = Input.GetButton("Jump");
                _attack.Value = Input.GetButton("Attack");
                _moveDirection.Value = new Vector3(
                    x:Input.GetAxis("Horizontal"),
                    y:0,
                    z:Input.GetAxis("Vertical"));
            }
        }
    }
    

    이 컴퍼넌트를 준비하면, 실제로 입력 이벤트를 사용해 처리를 실시하는 컴퍼넌트로부터 이것을 참조시킵니다. 이제 “UniRx를 사용하여 입력 이벤트를 처리”할 수있었습니다.

    using System;
    using UniRx;
    using UnityEngine;
    
    namespace Events
    {
    /// <summary>
    /// 예: Input을 보고 이동
    /// </summary>
        public class PlayerMove : MonoBehaviour
        {
            [SerializeField] private float _moveSpeed = 1.0f;
            [SerializeField] private InputEventProvider _inputEventProvider;
    
            private CharacterController _characterController;
    
            private void Start()
            {
                _characterController = GetComponent<CharacterController>();
    
    // 점프
    // 점프 버튼 입력 이벤트 결정
                _inputEventProvider.Jump
    // 버튼을 눌렀을 때,
                    .Where(x => x)
    // 접지 중이며,
                    .Where(_ => _characterController.isGrounded)
    // 마지막으로 점프한 지 1초 이상 경과하면,
                    .ThrottleFirst(TimeSpan.FromSeconds(1))
                    .Subscribe(_ =>
                    {
    // 점프 처리 수행
                        Jump();
                    });
    
    // 이동 처리
                _inputEventProvider
                    .MoveDirection
    // 일정값 이상 입력하면
                    .Where(x=>x.magnitude > 0.5f)
                    .Subscribe(x =>
                    {
    // 그쪽으로 이동
                        _characterController.Move(x.normalized * _moveSpeed);
                    });
            }
    
            private void Jump()
            {
    // 점프 처리(생략)
            }
        }
    }
    

    “Pub/Sub” 개념​

    이벤트 처리의 일종으로서, “Pub/Sub”라고 하는 것이 있습니다.

    일반적인 이벤트 처리에 있어서는, 일반적으로는 “누구로부터 메세지가 보내 오는지”를 구독측이 어느 정도는 의식할 필요가 있습니다. 그것을 Pub/Sub 있어서는 “이벤트 메세지 그 자체”에 주목해, “누구로부터 보내져 왔는지는 신경쓰지 않는다”라고 하는 모델이 되고 있습니다. (같이 송신측도 “누가 수신하고 있는지는 신경쓰지 않는다”라고 하는 형태가 됩니다)

    Pub/Sub를 사용하면 송신자와 수신자를 더 느슨하게 결합할 수 있습니다. 컴퍼넌트간의 참조 관계나 의존관계를 정리해 휘두르는 일 없이, 보다 데이터 플로우를 중심으로 한 구현을 실시하는 것이 가능해집니다. 그러나 다른 한편으로는 올바르게 메시지를 관리 할 수 ​​없으면 스파게티 코드가 가속된다는 문제점도 Pub/Sub있습니다. 초보자에게 추천할 수 있는 기능은 아닙니다만, 이런 것도 있으면 머리의 한쪽 구석에서 두면 어느 것이 도움이 될 때가 올 것입니다.

    이제 이것 Pub/Sub이지만 구현하는 방법에는 여러 가지가 있습니다.

    • UniRx의 “MessageBroker”라는 기능 사용
    • MessagePipe 라는 라이브러리 사용

    Pub/Sub을 가볍게 시도하고 싶다면 UniRx MessageBroker를 쉽게 사용할 수 추천합니다. 거기에서 한층 더 밟아, “DI와 조합해 사용하고 싶다” “서버 통신을 얽힌 Pub/Sub것을 실시하고 싶다”라고 하는 경우는 MessagePipe를 사용해 보면 좋을 것입니다.

    B. 비동기 처리에 사용​

    앞에서 결론에서 언급하면 ​​비동기 처리에 UniRx를 사용하는 것은 더 이상 권장 하지 않습니다. 과거 2017년 이전의 Unity에서는 비동기 처리의 선택사항으로서 괜찮은 것이 UniRx 정도밖에 없었습니다. 그러나 현대에서는 async/await나 UniTask의 등장에 의해, 굳이 UniRx를 사용해 비동기 처리를 취급할 필요성이 없어졌습니다.

    (원래 「비동기 처리」란 무엇을 가리키는지입니다만, 이쪽은 말하면 길어지기 때문에 다른 기사에서 다시 투고 예정입니다)

    추가​

    “비동기 처리에 UniRx를 사용하는 것은 비추천”이지만 일부 Operator가 매우 편리합니다. 특히 OnErrorRetry는 에러 발생시 지정 횟수까지 재 시도를 해주는 것입니다. 이런 Operator를 async/await 함께 try-catch 사용하면 상당히 복잡해지기 때문에, async/await와 함께 Observable을 같이 사용하는 것도 어려울 수 있지만 존재합니다.

    단, 어리석게 ToObservable().OnErrorRetry()만 해도 잘 움직이지 않고, Observable.Defer()와 병용할 필요가 있거나 합니다. 이 함수들은 상당히 까다롭기 때문에, 왜 Observable.Defer() 필요한지 모르는 사람은, 이 테크닉은 사용하지 않는 편이 안전할지도 모릅니다.

    private async UniTaskVoid SampleAsync(string uri, CancellationToken token)
    {
        var result =
        // Observable.Defer로 감싸서 Retry 발화시 GetAsync를 다시 실행하도록 합니다.
        await Observable.Defer(() => GetAsync(uri, token).ToObservable())
        // 에러 발생시 1초 기다린 후 총 3회까지 시도
        .OnErrorRetry((UnityWebRequestException ex) => Debug.LogException(ex), retryCount: 3, TimeSpan.FromSeconds(1));
    
        Debug.Log(result);
    }
    
    private async UniTask<string> GetAsync(string uri, CancellationToken token)
    {
        using (var uwr = UnityWebRequest.Get(uri))
        {
            await uwr.SendWebRequest().WithCancellation(token);
            return uwr.downloadHandler.text;
        }
    }

    예: AsyncOperation​

    Unity에서 등장하는 비동기 처리로 취급할 필요가 있는 객체 중:AsyncOperation가 있습니다. 이것은 UnityAPI에서 비동기 처리를 호출할 때 반환되는 객체입니다.

    【AsyncOperation한 객체를 돌려주는 Unity의 API의 예】

    • UnityWebRequest.SendWebRequest()
    • SceneManager.LoadSceneAsync()
    • AssetBundle.LoadAssetAsync

    AsyncOperation는 본래라면 코루틴으로 처리하는 객체입니다만, UniTask를 도입하고 있는 경우는 async/await로 기술하는 것이 가능합니다.

    // 텍스처 다운로드
    public async UniTask<Texture> FetchTextureAsync(string uri, CancellationToken token)
    {
        using (var uwr = UnityWebRequestTexture.GetTexture(uri))
        {
            await uwr.SendWebRequest().WithCancellation(token);
            return ((DownloadHandlerTexture) uwr.downloadHandler).texture;
        }
    }

    (이하 비추천)

    한때, UniRx 정도 괜찮은 비동기 처리의 핸들링 방법이 없었던 시대는 다음과 같은 쓰기를 하고 있었습니다. async/await와 비교해 보면, UniRx 쪽이 압도적으로 중복코드가 많고 복잡합니다.

    /// <summary>
        /// 텍스처 다운로드 Observable
        /// </summary>
        public IObservable<Texture> FetchTextureObservable(string uri)
        {
            // 코루틴을 Observable로 변환
            return Observable.FromCoroutine<Texture>(observer =>
            FetchTextureCoroutine(uri, observer));
        }
    
        /// <summary>
        /// 통신하는 코루틴
        /// </summary>
        private IEnumerator FetchTextureCoroutine(
            string uri,
            IObserver<Texture> observer)
        {
            using (var uwr = UnityWebRequestTexture.GetTexture(uri))
            {
                yield return uwr.SendWebRequest();
    
            if (uwr.result != UnityWebRequest.Result.Success)
            {
                observer.OnError(new Exception(uwr.error));
            }
            else
            {
                var result = ((DownloadHandlerTexture) uwr.downloadHandler).texture;
                observer.OnNext(result);
                observer.OnCompleted();
            }
        }
    }

    C.Model-View-(Reactive)Presenter 패턴에 사용​

    Model-View-(Reactive)Presenter, 통칭 MV(R)P패턴은 Unity에서 주로 UI 구현에서 사용되는 경우가 많은 패턴입니다. View와 Model 2개의 오브젝트를 UniRx를 이용해 연결하는 구현 패턴이 됩니다.

    D.MonoBehaviour의 로직 작성​

    UniRx에 존재하는 UpdateAsObservable()와 Observable.EveryUpdate() 애용하고 있는 분은 많을 것입니다. 여기에 대해서는 UniRx를 그대로 사용해도 되지만, 경우에 따라서는 async/await쪽이 간단하게 구현 가능한 경우도 있다고 기억해 두면 좋을 것입니다.

    간단한 로직을 UniRx로 작성​

    예로서 다음 로직을 생각해 봅시다.

    조건이 충족되면 처리를 실행한 후 몇 초 동안 쿨타임에 들어갑니다. (공격을 내면 그 후 몇 초간은 다시 공격을 할 수 없다, 같다)

    이것을 UniRx로 작성하면 다음과 같습니다.

    using System;
    using UniRx;
    using UniRx.Triggers;
    using UnityEngine;
    
    namespace Samples
    {
        public sealed class SimpleLogicUniRx : MonoBehaviour
        {
            private void Start()
            {
                // 매 프레임 실행
                this.UpdateAsObservable()
                    // 공격 버튼을 누르면
                    .Where(_ => Input.GetButtonDown("Attack"))
                    // 처리를 1회 실행시킨 후, 1초간 쿨타임
                    .ThrottleFirst(TimeSpan.FromSeconds(1))
                    .Subscribe(_ => Action())
                    .AddTo(this);
            }
    
            /// <summary>
            /// 정기적으로 실행되는 처리
            /// </summary>
            private void Action()
            {
                // 공격
                Debug.Log("Action!");
            }
        }
    }

    이러한 「오퍼레이터를 조합하는 것만으로 구현할 수 있는 처리」에 대해서는 UniRx를 사용해 써도 문제 없습니다.

    UniRx로 작성하기 어려운 경우​

    UniRx의 장점은 「풍부한 오퍼레이터를 사용할 수 있다」입니다만, 반대로 단점으로서 오퍼레이터의 범위 외의 처리는 굉장히 구현하기 어려워 집니다.

    조금 전의 예의 「조건을 만족하면 뭔가 처리를 실행해 그 후 몇 초간 쿨타임에 들어간다」를 조금 확장해, 다음과 같은 처리를 생각해 봅시다.

    • 조건 A를 만족하면 처리 X를 호출하고 N 초 동안 쿨타임에 들어갑니다.
    • 조건 B를 만족하면 처리 Y를 호출하고 M 초 동안 쿨타임에 들어갑니다.
    • 처리 X와 Y는 각각 배타적이며, 서로의 쿨타임 중에는 서로의 처리가 차단된다.

    알기 쉽게 바꿔 말한다면, “강 공격을 내면 쿨타임이 길다. 약공격을 내면 쿨타임이 짧다. 쿨타임 중에는 공격을 일절 할 수 없다”같은 패턴입니다.

    자, 이것을 UniRx만으로 작성하려고하면 어떻게 될까요? 오퍼레이터의 단순한 연결만으로는 구현할 수 없고, 상당히 얽힌 코드로 구현 코드가 나올 것 같습니다. 이러한 도중에 조건 분기가 들어가거나 조건에 따라 처리 내용이 크게 바뀌는 것을 UniRx는 매우 구현하기 힘듭니다.

    이러한 경우는 UniRx를 사용하지 않고 async/await(또는 코루틴)으로 써 버리는 쪽이 결과적으로 깨끗하게 구현할 수 있습니다.

    using System.Collections;
    using UnityEngine;
    
    namespace Samples
    {
        public sealed class ComplexLogicCoroutine : MonoBehaviour
        {
            private void Start()
            {
                StartCoroutine(LogicLoop());
            }
    
            private IEnumerator LogicLoop()
            {
                // Destroy 될 때까지 무한 루프
                while (true)
                {
                    if (Input.GetButtonDown("AttackA"))
                    {
                        // 입력 A가 실행되면 처리 X를 호출하여 1초 대기
                        ActionX();
                        yield return new WaitForSeconds(1);
                    }
                    else if (Input.GetButtonDown("AttackB"))
                    {
                        // 입력 B가 실행되면 처리 Y를 호출하여 2초 대기
                        ActionY();
                        yield return new WaitForSeconds(2);
                    }
                    else
                    {
                        // 입력이 없으면 1프레임 대기
                        yield return null;
                    }
                }
            }
    
            private void ActionX()
            {
                Debug.Log("do X!");
            }
    
            private void ActionY()
            {
                Debug.Log("do Y!");
            }
        }
    }
    using System;
    using System.Threading;
    using Cysharp.Threading.Tasks;
    using UnityEngine;
    
    namespace Samples
    {
        public sealed class ComplexLogicUniTask : MonoBehaviour
        {
            private void Start()
            {
    // CancellationToken생성
                var token = this.GetCancellationTokenOnDestroy();
    
    // 루프 시작
                LogicLoopAsync(token).Forget();
            }
    
            private async UniTaskVoid LogicLoopAsync(CancellationToken ct)
            {
    // Destroy 될 때까지 무한 루프
                while (!ct.IsCancellationRequested)
                {
                    if (Input.GetButtonDown("AttackA"))
                    {
    // 입력 A가 실행되면 처리 X를 호출하여 1초 대기
                        ActionX();
                        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
                    }
                    else if (Input.GetButtonDown("AttackB"))
                    {
    // 입력 B가 실행되면 처리 Y를 호출하여 2초 대기
                        ActionY();
                        await UniTask.Delay(TimeSpan.FromSeconds(2), cancellationToken: ct);
                    }
                    else
                    {
    // 입력이 없으면 1프레임 대기
                        await UniTask.Yield();
                    }
                }
            }
    
            private void ActionX()
            {
                Debug.Log("do X!");
            }
    
            private void ActionY()
            {
                Debug.Log("do Y!");
            }
        }
    }
    

    역주

    • GetCancellationTokenOnDestroy은 MonoBehaviour의 라이프 사이클과 동일하게 CancellationToken이 생성된다.

    MonoBehaviour의 로직 작성 요약​

    • Update() FixedUpdate()에 관련된 로직은 UniRx로 기술할 수 있다
    • 다만 UniRx를 사용하는 경우는 기존의 오퍼레이터로 구현할 수 있는 범위 내에 두어 둔다
    • 조금이라도 UniRx로 쓸 수 없다고 느끼면 곧바로 포기하고 코루틴이나 async/await에 다시 쓰는 편이 최종적으로 읽기 쉬워진다

    E. Update()와 같은 범위 분리​

    UniRx의 사용법으로서 “Update()등의 스코프를 분리한다”가 있습니다.

    • Update()와 FixedUpdate() 등의 처리를 문맥 별로 분리한다
    • Update()나 FixedUpdate() 실행 시작 타이밍 조정

    이 경우 UniRx를 사용할 수 있습니다.

    예: Update()를 문맥별로 분리​

    예를 들면 다음과 같이, Update()에 복수의 처리를 하는 코드가 있었다고 합니다.

    using UnityEngine;
    
    namespace Samples
    {
        public sealed class SamplePlayer : MonoBehaviour
        {
    // 여기에 많은 필드 변수가 정의
            private void Update()
            {
    // 공격 처리에 따른 처리
                CheckAttack();
    
    // Player의 위치에 따른 처리
                CheckPosition();
    
    // Player의 체력에 따른 처리
                CheckHealth();
    
    // 움직임 처리
                Move();
            }
    
    // ↓ 메서드가 줄지어 정의되어있다가 가정한다.
        }
    }
    

    그런데, 이 Update()문을 보고 느끼는 것은 없을까요. “이 처리의 순서는 바꿔도 문제 없을까?”, “어떤 처리와 어떤 처리는 연관된 처리가 있는가?” 라는 의문이 나올 수 있습니다.

    이러한 처리 순서에 따라 제대로 동작하지 않는 메서드가 있을 수 있고, 처리 순서가 무방한 메서드가 있을 수 있습니다. 이러한 부분이 주석에 써 있을지도 모르고, 전혀 주석이 없는 코드일지도 모릅니다. Update()라는 하나의 메소드에 나란히 쓰고 있는 이상은, 항상 이러한 것을 의식해 코드를 작성/읽어야 합니다.

    이러한 문제는 UniRx를 사용하면 다소 개선할 수 있습니다.

    using System;
    using UniRx;
    using UniRx.Triggers;
    using UnityEngine;
    
    namespace Samples
    {
        public sealed class SamplePlayer : MonoBehaviour
        {
            private void Start()
            {
    // Player의 위치에 따른 처
                this.UpdateAsObservable()
                    .Subscribe(_ => CheckPosition())
                    .AddTo(this);
    
    // Player의 체력에 따른 처리
                this.UpdateAsObservable()
                    .Subscribe(_ => CheckHealth())
                    .AddTo(this);
    
    // 공격 처리 확인 및 이동 처리
                this.UpdateAsObservable()
                    .Subscribe(_ =>
                    {
                        CheckAttack();
                        Move();
                    })
                    .AddTo(this);
            }
    
    // ↓ 메서드가 줄지어 있다고 가정한다.
    
    /*
            * 생략
            */
        }
    }
    

    이와 같이, 각 처리마다 따로 따로 Observable 저장하는 것으로 처리 끼리의 스코프를 명확하게 구분 할 수 있었습니다. 또 처리마다 Observable가 분리되어 있기 때문에, 일부 처리만 오퍼레이터를 추가하거나 조정도 쉬워집니다.

    예: 일부 처리만 실행 시작 타이밍을 어긋나기​

    Update()를 각 처리마다 Observable 나누어 버리는 이점으로서, “실행 개시 타이밍을 조정하기 쉽다”라고 하는 것이 있습니다.

    예를 들어 특정의 메소드가 불려 갈 때까지는 Update()의 처리의 일부를 스킵 해 두고 싶다고 하는 경우입니다. (적 캐릭터가 화면에 비칠 때까지 이동 처리를 멈추고 싶은 등)

    using UniRx;
    using UniRx.Triggers;
    using UnityEngine;
    
    namespace Samples
    {
        public sealed class SampleEnemy : MonoBehaviour
        {
            private void Start()
            {
    // 화면에 비치면 초기화 처리를 호출합니다.
                this.OnBecameVisibleAsObservable()
                    .Take(1)
                    .Subscribe(_ => Initialize())
                    .AddTo(this);
            }
    
    /// <summary>
    /// 초기화 처리
    /// </summary>
            private void Initialize()
            {
    // 매 프레임 Move()를 호출하도록 설정
                this.UpdateAsObservable()
                    .Subscribe(_ => Move())
                    .AddTo(this);
            }
    
    /// <summary>
    /// 이동 처리
    /// </summary>
            private void Move()
            {
    // 이동 처리
            }
        }
    }
    

    Update()와 같은 범위를 분리합니다.​

    이것은 UniRx가 처리를 모두 Observable라고 하는 객체에 감싸는 성질을 이용한, 약간의 테크닉입니다. 기억해두면 사용하기 편리하기 때문에 개인적으로는 가끔 사용하는 기술 이기도 합니다.

    덧붙여 이 기술을 async/await + UniTask로 사용하는 것도 가능합니다. 다만 UniRx와 비교해 이쪽은 기술량이 증가해 코드가 다소 복잡해 집니다. 그 때문에 이 용도에 있어서는 UniRx의 사용이 더 편리한 것으로 보입니다.

    private void Start()
    {
        var token = this.GetCancellationTokenOnDestroy();
    
        UniTask.Void(async () =>
        {
            while (!token.IsCancellationRequested)
            {
                CheckPosition();
                await UniTask.Yield();
            }
        });
    
        UniTask.Void(async () =>
        {
            while (!token.IsCancellationRequested)
            {
                CheckHealth();
                await UniTask.Yield();
            }
        });
    
        UniTask.Void(async () =>
        {
            while (!token.IsCancellationRequested)
            {
                CheckAttack();
                Move();
                await UniTask.Yield();
            }
        });
    }
    

    F. 종속성을 정리하는 데 사용​

    UniRx는 종속성을 구성하는 데 사용할 수 있습니다. (UniRx의 기능보다는 Observer 패턴의 성질 그 자체)

    방금 설명한 「이벤트 처리」와 같은 이야기입니다. (시점이 의존 관계로 정리가 되어 있을 뿐, 하고 있는 것은 이벤트 처리 그 자체)

    예: Player 및 PlayerManager​

    예를 들어, 다음과 같은 경우를 생각해 봅시다.

    • PlayerManager: Player 생성 및 수명주기 관리
    • Player: 자신이 죽은 것을 PlayerManager 알려준다.

    자, 이것을 생각 없이 그대로 구현하면 다음과 같은 코드가 될 것입니다.

    using UnityEngine;
    
    namespace Samples2
    {
        public class PlayerManager : MonoBehaviour
        {
    // Player의 Prefab
            [SerializeField] private Player _playerPrefab;
    
    // 지금 존재하는 플레이어의 실체
            private Player _currentPlayer;
    
            private void Start()
            {
                CreatePlayer();
            }
    
            public void OnPlayerDead()
            {
    // 플레이어가 죽었을 때의 처리가 여기에
                _currentPlayer = null;
    
    // 새로운 플레이어 생성
                CreatePlayer();
            }
    
            private void CreatePlayer()
            {
    // 플레이어 생성
                _currentPlayer = Instantiate(_playerPrefab);
    
    // Player에게 Manager를 가르치기
                _currentPlayer.Initialize(this);
            }
        }
    }
    
    using UnityEngine;
    
    namespace Samples2
    {
        public class Player : MonoBehaviour
        {
            private PlayerManager _playerManager;
    
            public void Initialize(PlayerManager playerManager)
            {
    // 초기화 시 Manager 유지
                _playerManager = playerManager;
            }
    
            private void OnDestroy()
            {
    // 이번에는 OnDestroy되면 '사망'이라는 취급으로
                _playerManager.OnPlayerDead();
            }
        }
    }
    

    이 코드에는 하나의 큰 문제가 있습니다. 그것은 Player와 PlayerManager가 상호 참조하는 것입니다.

    상호 참조는 나중에 스파게티 코드로 발전 할 위험이 매우 높습니다. 따라서 가능한 한 상호 참조를 제거해야합니다.

    그래서 이것을 UniRx (또는 UniTask)를 사용하여 정리해 보겠습니다.

    UniRx를 사용하여 종속성 구성​

    UniRx Observable를 이용하면 참조 관계를 일방통행으로 정리할 수 있습니다.

    using UniRx;
    using UnityEngine;
    
    namespace Samples2
    {
        public class PlayerManager : MonoBehaviour
        {
    // Player의Prefab
            [SerializeField] private Player _playerPrefab;
    
    // 지금 존재하는 플레이어의 실체
            private Player _currentPlayer;
    
            private void Start()
            {
                CreatePlayer();
            }
    
            private void OnPlayerDead()
            {
    // 플레이어가 죽었을 때의 처리가 여기에
                _currentPlayer = null;
    
    // 새로운 플레이어 생성
                CreatePlayer();
            }
    
            private void CreatePlayer()
            {
    // 플레이어 생성
                _currentPlayer = Instantiate(_playerPrefab);
    
    // 생성된 플레이어를 모니터링하고
    // 사망 이벤트가 오면 OnPlayerDead 실행
                _currentPlayer
                    .PlayerDeadAsync
                    .Subscribe(_ => OnPlayerDead())
                    .AddTo(this);
            }
        }
    }
    
    using System;
    using UniRx;
    using UnityEngine;
    
    namespace Samples2
    {
        public class Player : MonoBehaviour
        {
    /// <summary>
    /// 플레이어가 사망한 통지를 발행하는 Observable
    /// </summary>
            public IObservable<Unit> PlayerDeadAsync => _playerDeadSubject;
    
    // 1회만 통지를 발행하는 경우에는 AsyncSubject가 편리
            private readonly AsyncSubject<Unit> _playerDeadSubject = new AsyncSubject<Unit>();
    
            private void OnDestroy()
            {
    // 플레이어가 사망한 통지 발행
                _playerDeadSubject.OnNext(Unit.Default);
                _playerDeadSubject.OnCompleted();
    
                _playerDeadSubject.Dispose();
            }
        }
    }
    

    UniRx Observable를 사용하여 알림 흐름을 정리하고 참조 관계를 일방통행으로 만들 수 있습니다. 원래 Observable는 “불특정 다수에게 자신의 상태를 감시하게 한다”라는 용도를 위한 기능입니다. 그러므로 이러한 “뭔가 상태가 변화했을 때 그것을 상대에게 전달한다”는 경우에서는 Observable 강력하게 작용합니다.

    응용 프로그램 : 상태 알림​

    Observable의 “불특정 다수에게 자신의 상태를 감시시킨다”라고 하는 용도를 좀 더 생각해 보겠습니다.

    예를 들어 액션 게임에서 “뭔가 이벤트가 일어났을 때 그에 따라 복수의 처리를 동시에 실행시킨다”라는 패턴은 자주 존재합니다. 이러한 처리를 구현하는 경우, 이벤트 발행측이 통지처를 모두 파악하는 코드는 매우 파악하기 어렵게 됩니다.

    예로서 다음과 같은 구현이 있다고 합니다.

    • 플레이어가 데미지를 받으면 다음 처리가 실행됩니다.
      • 체력 감소
      • 넉백 효과
      • 데미지 애니메이션 재생
      • 파티클 이펙트가 나온다
      • 효과음 재생
      • 디버깅 할 때만 UI에 숫자를 표시하고 싶습니다.

    이것을 “데미지를 받았을 때에 하나하나 통지처의 메소드를 호출해 간다”라고 하는 구현으로 하면 이렇게 됩니다.

    이 구현의 약점은 “PlayerCore 모두를 관리하지 않으면 안된다”라고 하는 점에 있습니다. 통지처를 모든 PlayerCore 것이 알고 있는 상태로 해, 게다가 상황에 따라서 통지한다/안한다 판단을 PlayerCore하지 않으면 안됩니다. 이에 대한 PlayerCore 책임이 커지고, 점점 복잡한 코드로 성장해 버립니다.


    이러한 문제는 Observable를 사용하여 해결할 수 있습니다.

    PlayerCore에 Observable 정의하고 각 컴포넌트가 필요에 따라 Subscribe 하는 형식으로 해 줍니다. 이렇게 하면 PlayerCore 책임이 크게 줄어들고 “이벤트 알림을 처리하는 방법”의 책임이 각 구성 요소에 분산됩니다.

    UniTask를 사용하여 종속성 구성​

    UniTask 및 async/await를 사용하여 종속성을 구성할 수 있습니다. 단, UniRx와 달리 UniTask는 “이벤트 통지 횟수가 1회에 한정한다” 경우에만 이용 가능합니다.

    using Cysharp.Threading.Tasks;
    using UnityEngine;
    
    namespace Samples2
    {
        public class Player : MonoBehaviour
        {
            /// <summary>
            /// 플레이어가 사망하면 완료되는 UniTask
            /// </summary>
            public UniTask PlayerDeadAsync => _playerDeadUtc.Task;
    
            private readonly UniTaskCompletionSource _playerDeadUtc = new UniTaskCompletionSource();
    
            private void OnDestroy()
            {
                // 플레이어가 사망하면 UniTask 완료
                _playerDeadUtc.TrySetResult();
            }
        }
    }
    using System.Threading;
    using Cysharp.Threading.Tasks;
    using UnityEngine;
    
    namespace Samples2
    {
        public class PlayerManager : MonoBehaviour
        {
            // Player의Prefab
            [SerializeField] private Player _playerPrefab;
    
            // 지금 존재하는 플레이어의 실체
            private Player _currentPlayer;
    
            private void Start()
            {
                SetupPlayerAsync(this.GetCancellationTokenOnDestroy()).Forget();
            }
    
            private void OnPlayerDead()
            {
                // 플레이어가 죽었을 때의 처리가 여기에
                _currentPlayer = null;
    
                // 새로운 플레이어 생성
                SetupPlayerAsync(this.GetCancellationTokenOnDestroy()).Forget();
            }
    
            private async UniTaskVoid SetupPlayerAsync(CancellationToken token)
            {
                // 플레이어 생성
                _currentPlayer = Instantiate(_playerPrefab);
    
                // 생성된 플레이어를 모니터링하고
                // 사망하면 OnPlayerDead 실행
                await _currentPlayer.PlayerDeadAsync;
                token.ThrowIfCancellationRequested();
    
                OnPlayerDead();
            }
        }
    }

    종속성을 정리하는 데 사용되는 요약​

    UniRx를 사용하면 종속성을 정리하고 이벤트 흐름을 개선할 수 있습니다. 또 통지 회수가 1회에 한한 경우에 있어서는 “UniRx”나 “UniTask+async/await”의 어느 쪽에서도 기술하는 것도 가능합니다.

    덧붙여 개인적으로는, 통지 회수가 1회만이라면 UniTask+async/await, 몇번이나 통지한다면 Observable로 구분해서 사용 하고 있습니다. Observable라고 하는 형태만을 보았을 때에, “이것은 Observable 몇번 이벤트를 발행하는 것일까”를 모릅니다. 그렇다면 “UniTask 그렇다면 절대 많아도 1회 밖에 발행되지 않는다”, “Observable 그렇다면 복수회 발행될 것이다” 라고 나누는 것이 편하기 때문입니다. (상대측의 구현 내용을 상상해 쓰는, 같은 불필요한 일 하지 않아도 된다)

    G. 스크립트 실행 순서를 조정하는 데 사용​

    Unity에는 Script Execution Order 라는 스크립트 실행 순서를 조정하는 메커니즘이 있습니다. Script Execution Order은 마지막 보루로 남겨 두어야 할 정도로 안이하게 만져서는 안되는 기능입니다.

    그렇다면 실행 순서를 관리하고 싶을 때 어떻게해야할까요? UniRx를 사용하여 이벤트에서 작동하도록 해 버리는 것입니다. 즉, “전의 컴퍼넌트가 끝나면 다음의 컴퍼넌트가 연쇄해 실행된다”라고 하는 구조로 합니다. 이렇게 하면 컴포넌트 간의 호출 순서 Update()나 FixedUpdate() 생각하지 않아도 됩니다.

    예 : UniRx로 구성 요소 간의 타이밍 조정​

    예를 들어, 다음과 같은 구현을 생각해 보겠습니다.

    • PlayerInput에서 버튼 입력을 수락
    • 버튼 입력을 확인하고 PlayerMoveController 점프 처리를 수행합니다.
    • 점프 상태가 되면 PlayerAnimation 점프 애니메이션 재생

    “각 컴퍼넌트가 Update()를 실행해, 조건을 만족하면 각 처리를 실행한다” 라고 하는 어리석은 구현도 가능합니다. 하지만 이 방법의 경우, 실행 순서가 어긋나서 처리가 1프레임 늦어지거나, 상태 체크를 흘리는 등의 문제가 일어날 수 있습니다.

    그 때문에 그러한 것을 Update() 사용하는 코드를 멈추고 UniRx를 이용해 처리가 연쇄하는 구현 으로 해 보겠습니다.

    using UniRx;
    using UnityEngine;
    
    namespace Samples3
    {
    /// <summary>
    /// 입력 이벤트 관리
    /// </summary>
        public sealed class PlayerInput : MonoBehaviour
        {
    /// <summary>
    /// 점프 버튼의 입력 상태를 나타내는 ReactiveProperty
    /// </summary>
            public IReadOnlyReactiveProperty<bool> JumpButton => _jumpButton;
    
            private readonly ReactiveProperty<bool> _jumpButton = new ReactiveProperty<bool>(false);
    
    /*
            * 그 밖에도 여러 가지 이벤트가 줄지어 있지만 생략
            */
    
            private void Start()
            {
    // Destroy 시에 Dispose 되도록(듯이) 한다
                _jumpButton.AddTo(this);
            }
    
            private void Update()
            {
    // 버튼 상태를 반영하여 알림
                _jumpButton.Value = Input.GetButton("Jump");
            }
        }
    }
    
    using System;
    using UniRx;
    using UniRx.Triggers;
    using UnityEngine;
    
    namespace Samples3
    {
        public sealed class PlayerMoveController : MonoBehaviour
        {
    /// <summary>
    /// 접지 되었습니까?
    /// </summary>
            public IReadOnlyReactiveProperty<bool> IsGrounded => _isGrounded;
    
            private readonly ReactiveProperty<bool> _isGrounded = new ReactiveProperty<bool>(false);
    
    /// <summary>
    /// 점프 이벤트
    /// </summary>
            public IObservable<Unit> OnJump => _jumpSubject;
    
            private readonly Subject<Unit> _jumpSubject = new Subject<Unit>();
    
            [SerializeField] private LayerMask _groundLayerMask;
            [SerializeField] private PlayerInput _playerInput;
            private Rigidbody _rigidbody;
    
            private void Start()
            {
                _isGrounded.AddTo(this);
                _jumpSubject.AddTo(this);
                _rigidbody = GetComponent<Rigidbody>();
    
    // 접지 상태 확인
                this.FixedUpdateAsObservable()
                    .Subscribe(_ =>
                    {
    // 레이를 발밑으로 날리다
                        _isGrounded.Value = Physics.SphereCast(origin: transform.position + Vector3.up * 0.04f,
                        radius: 0.02f,
                        direction: Vector3.down, hitInfo: out var _, maxDistance: 0.05f, _groundLayerMask);
                    })
                    .AddTo(this);
    
    
    // 입력 이벤트 처리
                _playerInput.JumpButton
    // 점프 버튼을 누른 순간에 접지하면 실행
                    .Where(x => x && _isGrounded.Value)
    // Input 이벤트는 Update() 타이밍이므로,
    // 다음 FixedUpdate 타이밍으로 타이밍 조정
                    .ObserveOnMainThread(MainThreadDispatchType.FixedUpdate)
                    .Subscribe(_ =>
                    {
    // 점프실행
                        Jump();
                    })
                    .AddTo(this);
            }
    
            private void Jump()
            {
                _rigidbody.AddForce(Vector3.up * 10.0f, ForceMode.VelocityChange);
    
    // 점프 이벤트 발행
                _jumpSubject.OnNext(Unit.Default);
            }
        }
    }
    
    using UniRx;
    using UnityEngine;
    
    namespace Samples3
    {
        public sealed class PlayerAnimation : MonoBehaviour
        {
            [SerializeField] private Animator _animator;
            [SerializeField] private PlayerMoveController _moveController;
    
    /// <summary>
    /// 점프 애니메이션 재생
    /// </summary>
            private bool IsJumping
            {
                set => _animator.SetBool("Jumping", value);
            }
    
            private void Start()
            {
    // 점프 이벤트가 오면 점프 애니메이션 시작
                _moveController.OnJump.Subscribe(_ => IsJumping = true).AddTo(this);
    
    // 접지하면 점프 애니메이션 해제
                _moveController.IsGrounded.Where(x => x).Subscribe(_ => IsJumping = false).AddTo(this);
            }
        }
    }
    

    UniRx를 사용하면 3개의 컴포넌트가 충돌 없이 순서대로 동작시킬 수 있게 되었습니다. 이와 같이 각 컴퍼넌트로부터 Update()와 FixedUpdate()를 최대한 배제하는 것으로, 동작 순서를 완전하게 제어할 수 있게 됩니다. (정확히 반응)

    역주

    • ObserveOnMainThread(MainThreadDispatchType.FixedUpdate)을 사용하여 FixedUpdate에서 점프 처리를 실행하도록 처리하였다.

    예: 초기화 순서 조정(async/await)​

    실행 순서의 조정 Update()와 FixedUpdate() 어떠한 것에 한정되지 않고, 초기화(Start()나 Awake())의 순서도 중요하게 되는 일도 있습니다. 이러한 상황에서도 UniRx를 사용할 수 있습니다. 그렇지만, “초기화”라고 하는 기본적으로 1회 밖에 실행되지 않는 처리에 대해서는 “async/await+UniTask”로 쓰는 편이 깨끗해집니다.

    using System.Linq;
    using System.Threading;
    using Cysharp.Threading.Tasks;
    using UnityEngine;
    
    namespace Samples3
    {
        public sealed class EnemyManager : MonoBehaviour
        {
            [SerializeField] private Enemy _enemyPrefab;
    
            /// <summary>
            /// 적을 복수체 생성하여 초기화
            /// </summary>
            public async UniTask InitializeAsync(CancellationToken token)
            {
                // 10개 생성하고 모든 초기화가 끝날 때까지 기다린다
                // (UniTask.WhenAll을 암시 적으로 호출 중)
                await Enumerable.Range(0, 10).Select(x => CreateEnemyAsync(token));
            }
    
            /// <summary>
            /// 적을 생성한다
            /// </summary>
            private async UniTask CreateEnemyAsync(CancellationToken token)
            {
                var enemy = Instantiate(_enemyPrefab);
                // 초기화가 끝날 때까지 기다리다
                await enemy.InitializedAsync;
                // 초기화 중에 취소되면 중지
                token.ThrowIfCancellationRequested();
            }
        }
    }
    using System.Threading;
    using Cysharp.Threading.Tasks;
    using UnityEngine;
    using UnityEngine.AddressableAssets;
    using UnityEngine.ResourceManagement.AsyncOperations;
    
    namespace Samples3
    {
        /// <summary>
        /// 적
        /// </summary>
        public sealed class Enemy : MonoBehaviour
        {
            /// <summary>
            /// 초기화가 완료되었는지 여부
            /// </summary>
            public UniTask InitializedAsync => _initialTaskCompletionSource.Task;
            private readonly UniTaskCompletionSource _initialTaskCompletionSource = new UniTaskCompletionSource();
    
            private AsyncOperationHandle<GameObject> _operationHandle;
    
            /// <summary>
            /// 이 적 객체의 자식 요소 (비동기로 초기화됨)
            /// </summary>
            private GameObject _child;
    
            /// <summary>
            /// Start에서 초기화 처리가 실행됨
            /// </summary>
            private void Start()
            {
                var ct = this.GetCancellationTokenOnDestroy();
                InitializeAsync(ct).Forget();
            }
    
            /// <summary>
            /// 초기화 처리
            /// </summary>
            private async UniTaskVoid InitializeAsync(CancellationToken token)
            {
                _operationHandle = Addressables.InstantiateAsync("Child");
                await _operationHandle;
                _child = _operationHandle.Result;
            }
    
            private void OnDestroy()
            {
                Addressables.Release(_operationHandle);
    
                // Destroy가 먼저 실행되 취소합니다.
                _initialTaskCompletionSource.TrySetCanceled();
            }
        }
    }

    H. 컬렉션의 변동을 통지​

    컬렉션 Array과 Dictionary같은 여러 데이터를 처리하는 객체를 가리킵니다.

    UniRx로 컬렉션 변동 모니터링​

    ReactiveCollectionUniRx 에는 약간 ReactiveDictionary의 객체가 있습니다.

    • ReactiveCollection
    • ReactiveDictionary

    이러한 객체를 이용하는 것으로, “배열이나 맵의 내용이 변동했다”라는 것을 즉시 감지해 이벤트 처리를 실행하는 것이 가능해집니다.

    보충:ObservableCollections​

    UniRx ReactiveCollection에 가까운 기능을 제공하는 라이브러리 ObservableCollections가 있습니다.

    이곳은 UniRx에 의존하지 않고, 다양한 컬렉션의 감시를 할 수 있게 되는 라이브러리가 되고 있습니다. (UniRx에는 존재하지 않는, ObservableHashSet, ObservableRingBuffer 있습니다)

    “UniRx를 도입하고 싶지 않다” “UniRx가 제공하는 ReactiveCollection 것보다 더 심도 있는 처리를 하고 싶다”라고 하는 경우는 이쪽의 도입도 검토하면 좋을 것입니다.

    I. (이하, 생각하면 가필합니다)​

    요약​

    • “UniRx 전용”해서 사용해야 되는 시대는 사라졌습니다.
      • 현대는 UniRx 이상에 편리한 라이브러리와 언어 기능이 갖추어져 있습니다.
      • 특히 async/await+UniTask는 매우 강력하며 UniRx 대신 사용할 수 있는 패턴이 많습니다.
    • 그러나 한편으로 UniRx의 용도가 완전히 없어진 것은 아니다.
      • 단순한 Observer 패턴의 구현 라이브러리로 사용해도 편리
      • ReactiveProperty는 특히 편리성이 높고 현역에서 사용할 수 있습니다.
    • UniRx를 너무 과신하지 말자
      • 사용 장소의 판별은 중요
      • async/await+UniTask쪽이 깨끗하게 쓸 수 있는 패턴도 있다
      • (async/await 모른다면 코루틴을 사용할 수 있습니다)
  • Rx로 만든 카운트 다운 타이머

    환경

    UniRx 입문 시리즈 목차는 이쪽

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

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

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

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

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

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

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

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

    장점

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

    단점

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

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

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

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

    구현 목록

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

    RxCountDownTimer.cs

    /// <summary>
    /// 카운트 구성 요소
    /// </summary>
    public class RxCountDownTimer : MonoBehaviour
    {
        /// <summary>
        /// 카운트 다운 스트림
        /// 이 Observable을 각 클래스가 Subscribe 한다.
        /// </summary>
        public IObservable<int> CountDownObservable => _countDownObservable.AsObservable();
        
        private IConnectableObservable<int> _countDownObservable;
    
        // 60초 카운트 스트림을 생성
        // Publish로 Hot 변환
        private void Awake() => 
            _countDownObservable = CreateCountDownObservable(60).Publish();
    
        // start시 카운트 시작
        private void Start() =>
            _countDownObservable.Connect();
    
        /// <summary>
        /// countTime 만큼 카운트 다운하는 스트림
        /// </summary>
        /// <param name="countTime"></param>
        /// <returns></returns>
        private IObservable<int> CreateCountDownObservable(int countTime) =>
            Observable
                .Timer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1))
                .Select(x => (int) (countTime - x))
                .TakeWhile(x => x > 0);
    }

    CountDownTextComponent.cs

    /// <summary>
    /// 타이머의 시간을 기초로 Text를 업데이트 할 컴포넌트
    /// </summary>
    public class CountDownTextComponent : MonoBehaviour
    {
        /// <summary>
        /// UnityEditor에서 할당 한다.
        /// </summary>
        [SerializeField] private RxCountDownTimer _rxCountDownTimer;
    
        /// <summary>
        /// uGUI의 Text
        /// </summary>
        private Text _text;
    
        private void Start()
        {
            _text = GetComponent<Text>();
            
            // 타이머의 남은 시간을 표시한다.
            _rxCountDownTimer
                .CountDownObservable
                .Subscribe(time =>
                {
                    // onNext에서 시간을 표시한다.
                    _text.text = $"남은 시간 : {time}";
                }, () =>
                {
                    // onComplete에서 문자를 지운다.
                    _text.text = string.Empty;
                }).AddTo(gameObject);
            
            // 타이머가 10초 이하로 되는 타이밍에 색을 붉은 색으로 한다.
            _rxCountDownTimer
                .CountDownObservable
                .First(timer => timer <= 10)
                .Subscribe(_ => _text.color = Color.red);
        }
    }

    CountDownSoundComponent.cs

    [RequireComponent(typeof(AudioSource))]
    public class CountDownSoundComponent : MonoBehaviour
    {
        // 효과음
        [SerializeField] private AudioClip _seCountDownTick;
        [SerializeField] private AudioClip _seCountDownEnd;
        
        private AudioSource _audioSource;
    
        /// <summary>
        /// UnityEditor에서 할당 한다.
        /// </summary>
        [SerializeField] private RxCountDownTimer _rxCountDownTimer;
    
        private void Start()
        {
            _audioSource = GetComponent<AudioSource>();
            
            // 카운트가 10초 이하가 되면 효과음을 1초마다 울리게 한다.
            _rxCountDownTimer
                .CountDownObservable
                .Where(time => time <= 10)
                .Subscribe(_ => _audioSource.PlayOneShot(_seCountDownTick));
            
            // 계산이 완료된 시점에서 효과음을 울린다.
            _rxCountDownTimer
                .CountDownObservable
                .Subscribe(_ => { ; }, () => _audioSource.PlayOneShot((_seCountDownEnd)));
        }
    }

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

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

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

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

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

    변경할 목록

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

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

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

    public class GameTimerManager : MonoBehaviour
    {
        /// <summary>
        /// 경기 시작 전 카운트 다운
        /// </summary>
        public IObservable<int> GameStartCountDownObservable { get; private set; }
        
        /// <summary>
        /// 경기 중 카운트 다운
        /// </summary>
        public IObservable<int> BattleCountDownObservable { get; private set; }
    
        private void Start()
        {
            // 경기 전 3초 타이머
            // 3초 타이머의 스트림을 Publish로 Hot으로 변환 (아직 Connect는 하지 않는다)
            var startConnectableObservable = CreateCountDownObservable(3).Publish();
            // 외부에 공개하기 위해 Observable로 저장
            GameStartCountDownObservable = startConnectableObservable;
            
            // 경기 중 60초 타이머
            // 60초 타이머의 스트림을 Publish로 Hot으로 변환 (아직 Connect는 하지 않는다)
            var battleConnectableObservable = CreateCountDownObservable(60).Publish();
            // 외부에 공개하기 위해 Observable로 저장
            BattleCountDownObservable = battleConnectableObservable;
            
            // 3초 타이머의 OnComplete에서 60초 타이머를 Connect한다 (60초 타이머 시작)
            GameStartCountDownObservable
                .Subscribe(_ => { ; }, () => battleConnectableObservable.Connect());
            
            // 60초 타이머 뒤에 Concat으로 5초 타이머를 연결하고 OnComplete에서 Scene를 전환한다.
            BattleCountDownObservable
                .Concat(CreateCountDownObservable(5))
                .Subscribe(_ => { ; }, () =>
                {
                    SceneManager.LoadScene("NextScene");
                }).AddTo(gameObject);
            
            // 3초 타이머 시작
            startConnectableObservable.Connect();
        }
        
        /// <summary>
        /// countTime 만큼 카운트 다운하는 스트림
        /// </summary>
        /// <param name="countTime"></param>
        /// <returns></returns>
        private IObservable<int> CreateCountDownObservable(int countTime) =>
            Observable
                .Timer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1))
                .Select(x => (int) (countTime - x))
                .TakeWhile(x => x > 0);
    }

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

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

    정리​

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